Source code
Revision control
Copy as Markdown
Other Tools
/* 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
package mozilla.components.browser.state.engine.middleware
import kotlin.coroutines.resume
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.InitAction
import mozilla.components.browser.state.action.LocaleAction
import mozilla.components.browser.state.action.TranslationsAction
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.ai.AIFeaturesRuntime
import mozilla.components.concept.engine.translate.Language
import mozilla.components.concept.engine.translate.LanguageModel
import mozilla.components.concept.engine.translate.LanguageModel.Companion.areModelsProcessing
import mozilla.components.concept.engine.translate.LanguageSetting
import mozilla.components.concept.engine.translate.ModelManagementOptions
import mozilla.components.concept.engine.translate.ModelOperation
import mozilla.components.concept.engine.translate.ModelState
import mozilla.components.concept.engine.translate.TranslationDownloadSize
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.findLanguage
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.Store
import mozilla.components.support.base.log.logger.Logger
/**
* This middleware is for use with managing any states or resources required for translating a webpage.
*
* @param automaticallyInitialize If true, translations state will be initialized automatically when [InitAction] is
* dispatched.
* @param isTranslationsEnabled The user preference for whether we should enable translations or not.
*/
@Suppress("LargeClass")
class TranslationsMiddleware(
private val engine: Engine,
private val scope: CoroutineScope,
private val automaticallyInitialize: Boolean = true,
private val isTranslationsEnabled: suspend () -> Boolean,
) : Middleware<BrowserState, BrowserAction> {
private val logger = Logger("TranslationsMiddleware")
@Suppress("LongMethod", "CyclomaticComplexMethod", "CognitiveComplexMethod")
override fun invoke(
store: Store<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction,
) {
// Pre process actions
when (action) {
is InitAction ->
if (automaticallyInitialize) {
store.dispatch(TranslationsAction.InitTranslationsBrowserState)
}
is LocaleAction.UpdateLocaleAction -> {
logger.info("Detected app locale change.")
scope.launch {
// This information is dependent on the app language
requestSupportedLanguages(store, null)
requestLanguageModels(store, null)
}
}
is TranslationsAction.InitTranslationsBrowserState -> {
scope.launch {
val engineIsSupported = requestEngineSupport(store)
val translationsIsEnabled = isTranslationsEnabled()
// The default of [TranslationsBrowserState.isTranslationsEnabled] is true,
// only set when false to prevent an initialization cycle.
if (!translationsIsEnabled) {
store.dispatch(TranslationsAction.SetTranslationsEnabledAction(false))
}
if (engineIsSupported == true && translationsIsEnabled) {
initializeBrowserStore(store)
}
}
}
is TranslationsAction.SetTranslationsEnabledAction -> {
scope.launch {
// Notify the browser translations engine to enable or disable translations on that level.
val browserError = setBrowserTranslationsEnabled(action.isTranslationsEnabled)
if (action.isTranslationsEnabled) {
store.dispatch(TranslationsAction.InitTranslationsBrowserState)
}
if (browserError != null) {
store.dispatch(
TranslationsAction.EngineExceptionAction(
error = TranslationError.CouldNotSetBrowserEnabledError(browserError)
)
)
}
}
}
is TranslationsAction.TranslateExpectedAction -> {
requestDefaultModelDownloadSize(store, action.tabId)
}
is TranslationsAction.OperationRequestedAction -> {
when (action.operation) {
TranslationOperation.FETCH_SUPPORTED_LANGUAGES -> {
scope.launch {
requestSupportedLanguages(store, action.tabId)
}
}
TranslationOperation.FETCH_LANGUAGE_MODELS -> {
scope.launch {
requestLanguageModels(store, action.tabId)
}
}
TranslationOperation.FETCH_PAGE_SETTINGS -> {
val tabId = action.tabId ?: store.state.selectedTab?.id
if (action.tabId == null) {
logger.warn("Passed null tabId to FETCH_PAGE_SETTINGS, " + "Will use current selected tab.")
}
if (tabId != null) {
scope.launch {
store.state.selectedTab?.let {
requestPageSettings(store, it.id)
}
}
} else {
logger.warn(
"Passed null tabId to FETCH_PAGE_SETTINGS, " +
"and no selected tab was available. Performing no action."
)
}
}
TranslationOperation.FETCH_OFFER_SETTING -> {
scope.launch {
requestOfferSetting(store, action.tabId)
}
}
TranslationOperation.FETCH_AUTOMATIC_LANGUAGE_SETTINGS -> {
scope.launch {
requestLanguageSettings(store, action.tabId)
}
}
TranslationOperation.FETCH_NEVER_TRANSLATE_SITES -> {
scope.launch {
requestNeverTranslateSites(store, action.tabId)
}
}
TranslationOperation.TRANSLATE,
TranslationOperation.RESTORE -> Unit
}
}
is TranslationsAction.FetchTranslationDownloadSizeAction -> {
scope.launch {
requestTranslationSize(
store = store,
tabId = action.tabId,
fromLanguage = action.fromLanguage,
toLanguage = action.toLanguage,
)
}
}
is TranslationsAction.RemoveNeverTranslateSiteAction -> {
scope.launch {
removeNeverTranslateSite(store, action.origin)
}
}
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(
store = store,
tabId = action.tabId,
setting = action.setting,
settingType = LanguageSetting.ALWAYS,
)
}
TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_LANGUAGE ->
scope.launch {
updateLanguagePageSetting(
store = store,
tabId = action.tabId,
setting = action.setting,
settingType = LanguageSetting.NEVER,
)
}
TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_SITE ->
scope.launch {
updateNeverTranslateSitePageSetting(
store = store,
tabId = action.tabId,
setting = action.setting,
)
}
}
}
is TranslationsAction.UpdateGlobalOfferTranslateSettingAction -> {
scope.launch {
updateAlwaysOfferPopupPageSetting(setting = action.offerTranslation)
}
}
is TranslationsAction.UpdateLanguageSettingsAction -> {
scope.launch {
updateLanguageSetting(
store = store,
languageCode = action.languageCode,
setting = action.setting,
)
}
}
is TranslationsAction.ManageLanguageModelsAction -> {
scope.launch {
updateLanguageModel(
store = store,
options = action.options,
)
}
}
else -> {
// no-op
}
}
// Continue to post process actions
next(action)
}
/**
* Use this to initialize translations data for [BrowserState.translationEngine]. If an issue occurs, the relevant
* error will be set on [BrowserState.translationEngine].
*
* This will populate: Language Support - [requestSupportedLanguages] Language Models - [requestLanguageModels]
* Language Settings - [requestLanguageSettings] Never Translate Sites List - [requestNeverTranslateSites] Offer
* Setting - [requestOfferSetting]
*
* @param store Store to be initialised.
*/
private fun initializeBrowserStore(store: Store<BrowserState, BrowserAction>) {
requestSupportedLanguages(store)
requestLanguageModels(store)
requestLanguageSettings(store)
requestNeverTranslateSites(store)
requestOfferSetting(store)
}
/**
* Checks if the translations engine supports the device architecture and updates the state on
* [BrowserState.translationEngine].
*
* @param store Store to dispatch translation actions to.
* @return Whether the engine is supported or not, or null when the support cannot be determined.
*/
private suspend fun requestEngineSupport(store: Store<BrowserState, BrowserAction>): Boolean? {
return suspendCancellableCoroutine { continuation ->
engine.isTranslationsEngineSupported(
onSuccess = { isEngineSupported ->
store.dispatch(TranslationsAction.SetEngineSupportedAction(isEngineSupported = isEngineSupported))
logger.info("Success requesting engine support. isEngineSupported: $isEngineSupported")
continuation.resume(isEngineSupported)
},
onError = { error ->
store.dispatch(
TranslationsAction.EngineExceptionAction(
error = TranslationError.UnknownEngineSupportError(error)
)
)
logger.error("Error requesting engine support: ", error)
continuation.resume(null)
},
)
continuation.invokeOnCancellation {
// cancel the engine support check if the Engine exposes a cancellation API
}
}
}
/**
* Retrieves the list of supported languages and dispatches the result to the [BrowserState.translationEngine] via
* [TranslationsAction.SetSupportedLanguagesAction] or else dispatches the failure.
*
* For failure dispatching: If a tab ID is not provided, then only [TranslationsAction.EngineExceptionAction] will
* be dispatched to set the error on the [BrowserState.translationEngine].
*
* If a tab ID is provided, then [TranslationsAction.EngineExceptionAction] AND
* [TranslationsAction.TranslateExceptionAction] will be dispatched to set the error both on the
* [BrowserState.translationEngine] and [SessionState.translationsState].
*
* @param store Store to use to dispatch to the store.
* @param tabId If a Tab ID associated with the request for error handling. If null, this will only dispatch errors
* on the global translations browser state.
*/
private fun requestSupportedLanguages(
store: Store<BrowserState, BrowserAction>,
tabId: String? = null,
) {
engine.getSupportedTranslationLanguages(
onSuccess = {
store.dispatch(TranslationsAction.SetSupportedLanguagesAction(supportedLanguages = it))
// Ensures error is cleared, if a tab made this request.
if (tabId != null) {
store.dispatch(
TranslationsAction.TranslateSuccessAction(
tabId = tabId,
operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
)
)
}
logger.info("Success requesting supported languages.")
},
onError = {
store.dispatch(
TranslationsAction.EngineExceptionAction(error = TranslationError.CouldNotLoadLanguagesError(it))
)
if (tabId != null) {
store.dispatch(
TranslationsAction.TranslateExceptionAction(
tabId = tabId,
operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
translationError = TranslationError.CouldNotLoadLanguagesError(it),
)
)
}
logger.error("Error requesting supported languages: ", it)
},
)
}
/**
* Retrieves the list of language machine learning translation models the translation engine has available and
* dispatches the result to the [BrowserState.translationEngine] via [TranslationsAction.SetLanguageModelsAction] or
* else dispatches the failure.
*
* For failure dispatching: If a tab ID is not provided, then only [TranslationsAction.EngineExceptionAction] will
* be dispatched to set the error on the [BrowserState.translationEngine].
*
* If a tab ID is provided, then [TranslationsAction.EngineExceptionAction] AND
* [TranslationsAction.TranslateExceptionAction] will be dispatched to set the error both on the
* [BrowserState.translationEngine] and [SessionState.translationsState].
*
* @param store Store to use to dispatch to the store.
* @param tabId If a Tab ID associated with the request for error handling. If null, this will only dispatch errors
* on the global translations browser state.
*/
private fun requestLanguageModels(
store: Store<BrowserState, BrowserAction>,
tabId: String? = null,
) {
engine.getTranslationsModelDownloadStates(
onSuccess = {
store.dispatch(TranslationsAction.SetLanguageModelsAction(languageModels = it))
logger.info("Success requesting language models.")
},
onError = { error ->
store.dispatch(
TranslationsAction.EngineExceptionAction(error = TranslationError.ModelCouldNotRetrieveError(error))
)
if (tabId != null) {
store.dispatch(
TranslationsAction.TranslateExceptionAction(
tabId = tabId,
operation = TranslationOperation.FETCH_LANGUAGE_MODELS,
translationError = TranslationError.ModelCouldNotRetrieveError(error),
)
)
}
logger.error("Error requesting language models: ", error)
},
)
}
/**
* Retrieves the list of never translate sites and dispatches the result to the store via
* [TranslationsAction.SetNeverTranslateSitesAction] or else dispatches the failure via
* [TranslationsAction.EngineExceptionAction] and when a [tabId] is provided,
* [TranslationsAction.TranslateExceptionAction].
*
* @param store Store to use to dispatch to the store.
* @param tabId Tab ID associated with the request.
*/
private fun requestNeverTranslateSites(
store: Store<BrowserState, BrowserAction>,
tabId: String? = null,
) {
engine.getNeverTranslateSiteList(
onSuccess = {
store.dispatch(TranslationsAction.SetNeverTranslateSitesAction(neverTranslateSites = it))
logger.info("Success requesting never translate sites.")
},
onError = {
store.dispatch(
TranslationsAction.EngineExceptionAction(
error = TranslationError.CouldNotLoadNeverTranslateSites(it)
)
)
if (tabId != null) {
store.dispatch(
TranslationsAction.TranslateExceptionAction(
tabId = tabId,
operation = TranslationOperation.FETCH_NEVER_TRANSLATE_SITES,
translationError = TranslationError.CouldNotLoadNeverTranslateSites(it),
)
)
}
logger.error("Error requesting never translate sites: ", it)
},
)
}
/**
* Removes the site from the list of never translate sites using [scope] and dispatches the result to the store via
* [TranslationsAction.SetNeverTranslateSitesAction] or else dispatches the failure
* [TranslationsAction.TranslateExceptionAction].
*
* @param store Store to use to dispatch to the store.
* @param origin A site origin URI that will have the specified never translate permission set.
*/
private fun removeNeverTranslateSite(
store: Store<BrowserState, BrowserAction>,
origin: String,
) {
engine.setNeverTranslateSpecifiedSite(
origin = origin,
setting = false,
onSuccess = {
logger.info("Success changing never translate sites.")
},
onError = {
logger.error("Error removing site from never translate list: ", it)
// Fetch never translate sites to ensure the state matches the engine, because it
// was proactively removed in the reducer.
requestNeverTranslateSites(store)
},
)
}
/**
* Retrieves the page settings and dispatches the result to the store via [TranslationsAction.SetPageSettingsAction]
* or else dispatches the failure [TranslationsAction.TranslateExceptionAction].
*
* @param store Store to use to dispatch to the store.
* @param tabId Tab ID associated with the request.
*/
private suspend fun requestPageSettings(
store: Store<BrowserState, BrowserAction>,
tabId: String,
) {
logger.info("Requesting page settings.")
// Always offer setting
val alwaysOfferPopup: Boolean = engine.getTranslationsOfferPopup()
// Page language settings
val pageLanguage =
store.state.findTab(tabId)?.translationsState?.translationEngineState?.detectedLanguages?.documentLangTag
val setting = pageLanguage?.let { getLanguageSetting(it) }
val alwaysTranslateLanguage = setting?.toBoolean(LanguageSetting.ALWAYS)
val neverTranslateLanguage = setting?.toBoolean(LanguageSetting.NEVER)
// Never translate site
val engineSession = store.state.findTab(tabId)?.engineState?.engineSession
val neverTranslateSite = engineSession?.let { getNeverTranslateSiteSetting(it) }
if (alwaysTranslateLanguage != null && neverTranslateLanguage != null && neverTranslateSite != null) {
logger.info("Successfully found all page settings.")
store.dispatch(
TranslationsAction.SetPageSettingsAction(
tabId = tabId,
pageSettings =
TranslationPageSettings(
alwaysOfferPopup = alwaysOfferPopup,
alwaysTranslateLanguage = alwaysTranslateLanguage,
neverTranslateLanguage = neverTranslateLanguage,
neverTranslateSite = neverTranslateSite,
),
)
)
} else {
logger.error("Could not find all page settings.")
// Any null values indicate something went wrong, alert an error occurred
store.dispatch(
TranslationsAction.TranslateExceptionAction(
tabId = tabId,
operation = TranslationOperation.FETCH_PAGE_SETTINGS,
translationError = TranslationError.CouldNotLoadPageSettingsError(null),
)
)
}
}
/**
* Retrieves the setting to always offer to translate and dispatches the result to the store via
* [TranslationsAction.SetGlobalOfferTranslateSettingAction]. Will additionally dispatch a request to update page
* settings, when a [tabId] is provided.
*
* @param store Store to use to dispatch to the store.
* @param tabId Tab ID associated with the request.
*/
private fun requestOfferSetting(
store: Store<BrowserState, BrowserAction>,
tabId: String? = null,
) {
logger.info("Requesting offer setting.")
val alwaysOfferPopup: Boolean = engine.getTranslationsOfferPopup()
store.dispatch(TranslationsAction.SetGlobalOfferTranslateSettingAction(offerTranslation = alwaysOfferPopup))
if (tabId != null) {
// Fetch page settings to ensure the state matches the engine.
store.dispatch(
TranslationsAction.OperationRequestedAction(
tabId = tabId,
operation = TranslationOperation.FETCH_PAGE_SETTINGS,
)
)
}
}
/**
* Fetches the always or never language setting synchronously from the engine. Will return null if an error occurs.
*
* @param pageLanguage Page language to check the translation preferences for.
* @return The page translate language setting or null.
*/
private suspend fun getLanguageSetting(pageLanguage: String): LanguageSetting? {
return suspendCancellableCoroutine { continuation ->
engine.getLanguageSetting(
languageCode = pageLanguage,
onSuccess = { setting ->
logger.info("Success requesting language settings.")
continuation.resume(setting)
},
onError = {
logger.error("Could not retrieve language settings: $it")
continuation.resume(null)
},
)
continuation.invokeOnCancellation {
// cancel the language setting request when the Engine exposes a cancellation API.
}
}
}
/**
* Retrieves the list of languages and their settings and dispatches the result to the
* [BrowserState.translationEngine] via [TranslationsAction.SetLanguageSettingsAction] or else dispatches the
* failure.
*
* For failure dispatching: If a tab ID is not provided, then only [TranslationsAction.EngineExceptionAction] will
* be dispatched to set the error on the [BrowserState.translationEngine].
*
* If a tab ID is provided, then [TranslationsAction.EngineExceptionAction] AND
* [TranslationsAction.TranslateExceptionAction] will be dispatched to set the error both on the
* [BrowserState.translationEngine] and [SessionState.translationsState].
*
* @param store Store to use to dispatch to the store.
* @param tabId If a Tab ID is associated with the request for error handling. If null, this will only dispatch
* errors on the global translations browser state.
*/
private fun requestLanguageSettings(
store: Store<BrowserState, BrowserAction>,
tabId: String? = null,
) {
engine.getLanguageSettings(
onSuccess = { settings ->
store.dispatch(TranslationsAction.SetLanguageSettingsAction(languageSettings = settings))
logger.info("Success requesting language settings.")
},
onError = {
store.dispatch(
TranslationsAction.EngineExceptionAction(
error = TranslationError.CouldNotLoadLanguageSettingsError(it)
)
)
if (tabId != null) {
store.dispatch(
TranslationsAction.TranslateExceptionAction(
tabId = tabId,
operation = TranslationOperation.FETCH_AUTOMATIC_LANGUAGE_SETTINGS,
translationError = TranslationError.CouldNotLoadLanguageSettingsError(it),
)
)
}
logger.error("Error requesting language settings: ", it)
},
)
}
/**
* Fetches the never translate site setting synchronously from the [EngineSession]. Will return null if an error
* occurs.
*
* @param engineSession With page context on how to complete this operation.
* @return The never translate site setting from the [EngineSession] or null.
*/
private suspend fun getNeverTranslateSiteSetting(engineSession: EngineSession): Boolean? {
return suspendCancellableCoroutine { continuation ->
engineSession.getNeverTranslateSiteSetting(
onResult = { setting ->
logger.info("Success requesting never translate site settings.")
continuation.resume(setting)
},
onException = {
logger.error("Could not retrieve never translate site settings: $it")
continuation.resume(null)
},
)
continuation.invokeOnCancellation {
// cancel the never-translate-site request when the EngineSession exposes a cancellation API
}
}
}
/**
* Retrieves the download size and dispatches the result to the [SessionState.translationsState] on the
* [BrowserStore] via [TranslationsAction.SetTranslationDownloadSizeAction].
*
* @param store Store to use to dispatch to the store.
* @param tabId Tab ID associated with the request.
* @param fromLanguage The from language to request the translation download size for.
* @param toLanguage The to language to request the translation download size for.
*/
private fun requestTranslationSize(
store: Store<BrowserState, BrowserAction>,
tabId: String,
fromLanguage: Language,
toLanguage: Language,
) {
engine.getTranslationsPairDownloadSize(
fromLanguage = fromLanguage.code,
toLanguage = toLanguage.code,
onSuccess = { size ->
store.dispatch(
TranslationsAction.SetTranslationDownloadSizeAction(
tabId = tabId,
translationSize =
TranslationDownloadSize(
fromLanguage = fromLanguage,
toLanguage = toLanguage,
size = size,
error = null,
),
)
)
logger.info("Success requesting download size.")
},
onError = { error ->
store.dispatch(
TranslationsAction.SetTranslationDownloadSizeAction(
tabId = tabId,
translationSize =
TranslationDownloadSize(
fromLanguage = fromLanguage,
toLanguage = toLanguage,
size = null,
error = TranslationError.CouldNotDetermineDownloadSizeError(null),
),
)
)
logger.error("Error requesting download size: ", error)
},
)
}
/**
* Fetches the expected translation model download size assuming the user intends to complete a translation using
* the detected default `from` (page language) and `to` (user preferred) languages.
*
* If the detected default languages are available, then this will fetch and set the corresponding model download
* size on [SessionState.translationsState].
*
* If no defaults are available, then no action will occur.
*
* @param store Store to use to dispatch to the store.
* @param tabId Tab ID associated with the request.
*/
private fun requestDefaultModelDownloadSize(
store: Store<BrowserState, BrowserAction>,
tabId: String,
) {
val fromLanguage = getDefaultFromLanguage(store, tabId) ?: return
val toLanguage = getDefaultToLanguage(store, tabId) ?: return
store.dispatch(
TranslationsAction.FetchTranslationDownloadSizeAction(
tabId = tabId,
fromLanguage = fromLanguage,
toLanguage = toLanguage,
)
)
}
/**
* 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 store The store 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(
store: Store<BrowserState, BrowserAction>,
tabId: String,
setting: Boolean,
settingType: LanguageSetting,
) {
logger.info("Preparing to update the translations language preference.")
val pageLanguage =
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.
store.dispatch(
TranslationsAction.OperationRequestedAction(
tabId = tabId,
operation = TranslationOperation.FETCH_PAGE_SETTINGS,
)
)
} else {
logger.info("Updating language setting.")
updateLanguageSetting(store, tabId, pageLanguage, convertedSetting)
}
}
/**
* Updates the language settings with the [Engine].
*
* If an error occurs, and a [tabId] is known then the method will request the page settings be re-fetched and set
* on the browser store.
*
* @param store The store 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(
store: Store<BrowserState, BrowserAction>,
tabId: String? = null,
languageCode: String,
setting: LanguageSetting,
) {
logger.info("Setting the translations language preference.")
engine.setLanguageSetting(
languageCode = languageCode,
languageSetting = setting,
onSuccess = {
// Value was proactively updated in [TranslationsStateReducer] for
// [TranslationsBrowserState.languageSettings]
if (tabId != null) {
// Ensure the session's page settings remain in sync with this update.
store.dispatch(
TranslationsAction.OperationRequestedAction(
tabId = tabId,
operation = TranslationOperation.FETCH_AUTOMATIC_LANGUAGE_SETTINGS,
)
)
}
logger.info("Successfully updated the language preference.")
},
onError = {
logger.error("Could not update the language preference.", it)
// The browser store [TranslationsBrowserState.languageSettings] is out of sync,
// re-request to sync the state.
requestLanguageSettings(store, tabId)
if (tabId != null) {
// Fetch page settings to ensure the state matches the engine.
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 store The store 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(
store: Store<BrowserState, BrowserAction>,
tabId: String,
setting: Boolean,
) {
val engineSession = 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.
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.
store.dispatch(
TranslationsAction.OperationRequestedAction(
tabId = tabId,
operation = TranslationOperation.FETCH_PAGE_SETTINGS,
)
)
},
)
}
}
/**
* Helper to find the default "from" language for a site using the page detected language and engine supported
* languages.
*
* @param store The store used to request the information from the store.
* @param tabId Tab ID associated with the request.
* @return The default expected translate "from" language, which is the page language or null if unavailable or an
* unsupported language by the engine.
*/
private fun getDefaultFromLanguage(
store: Store<BrowserState, BrowserAction>,
tabId: String,
): Language? {
val pageLang =
store.state.findTab(tabId)?.translationsState?.translationEngineState?.detectedLanguages?.documentLangTag
?: return null
val supportedLanguages = store.state.translationEngine.supportedLanguages ?: return null
return supportedLanguages.findLanguage(pageLang)
}
/**
* Helper to find the default "to" language using the user's preferred language and engine supported languages.
*
* @param store The store used to request the information from the store.
* @param tabId Tab ID associated with the request.
* @return The default translate "to" language, which is the user's preferred language or null if unavailable or an
* unsupported language by the engine.
*/
private fun getDefaultToLanguage(
store: Store<BrowserState, BrowserAction>,
tabId: String,
): Language? {
val userPreferredLang =
store.state
.findTab(tabId)
?.translationsState
?.translationEngineState
?.detectedLanguages
?.userPreferredLangTag ?: return null
val supportedLanguages = store.state.translationEngine.supportedLanguages ?: return null
return supportedLanguages.findLanguage(userPreferredLang)
}
/**
* Requests the language model updates occur on the [Engine].
*
* Examples of operations include downloading and deleting individual models, all models, or the cache.
*
* @param store The store used to update the language models.
* @param options The change and specified language models that should change state.
*/
@Suppress("CognitiveComplexMethod")
private fun updateLanguageModel(
store: Store<BrowserState, BrowserAction>,
options: ModelManagementOptions,
) {
logger.info("Requesting the translations engine update the language model(s).")
engine.manageTranslationsLanguageModel(
options = options,
onSuccess = {
// Value was set to a wait state in [TranslationsStateReducer] for
// [TranslationsBrowserState.languageModels], so we need to resolve the state.
val processState =
if (options.operation == ModelOperation.DOWNLOAD) {
ModelState.DOWNLOADED
} else {
ModelState.NOT_DOWNLOADED
}
val newModelState =
LanguageModel.determineNewLanguageModelState(
appLanguage = store.state.locale?.language.toString(),
currentLanguageModels = store.state.translationEngine.languageModels,
options = options,
newStatus = processState,
)
if (newModelState != null) {
store.dispatch(TranslationsAction.SetLanguageModelsAction(languageModels = newModelState))
if (!areModelsProcessing(newModelState)) {
// Refresh state to ensure we have the latest model sizes.
// Sizes can change if pivots are required and acquired or deleted.
requestLanguageModels(store)
}
logger.info("Successfully updated the language model(s).")
} else {
logger.warn(
"The model(s) were updated with the engine, " +
"but unexpectedly could not update state. " +
"Re-requesting state be retrieved from the engine."
)
// Unexpectedly lost state, so check with the engine to put it back in-sync.
requestLanguageModels(store)
}
},
onError = { error ->
logger.error("Could not update the language model(s).", error)
// Value was set to a wait state in [TranslationsStateReducer] for
// [TranslationsBrowserState.languageModels], so we need to set an error state.
val errorState =
if (options.operation == ModelOperation.DOWNLOAD) {
ModelState.ERROR_DOWNLOAD
} else {
ModelState.ERROR_DELETION
}
val errorModelState =
LanguageModel.determineNewLanguageModelState(
appLanguage = store.state.locale?.language.toString(),
currentLanguageModels = store.state.translationEngine.languageModels,
options = options,
newStatus = errorState,
)
if (errorModelState != null) {
store.dispatch(TranslationsAction.SetLanguageModelsAction(languageModels = errorModelState))
logger.info("Successfully set the language model(s) error state.")
} else {
logger.warn(
"Unexpectedly could not update error state. " +
"Re-requesting state be retrieved from the engine."
)
// Unexpectedly lost state, so check with the engine to put it back in-sync.
requestLanguageModels(store)
}
store.dispatch(
TranslationsAction.EngineExceptionAction(error = TranslationError.LanguageModelUpdateError(error))
)
},
)
}
/**
* Notifies the browser engine to enable or disable the translations feature via [AIFeaturesRuntime].
*
* @param isEnabled Whether translations should be enabled.
* @return Null on success, or the [Throwable] reported by the engine on failure.
*/
private suspend fun setBrowserTranslationsEnabled(isEnabled: Boolean): Throwable? {
return suspendCancellableCoroutine { continuation ->
engine.aiFeatures.setFeatureEnablement(
featureId = "translations",
isEnabled = isEnabled,
onSuccess = {
logger.info("Successfully set browser translations enabled to $isEnabled.")
continuation.resume(null)
},
onError = { error ->
logger.error("Error setting browser translations enabled.")
continuation.resume(error)
},
)
continuation.invokeOnCancellation {
// cancel the feature enablement request when the AIFeaturesRuntime exposes a cancellation API
}
}
}
}