added support for translations and printing

This commit is contained in:
Fabian Freund
2026-03-05 07:29:15 +01:00
parent ce014e40bf
commit c3e39b3a1d
28 changed files with 3547 additions and 2837 deletions
@@ -227,8 +227,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
val engineView = createEngine(components)
fragmentEngineView = engineView // Track for lifecycle management
val originalContext = ActivityContextWrapper.getOriginalContext(requireActivity())
?.let { ProfileContext(it, components.profileApplicationContext.relativePath) }
val originalContext =
ActivityContextWrapper.getOriginalContext(requireActivity()) ?: requireActivity()
val engineNativeView = engineView.asView()
engineNativeView.layoutParams = layoutParams
@@ -27,6 +27,9 @@ import eu.weblibre.flutter_mozilla_components.pigeons.ReaderableState
import eu.weblibre.flutter_mozilla_components.pigeons.RestoreLocation
import eu.weblibre.flutter_mozilla_components.pigeons.SecurityInfoState
import eu.weblibre.flutter_mozilla_components.pigeons.TabContentState
import eu.weblibre.flutter_mozilla_components.pigeons.TabTranslationStateData
import eu.weblibre.flutter_mozilla_components.pigeons.TranslationEngineStateData
import eu.weblibre.flutter_mozilla_components.pigeons.TranslationLanguage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -39,6 +42,7 @@ import mozilla.components.browser.icons.IconRequest
import mozilla.components.browser.session.storage.RecoverableBrowserState
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.TabListAction
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.LastMediaAccessState
@@ -55,6 +59,7 @@ import org.json.JSONObject
import org.mozilla.gecko.util.ThreadUtils.runOnUiThread
import mozilla.components.feature.addons.logger
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.translate.TranslationOperation
class GeckoTabsApiImpl : GeckoTabsApi {
companion object {
@@ -185,6 +190,7 @@ class GeckoTabsApiImpl : GeckoTabsApi {
onPageExtensionsChange: Boolean,
onBrowserExtensionIcons: Boolean,
onPageExtensionIcons: Boolean,
onTranslationStateChange: Boolean,
) {
try {
val tabs = components.core.store.state.tabs.map { it.copy() }
@@ -293,11 +299,85 @@ class GeckoTabsApiImpl : GeckoTabsApi {
onPageExtensionIcons
)
}
// Sync translation events
if (onTranslationStateChange) {
syncTranslationEvents(tabs)
}
} catch (e: Exception) {
logger.error("$TAG: Failed to sync events", e)
}
}
private fun syncTranslationEvents(tabs: List<mozilla.components.browser.state.state.TabSessionState>) {
try {
val browserState = components.core.store.state
// Retry initialization when support status is still unknown.
if (browserState.translationEngine.isEngineSupported == null) {
components.core.store.dispatch(TranslationsAction.InitTranslationsBrowserState)
}
// Fetch supported languages when engine is supported but languages are still missing.
if (
browserState.translationEngine.isEngineSupported == true &&
(
browserState.translationEngine.supportedLanguages?.fromLanguages == null ||
browserState.translationEngine.supportedLanguages?.toLanguages == null
)
) {
components.core.store.dispatch(
TranslationsAction.OperationRequestedAction(
tabId = browserState.selectedTabId,
operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
)
)
}
val translationEngine = browserState.translationEngine
// Emit browser-level engine state
val fromLanguages = translationEngine.supportedLanguages?.fromLanguages?.map { lang ->
TranslationLanguage(code = lang.code, localizedDisplayName = lang.localizedDisplayName ?: lang.code)
}
val toLanguages = translationEngine.supportedLanguages?.toLanguages?.map { lang ->
TranslationLanguage(code = lang.code, localizedDisplayName = lang.localizedDisplayName ?: lang.code)
}
components.flutterEvents.onTranslationEngineStateChange(
EventSequence.next(),
TranslationEngineStateData(
isEngineSupported = translationEngine.isEngineSupported,
fromLanguages = fromLanguages,
toLanguages = toLanguages,
)
) { }
// Emit per-tab translation state
tabs.forEach { tab ->
val translationsState = tab.translationsState
components.flutterEvents.onTabTranslationStateChange(
EventSequence.next(),
TabTranslationStateData(
tabId = tab.id,
isTranslated = translationsState.isTranslated,
isTranslateProcessing = translationsState.isTranslateProcessing,
isOfferTranslate = translationsState.isOfferTranslate,
isExpectedTranslate = translationsState.isExpectedTranslate,
detectedLanguageCode = translationsState.translationEngineState?.detectedLanguages?.documentLangTag,
userPreferredLanguageCode = translationsState.translationEngineState?.detectedLanguages?.userPreferredLangTag,
requestedFromLanguage = translationsState.translationEngineState?.requestedTranslationPair?.fromLanguage,
requestedToLanguage = translationsState.translationEngineState?.requestedTranslationPair?.toLanguage,
translationErrorName = translationsState.translationError?.errorName,
displayError = translationsState.translationError?.displayError,
)
) { }
}
} catch (e: Exception) {
logger.error("$TAG: Failed to sync translation events", e)
}
}
private fun syncExtensionEvents(
onBrowserExtensionsChange: Boolean,
onPageExtensionsChange: Boolean,
@@ -30,6 +30,7 @@ import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.browser.session.storage.SessionStorage
import mozilla.components.browser.state.engine.EngineMiddleware
import mozilla.components.browser.state.engine.middleware.SessionPrioritizationMiddleware
import mozilla.components.browser.state.engine.middleware.TranslationsMiddleware
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.storage.sync.PlacesBookmarksStorage
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
@@ -67,6 +68,7 @@ import mozilla.components.concept.base.crash.Breadcrumb
import mozilla.components.concept.base.crash.CrashReporting
import mozilla.components.support.base.worker.Frequency
import org.mozilla.geckoview.GeckoRuntime
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.Job
import java.util.concurrent.TimeUnit
@@ -207,6 +209,9 @@ class Core(
SaveToPDFMiddleware(),
FileUploadsDirCleanerMiddleware(fileUploadsDirCleaner),
LastMediaAccessMiddleware(),
// Keep parity with Fenix: do not initialize translations on store init.
// EngineObserver will dispatch InitTranslationsBrowserState after first completed page load.
TranslationsMiddleware(engine, MainScope(), false),
) + EngineMiddleware.create(
engine,
// We are disabling automatic suspending of engine sessions under memory pressure.
@@ -23,6 +23,9 @@ import eu.weblibre.flutter_mozilla_components.pigeons.ShareTarget
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetFiles
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetParams
import eu.weblibre.flutter_mozilla_components.pigeons.TabContentState
import eu.weblibre.flutter_mozilla_components.pigeons.TabTranslationStateData
import eu.weblibre.flutter_mozilla_components.pigeons.TranslationEngineStateData
import eu.weblibre.flutter_mozilla_components.pigeons.TranslationLanguage
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
@@ -222,6 +225,71 @@ class Events(
}
}
// Translation engine state (browser-level)
stateFlow.flowScoped { flow ->
flow.map { state -> state.translationEngine }
.distinctUntilChanged { old, new ->
old.isEngineSupported == new.isEngineSupported &&
old.supportedLanguages == new.supportedLanguages
}
.debounce(50)
.collect { translationEngine ->
val fromLanguages = translationEngine.supportedLanguages?.fromLanguages?.map { lang ->
TranslationLanguage(code = lang.code, localizedDisplayName = lang.localizedDisplayName ?: lang.code)
}
val toLanguages = translationEngine.supportedLanguages?.toLanguages?.map { lang ->
TranslationLanguage(code = lang.code, localizedDisplayName = lang.localizedDisplayName ?: lang.code)
}
flutterEvents.onTranslationEngineStateChange(
EventSequence.next(),
TranslationEngineStateData(
isEngineSupported = translationEngine.isEngineSupported,
fromLanguages = fromLanguages,
toLanguages = toLanguages,
)
) { _ -> }
}
}
// Per-tab translation state
stateFlow.flowScoped { flow ->
flow.mapNotNull { state -> state.tabs }
.filterChanged {
it.translationsState
}
.ifAnyChanged {
arrayOf(
it.translationsState.isTranslated,
it.translationsState.isTranslateProcessing,
it.translationsState.isOfferTranslate,
it.translationsState.isExpectedTranslate,
it.translationsState.translationEngineState,
it.translationsState.translationError,
)
}
.debounce(25)
.collect { tab ->
val ts = tab.translationsState
flutterEvents.onTabTranslationStateChange(
EventSequence.next(),
TabTranslationStateData(
tabId = tab.id,
isTranslated = ts.isTranslated,
isTranslateProcessing = ts.isTranslateProcessing,
isOfferTranslate = ts.isOfferTranslate,
isExpectedTranslate = ts.isExpectedTranslate,
detectedLanguageCode = ts.translationEngineState?.detectedLanguages?.documentLangTag,
userPreferredLanguageCode = ts.translationEngineState?.detectedLanguages?.userPreferredLangTag,
requestedFromLanguage = ts.translationEngineState?.requestedTranslationPair?.fromLanguage,
requestedToLanguage = ts.translationEngineState?.requestedTranslationPair?.toLanguage,
translationErrorName = ts.translationError?.errorName,
displayError = ts.translationError?.displayError,
)
) { _ -> }
}
}
// PWA manifest availability events - following Fenix MenuPresenter pattern
stateFlow.flowScoped { flow ->
flow.mapNotNull { state -> state.selectedTab }
@@ -24,6 +24,15 @@ class SaveToPDFMiddleware : Middleware<BrowserState, BrowserAction> {
is EngineAction.SaveToPdfExceptionAction -> {
logger.error("Unable to save PDF", action.throwable)
}
is EngineAction.PrintContentAction -> {
next(action)
}
is EngineAction.PrintContentCompletedAction -> {
logger.info("Print completed successfully")
}
is EngineAction.PrintContentExceptionAction -> {
logger.error("Unable to print content", action.throwable)
}
else -> {
next(action)
}
@@ -94,9 +94,15 @@ export 'src/pigeons/gecko.g.dart'
SyncRemoteTab,
TabContent,
TabContentState,
TabTranslationStateData,
TrackingProtectionException,
TrackingProtectionPolicy,
TrackingScope,
TranslationDetectedLanguages,
TranslationEngineStateData,
TranslationLanguage,
TranslationOptions,
TranslationPair,
UnknownHitResult,
VideoHitResult,
VisitInfo,
@@ -21,6 +21,8 @@ typedef FindResultsEvent = ({String tabId, List<FindResultState> results});
typedef LongPressEvent = ({String tabId, HitResult hitResult});
typedef ScrollEvent = ({String tabId, int scrollY});
typedef ManifestUpdateEvent = ({String tabId, PwaManifest? manifest});
typedef TabTranslationEvent = ({String tabId, TabTranslationStateData state});
typedef TranslationEngineEvent = TranslationEngineStateData;
class GeckoEventService extends GeckoStateEvents {
// Stream controllers
@@ -46,6 +48,9 @@ class GeckoEventService extends GeckoStateEvents {
final _tabAddedSubject = PublishSubject<String>();
final _mlProgressSubject = PublishSubject<MlProgressData>();
final _manifestUpdateSubject = PublishSubject<ManifestUpdateEvent>();
final _translationEngineSubject =
BehaviorSubject<TranslationEngineEvent>();
final _tabTranslationSubject = ReplaySubject<TabTranslationEvent>();
// Event streams
ValueStream<bool> get viewReadyStateEvents => _viewStateSubject.stream;
@@ -72,6 +77,10 @@ class GeckoEventService extends GeckoStateEvents {
Stream<MlProgressData> get mlProgressEvents => _mlProgressSubject.stream;
Stream<ManifestUpdateEvent> get manifestUpdateEvents =>
_manifestUpdateSubject.stream;
ValueStream<TranslationEngineEvent> get translationEngineEvents =>
_translationEngineSubject.stream;
Stream<TabTranslationEvent> get tabTranslationEvents =>
_tabTranslationSubject.stream;
@override
void onViewReadyStateChange(int sequence, bool state) {
@@ -210,6 +219,25 @@ class GeckoEventService extends GeckoStateEvents {
));
}
@override
void onTranslationEngineStateChange(
int sequence,
TranslationEngineStateData state,
) {
_translationEngineSubject.addWhenMoreRecent(sequence, null, state);
}
@override
void onTabTranslationStateChange(
int sequence,
TabTranslationStateData state,
) {
_tabTranslationSubject.addWhenMoreRecent(sequence, state.tabId, (
tabId: state.tabId,
state: state,
));
}
GeckoEventService.setUp({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
@@ -241,5 +269,7 @@ class GeckoEventService extends GeckoStateEvents {
await _siteAssignementSubject.close();
await _mlProgressSubject.close();
await _manifestUpdateSubject.close();
await _translationEngineSubject.close();
await _tabTranslationSubject.close();
}
}
@@ -29,6 +29,7 @@ class GeckoTabService {
bool onPageExtensionsChange = false,
bool onBrowserExtensionIcons = false,
bool onPageExtensionIcons = false,
bool onTranslationStateChange = false,
}) {
return _api.syncEvents(
onSelectedTabChange: onSelectedTabChange,
@@ -44,6 +45,7 @@ class GeckoTabService {
onPageExtensionsChange: onPageExtensionsChange,
onBrowserExtensionIcons: onBrowserExtensionIcons,
onPageExtensionIcons: onPageExtensionIcons,
onTranslationStateChange: onTranslationStateChange,
);
}
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,77 @@ class TranslationOptions {
TranslationOptions({this.downloadModel = true});
}
/// A language supported by the translation engine.
class TranslationLanguage {
final String code;
final String localizedDisplayName;
TranslationLanguage({required this.code, required this.localizedDisplayName});
}
/// Detected languages for a page.
class TranslationDetectedLanguages {
final String? documentLangTag;
final bool? supportedDocumentLang;
final String? userPreferredLangTag;
TranslationDetectedLanguages({
this.documentLangTag,
this.supportedDocumentLang,
this.userPreferredLangTag,
});
}
/// A from/to language pair for translation.
class TranslationPair {
final String fromLanguage;
final String toLanguage;
TranslationPair({required this.fromLanguage, required this.toLanguage});
}
/// Browser-level translation engine state (global).
class TranslationEngineStateData {
final bool? isEngineSupported;
final List<TranslationLanguage?>? fromLanguages;
final List<TranslationLanguage?>? toLanguages;
TranslationEngineStateData({
this.isEngineSupported,
this.fromLanguages,
this.toLanguages,
});
}
/// Per-tab translation state.
class TabTranslationStateData {
final String tabId;
final bool isTranslated;
final bool isTranslateProcessing;
final bool isOfferTranslate;
final bool isExpectedTranslate;
final String? detectedLanguageCode;
final String? userPreferredLanguageCode;
final String? requestedFromLanguage;
final String? requestedToLanguage;
final String? translationErrorName;
final bool? displayError;
TabTranslationStateData({
required this.tabId,
required this.isTranslated,
required this.isTranslateProcessing,
required this.isOfferTranslate,
required this.isExpectedTranslate,
this.detectedLanguageCode,
this.userPreferredLanguageCode,
this.requestedFromLanguage,
this.requestedToLanguage,
this.translationErrorName,
this.displayError,
});
}
/// Value type that represents the state of reader mode/view.
class ReaderState {
/// Whether or not the current page can be transformed to
@@ -1256,6 +1327,7 @@ abstract class GeckoTabsApi {
required bool onPageExtensionsChange,
required bool onBrowserExtensionIcons,
required bool onPageExtensionIcons,
required bool onTranslationStateChange,
});
void selectTab({required String tabId});
@@ -1562,6 +1634,15 @@ abstract class GeckoStateEvents {
void onMlProgress(int sequence, MlProgressData progress);
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest);
void onTranslationEngineStateChange(
int sequence,
TranslationEngineStateData state,
);
void onTabTranslationStateChange(
int sequence,
TabTranslationStateData state,
);
}
@FlutterApi()