Skip to content

Commit bdf008d

Browse files
authored
Add FXIOS-13844 [Translations] language detection to middleware (#30407)
1 parent 2796dc2 commit bdf008d

File tree

11 files changed

+177
-65
lines changed

11 files changed

+177
-65
lines changed

firefox-ios/Client.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,7 @@
14061406
AAD861A82E9E748700F6E0E0 /* TranslationSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD861A72E9E748100F6E0E0 /* TranslationSetting.swift */; };
14071407
AAD861AB2E9E75AB00F6E0E0 /* TranslationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD861AA2E9E75A100F6E0E0 /* TranslationSettingsViewController.swift */; };
14081408
AAD9D64B2EB25B3500BFECAF /* TranslationsMiddlewareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD9D64A2EB25B3200BFECAF /* TranslationsMiddlewareTests.swift */; };
1409+
AADBD0E42EBBA79B00F35E30 /* MockLanguageDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AADBD0E32EBBA79700F35E30 /* MockLanguageDetector.swift */; };
14091410
AB2AC6632BCFD0A200022AAB /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = AB2AC6622BCFD0A200022AAB /* X509 */; };
14101411
AB2AC6662BD15E6300022AAB /* CertificatesHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2AC6652BD15E6300022AAB /* CertificatesHandler.swift */; };
14111412
AB3DB0C92B596739001D32CB /* AppStartupTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3DB0C82B596739001D32CB /* AppStartupTelemetry.swift */; };
@@ -9520,6 +9521,7 @@
95209521
AAD861A72E9E748100F6E0E0 /* TranslationSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationSetting.swift; sourceTree = "<group>"; };
95219522
AAD861AA2E9E75A100F6E0E0 /* TranslationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationSettingsViewController.swift; sourceTree = "<group>"; };
95229523
AAD9D64A2EB25B3200BFECAF /* TranslationsMiddlewareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationsMiddlewareTests.swift; sourceTree = "<group>"; };
9524+
AADBD0E32EBBA79700F35E30 /* MockLanguageDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLanguageDetector.swift; sourceTree = "<group>"; };
95239525
AAF64ABAB8A585208603D45B /* dsb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = dsb; path = dsb.lproj/LoginManager.strings; sourceTree = "<group>"; };
95249526
AB2AC6652BD15E6300022AAB /* CertificatesHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatesHandler.swift; sourceTree = "<group>"; };
95259527
AB2B45078A7F1E09F65ACEC5 /* cy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cy; path = cy.lproj/Shared.strings; sourceTree = "<group>"; };
@@ -14167,6 +14169,7 @@
1416714169
AAD9D6492EB25B2700BFECAF /* TranslationsTests */ = {
1416814170
isa = PBXGroup;
1416914171
children = (
14172+
AADBD0E32EBBA79700F35E30 /* MockLanguageDetector.swift */,
1417014173
C27EF61E2EBA395000BED719 /* MockLanguageSampleSource.swift */,
1417114174
C27EF6142EBA2EDD00BED719 /* LanguageDetectorTests.swift */,
1417214175
AAD9D64A2EB25B3200BFECAF /* TranslationsMiddlewareTests.swift */,
@@ -19369,6 +19372,7 @@
1936919372
39BF0CD72EB512CD002064F5 /* ImpressionTrackingUtilityTests.swift in Sources */,
1937019373
21FA8FB02AE856590013B815 /* RemoteTabsCoordinatorTests.swift in Sources */,
1937119374
0E6557402D82232B00D7F017 /* DownloadProgressManagerTests.swift in Sources */,
19375+
AADBD0E42EBBA79B00F35E30 /* MockLanguageDetector.swift in Sources */,
1937219376
AAB434062E82F0600075E47F /* MockTrendingSearchProvider.swift in Sources */,
1937319377
C80C11F428B3CD580062922A /* MockUserDefaultsTests.swift in Sources */,
1937419378
E14D6D342CCBA9FF0058B910 /* AddressBarStateTests.swift in Sources */,

firefox-ios/Client/Frontend/Browser/Toolbars/Redux/AddressBarState.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,10 +1054,10 @@ struct AddressBarState: StateType, Sendable, Equatable {
10541054
let shouldShowTranslationIcon = canTranslateFromAction || canTranslateFromState
10551055
guard shouldShowTranslationIcon else { return nil }
10561056
let configuration = action.translationConfiguration ?? addressBarState.translationConfiguration
1057-
guard let configuration else { return nil }
1057+
guard let state = configuration?.state else { return nil }
10581058
return translateAction(
10591059
enabled: isLoading == false,
1060-
configuration: configuration,
1060+
state: state,
10611061
hasAlternativeLocationColor: hasAlternativeLocationColor
10621062
)
10631063
}
@@ -1308,22 +1308,22 @@ struct AddressBarState: StateType, Sendable, Equatable {
13081308
// when switching from inactive icon to loading icon when user taps on it. Hence, `hasHighlightedColor: false`.
13091309
private static func translateAction(
13101310
enabled: Bool,
1311-
configuration: TranslationConfiguration,
1311+
state: TranslationConfiguration.IconState,
13121312
hasAlternativeLocationColor: Bool
13131313
) -> ToolbarActionConfiguration {
13141314
// We do not want to use template mode for translate active icon.
1315-
let isActiveState = configuration.state == .active
1315+
let isActiveState = state == .active
13161316

13171317
return ToolbarActionConfiguration(
13181318
actionType: .translate,
1319-
iconName: configuration.state.buttonImageName,
1319+
iconName: state.buttonImageName,
13201320
templateModeForImage: !isActiveState,
1321-
shouldUseLoadingSpinner: configuration.state == .loading,
1321+
shouldUseLoadingSpinner: state == .loading,
13221322
isEnabled: enabled,
13231323
hasCustomColor: !hasAlternativeLocationColor,
13241324
hasHighlightedColor: false,
13251325
contextualHintType: ContextualHintType.translation.rawValue,
1326-
a11yLabel: configuration.state.buttonA11yLabel,
1326+
a11yLabel: state.buttonA11yLabel,
13271327
a11yId: AccessibilityIdentifiers.Toolbar.translateButton
13281328
)
13291329
}

firefox-ios/Client/Frontend/Browser/Toolbars/Redux/TranslationsConfiguration.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ struct TranslationConfiguration: Equatable, FeatureFlaggable {
4141
}
4242

4343
let prefs: Prefs
44-
let state: IconState
44+
let state: IconState?
4545

46-
init(prefs: Prefs, state: IconState = .inactive) {
46+
// We initially set icon state as nil until we can detect the
47+
// web page and determine if we should show the translation icon
48+
// and set the icon to .inactive state.
49+
init(prefs: Prefs, state: IconState? = nil) {
4750
self.prefs = prefs
4851
self.state = state
4952
}

firefox-ios/Client/Frontend/Translations/LanguageDetector/LanguageDetector.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,31 @@ import NaturalLanguage
77

88
/// A small utility for extracting a text sample from a web page and detecting its language.
99
/// The sample is extracted using JS.
10-
final class LanguageDetector {
10+
protocol LanguageDetectorProvider: Sendable {
11+
func detectLanguage(from source: LanguageSampleSource) async throws -> String?
12+
}
13+
14+
final class LanguageDetector: LanguageDetectorProvider {
1115
/// JS function that is called to get a page sample back. The function is implemented in `Summarizer.js`.
1216
private let languageSampleScript =
1317
"return await window.__firefox__.Translations.getLanguageSampleWhenReady()"
1418

19+
func detectLanguage(from source: LanguageSampleSource) async throws -> String? {
20+
let sample = try await extractSample(from: source)
21+
guard let textSample = sample else { return nil }
22+
return getDominantLanguage(of: textSample)
23+
}
24+
1525
/// Extracts a text sample from the page via the JS bridge.
1626
/// Returns `nil` if the bridge isn’t ready or no sample is available.
17-
@MainActor
18-
func extractSample(from source: LanguageSampleSource) async throws -> String? {
27+
private func extractSample(from source: LanguageSampleSource) async throws -> String? {
1928
let sample = try await source.getLanguageSample(scriptEvalExpression: languageSampleScript)
2029
guard let sample = sample, !sample.isEmpty else { return nil }
2130
return sample
2231
}
2332

2433
/// Detects the dominant language of a given text and returns its BCP-47 code (e.g. `"en"`, `"fr"`).
25-
func detectLanguage(of text: String) -> String? {
34+
private func getDominantLanguage(of text: String) -> String? {
2635
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
2736
guard !trimmed.isEmpty else { return nil }
2837
return NLLanguageRecognizer.dominantLanguage(for: trimmed)?.rawValue

firefox-ios/Client/Frontend/Translations/LanguageDetector/LanguageSampleSource.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Foundation
77
/// A small abstraction for obtaining a language sample from webpage content.
88
/// This is done to allow `LanguageDetector` to request text from a page without knowing
99
/// whether the source is a real `WKWebView` running JavaScript or a test mock.
10-
protocol LanguageSampleSource {
10+
protocol LanguageSampleSource: Sendable {
1111
/// `scriptEvalExpression` is the JavaScript expression that should be evaluated
1212
/// by the underlying implementation.
1313
@MainActor

firefox-ios/Client/Frontend/Translations/TranslationsMiddleware.swift

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@ import Common
1010
final class TranslationsMiddleware {
1111
private let profile: Profile
1212
private let logger: Logger
13+
private let languageDetector: LanguageDetectorProvider
14+
private let windowManager: WindowManager
1315

1416
init(profile: Profile = AppContainer.shared.resolve(),
15-
logger: Logger = DefaultLogger.shared) {
17+
languageDetector: LanguageDetectorProvider = LanguageDetector(),
18+
logger: Logger = DefaultLogger.shared,
19+
windowManager: WindowManager = AppContainer.shared.resolve()
20+
) {
1621
self.profile = profile
22+
self.languageDetector = languageDetector
1723
self.logger = logger
24+
self.windowManager = windowManager
1825
}
1926

2027
lazy var translationsProvider: Middleware<AppState> = { state, action in
@@ -104,21 +111,31 @@ final class TranslationsMiddleware {
104111
store.dispatch(toolbarAction)
105112
}
106113

107-
// TODO: FXIOS-13844 - Check if we can translate a page based on certain eligibility
108-
@MainActor
114+
/// Checks whether the current page in the active tab is eligible for translation,
115+
/// and if so, dispatches a toolbar action to update the translation state.
109116
private func checkTranslationsAreEligible(for action: ToolbarAction) {
110-
// We dispatch an action for now, but eventually we want to inject a script
111-
// to check if the page language differs from our locale language.
112-
guard action.translationConfiguration?.canTranslate == true else { return }
113-
let toolbarAction = ToolbarAction(
114-
translationConfiguration: TranslationConfiguration(
115-
prefs: profile.prefs,
116-
state: .inactive
117-
),
118-
windowUUID: action.windowUUID,
119-
actionType: ToolbarActionType.receivedTranslationLanguage
120-
)
121-
store.dispatch(toolbarAction)
117+
Task { @MainActor in
118+
guard action.translationConfiguration?.canTranslate == true else { return }
119+
120+
guard let selectedTab = self.windowManager.tabManager(for: action.windowUUID).selectedTab,
121+
let webView = selectedTab.webView
122+
else { return }
123+
124+
let languageSampleSource = WebViewLanguageSampleSource(webView: webView)
125+
let pageLanguage = try await languageDetector.detectLanguage(from: languageSampleSource)
126+
127+
guard let pageLanguage, pageLanguage != Locale.current.languageCode else { return }
128+
129+
let toolbarAction = ToolbarAction(
130+
translationConfiguration: TranslationConfiguration(
131+
prefs: profile.prefs,
132+
state: .inactive
133+
),
134+
windowUUID: action.windowUUID,
135+
actionType: ToolbarActionType.receivedTranslationLanguage
136+
)
137+
store.dispatch(toolbarAction)
138+
}
122139
}
123140

124141
// TODO: FXIOS-13844 - Start translation a page and dispatch action after completion

firefox-ios/firefox-ios-tests/Tests/ClientTests/Toolbar/AddressBarStateTests.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,10 @@ final class AddressBarStateTests: XCTestCase, StoreTestUtility {
391391
initialState,
392392
ToolbarAction(
393393
url: URL(string: "http://mozilla.com"),
394-
translationConfiguration: TranslationConfiguration(prefs: mockProfile.prefs),
394+
translationConfiguration: TranslationConfiguration(
395+
prefs: mockProfile.prefs,
396+
state: .inactive
397+
),
395398
windowUUID: windowUUID,
396399
actionType: ToolbarActionType.urlDidChange
397400
)

firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/LanguageDetectorTests.swift

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,61 +9,80 @@ import XCTest
99
final class LanguageDetectorTests: XCTestCase {
1010
var mockLanguageSampleSource = MockLanguageSampleSource()
1111

12-
func testExtractSampleReturnsTextSample() async throws {
12+
func test_detectLanguage_withFrench_returnsProperLanguageCode() async throws {
1313
mockLanguageSampleSource.mockResult = "Bonjour le monde"
1414
let subject = createSubject()
15-
let result = try await subject.extractSample(from: mockLanguageSampleSource)
16-
XCTAssertEqual(result, "Bonjour le monde")
15+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
16+
XCTAssertEqual(result, "fr")
1717
}
1818

19-
func testExtractSampleReturnsNilForEmptyString() async throws {
19+
func test_detectLanguage_withEnglish_returnsProperLanguageCode() async throws {
20+
mockLanguageSampleSource.mockResult = "Hello world"
21+
let subject = createSubject()
22+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
23+
XCTAssertEqual(result, "en")
24+
}
25+
26+
func test_detectLanguage_withSpanish_returnsProperLanguageCode() async throws {
27+
mockLanguageSampleSource.mockResult = "Hola mundo"
28+
let subject = createSubject()
29+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
30+
XCTAssertEqual(result, "es")
31+
}
32+
33+
func test_detectLanguage_withJapanese_returnsProperLanguageCode() async throws {
34+
mockLanguageSampleSource.mockResult = "こんにちは世界"
35+
let subject = createSubject()
36+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
37+
XCTAssertEqual(result, "ja")
38+
}
39+
40+
func test_detectLanguage_withKorean_returnsProperLanguageCode() async throws {
41+
mockLanguageSampleSource.mockResult = "안녕하세요 세계"
42+
let subject = createSubject()
43+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
44+
XCTAssertEqual(result, "ko")
45+
}
46+
47+
func test_detectLanguage_withEmptyString_returnsNil() async throws {
2048
mockLanguageSampleSource.mockResult = ""
2149
let subject = createSubject()
22-
let result = try await subject.extractSample(from: mockLanguageSampleSource)
50+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
2351
XCTAssertNil(result)
2452
}
2553

26-
func testExtractSampleReturnsNilForNonString() async throws {
54+
func test_detectLanguage_withWhitespaces_returnsNil() async throws {
55+
mockLanguageSampleSource.mockResult = " \n\t "
56+
let subject = createSubject()
57+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
58+
XCTAssertNil(result)
59+
}
60+
61+
func test_detectLanguage_returnsNilForNonString() async throws {
2762
mockLanguageSampleSource.mockResult = 42
2863
let subject = createSubject()
29-
let result = try await subject.extractSample(from: mockLanguageSampleSource)
64+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
3065
XCTAssertNil(result)
3166
}
3267

33-
func testExtractSamplePropagatesError() async {
68+
func test_detectLanguage_propagatesError() async {
3469
enum FakeError: Error, Equatable { case foo }
3570
mockLanguageSampleSource.mockError = FakeError.foo
3671
let subject = createSubject()
3772

3873
do {
39-
_ = try await subject.extractSample(from: mockLanguageSampleSource)
74+
_ = try await subject.detectLanguage(from: mockLanguageSampleSource)
4075
XCTFail("expected error")
4176
} catch {
4277
XCTAssertEqual(error as? FakeError, .foo)
4378
}
4479
}
4580

46-
func testDetectLanguageReturnsLanguageCode() {
47-
let subject = createSubject()
48-
49-
XCTAssertEqual(subject.detectLanguage(of: "Hello world"), "en")
50-
XCTAssertEqual(subject.detectLanguage(of: "Bonjour le monde"), "fr")
51-
XCTAssertEqual(subject.detectLanguage(of: "Hola mundo"), "es")
52-
XCTAssertEqual(subject.detectLanguage(of: "こんにちは世界"), "ja")
53-
XCTAssertEqual(subject.detectLanguage(of: "안녕하세요 세계"), "ko")
54-
}
55-
56-
func testDetectLanguageReturnsNilForEmptyOrWhitespace() {
57-
let subject = createSubject()
58-
59-
XCTAssertNil(subject.detectLanguage(of: ""))
60-
XCTAssertNil(subject.detectLanguage(of: " \n\t "))
61-
}
62-
63-
func testDetectLanguagePrefersDominantLanguage() {
81+
func test_detectLanguage_prefersDominantLanguage() async throws {
6482
let subject = createSubject()
65-
let text = "Hello, bonjour, hello, hello"
66-
XCTAssertEqual(subject.detectLanguage(of: text), "en")
83+
mockLanguageSampleSource.mockResult = "Hello, bonjour, hello, hello"
84+
let result = try await subject.detectLanguage(from: mockLanguageSampleSource)
85+
XCTAssertEqual(result, "en")
6786
}
6887

6988
private func createSubject() -> LanguageDetector {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
import Foundation
5+
@testable import Client
6+
7+
/// Test helper that simulates language detection.
8+
final class MockLanguageDetector: LanguageDetectorProvider, @unchecked Sendable {
9+
var detectLanguageCallCount = 0
10+
func detectLanguage(from source: LanguageSampleSource) async throws -> String? {
11+
detectLanguageCallCount += 1
12+
return "ja"
13+
}
14+
}

firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockLanguageSampleSource.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Foundation
66
@testable import Client
77

88
/// Test helper that simulates JS evaluation for language sample extraction.
9-
final class MockLanguageSampleSource: LanguageSampleSource {
9+
final class MockLanguageSampleSource: LanguageSampleSource, @unchecked Sendable {
1010
var mockResult: Any?
1111
var mockError: Error?
1212

0 commit comments

Comments
 (0)