diff --git a/firefox-ios/Client/Application/RemoteSettings/Application Services/ASTranslationModelsFetcher.swift b/firefox-ios/Client/Application/RemoteSettings/Application Services/ASTranslationModelsFetcher.swift index 291c39a76dce2..3cd8c6c97366d 100644 --- a/firefox-ios/Client/Application/RemoteSettings/Application Services/ASTranslationModelsFetcher.swift +++ b/firefox-ios/Client/Application/RemoteSettings/Application Services/ASTranslationModelsFetcher.swift @@ -38,6 +38,7 @@ protocol TranslationModelsFetcherProtocol: Sendable { func fetchModelBuffer(recordId: String) async -> Data? func prewarmResources(for sourceLang: String, to targetLang: String) async func fetchSupportedTargetLanguages() async -> [String] + func resetStorage() async } final class ASTranslationModelsFetcher: TranslationModelsFetcherProtocol { @@ -215,6 +216,20 @@ final class ASTranslationModelsFetcher: TranslationModelsFetcherProtocol { .uniqued() } + /// Resets any local storage of models. + func resetStorage() async { + guard let client = modelsClient else { + logger.log("Models client not available, skipping reset.", level: .warning, category: .remoteSettings) + return + } + + do { + try client.resetStorage() + } catch { + logger.log("Resetting storage for models failed with error \(error.localizedDescription).", level: .warning, category: .remoteSettings) + } + } + /// Pre-warms attachments for a list of records by fetching them /// Calling this method multiple times for the same attachment pair is safe /// since attachments will be fetched from network only once and then cached. diff --git a/firefox-ios/Client/Coordinators/SettingsCoordinator.swift b/firefox-ios/Client/Coordinators/SettingsCoordinator.swift index 9bc23f1883c22..52b5b74321fa7 100644 --- a/firefox-ios/Client/Coordinators/SettingsCoordinator.swift +++ b/firefox-ios/Client/Coordinators/SettingsCoordinator.swift @@ -378,11 +378,10 @@ final class SettingsCoordinator: BaseCoordinator, // MARK: GeneralSettingsDelegate func pressedAIControls() { - let model = AIControlsModel(prefs: profile.prefs) + let model = AIControlsModel(prefs: profile.prefs, windowUUID: windowUUID) let viewController = UIHostingController( rootView: AIControlsSettingsView( - windowUUID: windowUUID, aiControlsModel: model ) ) diff --git a/firefox-ios/Client/Frontend/Settings/AIControls/AIControlsModel.swift b/firefox-ios/Client/Frontend/Settings/AIControls/AIControlsModel.swift index ccefa33461bf1..f1b16dff18334 100644 --- a/firefox-ios/Client/Frontend/Settings/AIControls/AIControlsModel.swift +++ b/firefox-ios/Client/Frontend/Settings/AIControls/AIControlsModel.swift @@ -6,6 +6,7 @@ import Shared import Common class AIControlsModel: ObservableObject, LegacyFeatureFlaggable { + let windowUUID: WindowUUID @Published var killSwitchIsOn = false @Published var translationEnabled: Bool @Published var pageSummariesEnabled: Bool @@ -51,10 +52,12 @@ class AIControlsModel: ObservableObject, LegacyFeatureFlaggable { init( prefs: Prefs, + windowUUID: WindowUUID, translationConfiguration: TranslationConfiguration? = nil, summarizerConfiguration: SummarizerNimbusUtils = DefaultSummarizerNimbusUtils() ) { self.prefs = prefs + self.windowUUID = windowUUID self.translationConfiguration = translationConfiguration ?? TranslationConfiguration(prefs: prefs) self.summarizerConfiguration = summarizerConfiguration @@ -67,24 +70,26 @@ class AIControlsModel: ObservableObject, LegacyFeatureFlaggable { killSwitchIsOn = featureFlags.isFeatureEnabled(.aiKillSwitch, checking: .buildAndUser) } + @MainActor func toggleKillSwitch(to newValue: Bool) { prefs.setBool(newValue, forKey: PrefsKeys.Settings.aiKillSwitchFeature) - switch newValue { - case false: - pageSummariesEnabled = true - translationEnabled = true - prefs.setBool(true, forKey: PrefsKeys.Settings.translationsFeature) - prefs.setBool(true, forKey: PrefsKeys.Summarizer.summarizeContentFeature) - case true: - pageSummariesEnabled = false - translationEnabled = false - prefs.setBool(false, forKey: PrefsKeys.Settings.translationsFeature) - prefs.setBool(false, forKey: PrefsKeys.Summarizer.summarizeContentFeature) - } + pageSummariesEnabled = !newValue + translationEnabled = !newValue + prefs.setBool(!newValue, forKey: PrefsKeys.Summarizer.summarizeContentFeature) + store.dispatch(TranslationSettingsViewAction( + newSettingValue: !newValue, + windowUUID: windowUUID, + actionType: TranslationSettingsViewActionType.toggleTranslationsEnabled + )) } + @MainActor func toggleTranslationsFeature(to newValue: Bool) { - prefs.setBool(newValue, forKey: PrefsKeys.Settings.translationsFeature) + store.dispatch(TranslationSettingsViewAction( + newSettingValue: newValue, + windowUUID: windowUUID, + actionType: TranslationSettingsViewActionType.toggleTranslationsEnabled + )) } func togglePageSummariesFeature(to newValue: Bool) { diff --git a/firefox-ios/Client/Frontend/Settings/AIControls/AIControlsSettingsView.swift b/firefox-ios/Client/Frontend/Settings/AIControls/AIControlsSettingsView.swift index aec04e1cef33e..84bf2fbf941ca 100644 --- a/firefox-ios/Client/Frontend/Settings/AIControls/AIControlsSettingsView.swift +++ b/firefox-ios/Client/Frontend/Settings/AIControls/AIControlsSettingsView.swift @@ -6,7 +6,6 @@ import Common import Shared struct AIControlsSettingsView: View, ThemeApplicable { - let windowUUID: WindowUUID @ObservedObject var aiControlsModel: AIControlsModel // MARK: - Theming @@ -70,8 +69,8 @@ struct AIControlsSettingsView: View, ThemeApplicable { aiControlsModel.togglePageSummariesFeature(to: newValue) }) .onReceive(NotificationCenter.default.publisher(for: .ThemeDidChange)) { notification in - guard let uuid = notification.windowUUID, uuid == windowUUID else { return } - applyTheme(theme: themeManager.getCurrentTheme(for: windowUUID)) + guard let uuid = notification.windowUUID, uuid == aiControlsModel.windowUUID else { return } + applyTheme(theme: themeManager.getCurrentTheme(for: aiControlsModel.windowUUID)) } } @@ -215,7 +214,6 @@ private struct RoundedCard: View { #Preview { AIControlsSettingsView( - windowUUID: WindowUUID.DefaultUITestingUUID, - aiControlsModel: AIControlsModel(prefs: MockProfilePrefs()) + aiControlsModel: AIControlsModel(prefs: MockProfilePrefs(), windowUUID: WindowUUID.DefaultUITestingUUID) ) } diff --git a/firefox-ios/Client/Frontend/Settings/Translation/TranslationSettingsAction.swift b/firefox-ios/Client/Frontend/Settings/Translation/TranslationSettingsAction.swift index 3aa35e3b5b792..ab1b8875746b7 100644 --- a/firefox-ios/Client/Frontend/Settings/Translation/TranslationSettingsAction.swift +++ b/firefox-ios/Client/Frontend/Settings/Translation/TranslationSettingsAction.swift @@ -11,15 +11,18 @@ struct TranslationSettingsViewAction: Action { let languageCode: String? let languages: [String]? let pendingLanguages: [PreferredLanguageDetails]? + let newSettingValue: Bool? init(languageCode: String? = nil, languages: [String]? = nil, pendingLanguages: [PreferredLanguageDetails]? = nil, + newSettingValue: Bool? = nil, windowUUID: WindowUUID, actionType: ActionType) { self.languageCode = languageCode self.languages = languages self.pendingLanguages = pendingLanguages + self.newSettingValue = newSettingValue self.windowUUID = windowUUID self.actionType = actionType } @@ -66,4 +69,5 @@ enum TranslationSettingsViewActionType: ActionType { enum TranslationSettingsMiddlewareActionType: ActionType { case didLoadSettings case didUpdateSettings + case didResetStorage } diff --git a/firefox-ios/Client/Frontend/Settings/Translation/TranslationSettingsMiddleware.swift b/firefox-ios/Client/Frontend/Settings/Translation/TranslationSettingsMiddleware.swift index 3c53c876cedbd..ac9029ba517a3 100644 --- a/firefox-ios/Client/Frontend/Settings/Translation/TranslationSettingsMiddleware.swift +++ b/firefox-ios/Client/Frontend/Settings/Translation/TranslationSettingsMiddleware.swift @@ -53,7 +53,8 @@ final class TranslationSettingsMiddleware { case TranslationSettingsViewActionType.toggleTranslationsEnabled: let current = prefs.boolForKey(PrefsKeys.Settings.translationsFeature) ?? true - let newValue = !current + // TODO: FXIOS-15421 Always configure new setting value instead of toggling pref + let newValue = action.newSettingValue ?? !current prefs.setBool(newValue, forKey: PrefsKeys.Settings.translationsFeature) SettingsTelemetry().changedSetting( PrefsKeys.Settings.translationsFeature, @@ -70,7 +71,16 @@ final class TranslationSettingsMiddleware { windowUUID: action.windowUUID, actionType: TranslationSettingsMiddlewareActionType.didUpdateSettings )) - + if !newValue { + Task { + await modelsFetcher.resetStorage() + store.dispatch(TranslationSettingsMiddlewareAction( + isTranslationsEnabled: newValue, + windowUUID: action.windowUUID, + actionType: TranslationSettingsMiddlewareActionType.didResetStorage + )) + } + } case TranslationSettingsViewActionType.toggleAutoTranslate: let newValue = !isAutoTranslateEnabled prefs.setBool(newValue, forKey: PrefsKeys.Settings.translationAutoTranslate) diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/AI Controls/AIControlsModelTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/AI Controls/AIControlsModelTests.swift index be1e8176d4697..951b7841b1e93 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/AI Controls/AIControlsModelTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/AI Controls/AIControlsModelTests.swift @@ -7,11 +7,13 @@ import Shared @testable import Client -class AIControlsModelTests: XCTestCase { +class AIControlsModelTests: XCTestCase, StoreTestUtility { + private var mockStore: MockStoreForMiddleware! var mockPrefs: MockProfilePrefs! override func setUp() async throws { try await super.setUp() + setupStore() let mockProfile = MockProfile(databasePrefix: "test") mockPrefs = MockProfilePrefs(things: [ PrefsKeys.Summarizer.summarizeContentFeature: true, @@ -23,6 +25,11 @@ class AIControlsModelTests: XCTestCase { await DependencyHelperMock().bootstrapDependencies(injectedProfile: mockProfile) } + override func tearDown() async throws { + resetStore() + try await super.tearDown() + } + @MainActor func testHeaderLinkInfo() { let aiControlsModel = createSubject(prefs: mockPrefs) @@ -78,7 +85,10 @@ class AIControlsModelTests: XCTestCase { } @MainActor - func testToggleKillSwitchOn() { + func testToggleKillSwitchOn() throws { + let expectation = XCTestExpectation(description: "toggleTranslationsEnabled dispatched") + expectation.expectedFulfillmentCount = 1 + mockStore.dispatchCalled = { expectation.fulfill() } mockPrefs = MockProfilePrefs(things: [ PrefsKeys.Summarizer.summarizeContentFeature: true, PrefsKeys.Settings.translationsFeature: false, @@ -107,10 +117,18 @@ class AIControlsModelTests: XCTestCase { } else { XCTFail("No pref value for translations feature") } + + wait(for: [expectation], timeout: 1.0) + let action = try XCTUnwrap(mockStore.dispatchedActions.last as? TranslationSettingsViewAction) + XCTAssertFalse(try XCTUnwrap(action.newSettingValue)) } @MainActor - func testToggleKillSwitchOff() { + func testToggleKillSwitchOff() throws { + let expectation = XCTestExpectation(description: "toggleTranslationsEnabled dispatched") + expectation.expectedFulfillmentCount = 1 + mockStore.dispatchCalled = { expectation.fulfill() } + let aiControlsModel = createSubject(prefs: mockPrefs) aiControlsModel.toggleKillSwitch(to: false) @@ -120,12 +138,6 @@ class AIControlsModelTests: XCTestCase { XCTFail("No pref value for ai kill switch feature") } - if let prefVal = mockPrefs.boolForKey(PrefsKeys.Settings.translationsFeature) { - XCTAssertTrue(prefVal) - } else { - XCTFail("No pref value for translations feature") - } - if let prefVal = mockPrefs.boolForKey(PrefsKeys.Summarizer.summarizeContentFeature) { XCTAssertTrue(prefVal) } else { @@ -133,31 +145,24 @@ class AIControlsModelTests: XCTestCase { } XCTAssertTrue(aiControlsModel.pageSummariesEnabled) + + wait(for: [expectation], timeout: 1.0) + let action = try XCTUnwrap(mockStore.dispatchedActions.last as? TranslationSettingsViewAction) + XCTAssertTrue(try XCTUnwrap(action.newSettingValue)) XCTAssertTrue(aiControlsModel.translationEnabled) } @MainActor - func testToggleTranslationsFeatureOn() { + func testToggleTranslationsFeature() throws { + let expectation = XCTestExpectation(description: "toggleTranslationsEnabled dispatched") + expectation.expectedFulfillmentCount = 1 + mockStore.dispatchCalled = { expectation.fulfill() } let aiControlsModel = createSubject(prefs: mockPrefs) aiControlsModel.toggleTranslationsFeature(to: true) - if let prefVal = mockPrefs.boolForKey(PrefsKeys.Settings.translationsFeature) { - XCTAssertTrue(prefVal) - } else { - XCTFail("No pref value for translations feature") - } - } - - @MainActor - func testToggleTranslationsFeatureOff() { - let aiControlsModel = createSubject(prefs: mockPrefs) - aiControlsModel.toggleTranslationsFeature(to: false) - - if let prefVal = mockPrefs.boolForKey(PrefsKeys.Settings.translationsFeature) { - XCTAssertFalse(prefVal) - } else { - XCTFail("No pref value for translations feature") - } + wait(for: [expectation], timeout: 1.0) + let action = try XCTUnwrap(mockStore.dispatchedActions.last as? TranslationSettingsViewAction) + XCTAssertTrue(try XCTUnwrap(action.newSettingValue)) } @MainActor @@ -196,8 +201,36 @@ class AIControlsModelTests: XCTestCase { @MainActor private func createSubject(prefs: Prefs) -> AIControlsModel { - let subject = AIControlsModel(prefs: prefs) + let subject = AIControlsModel(prefs: prefs, windowUUID: .XCTestDefaultUUID) trackForMemoryLeaks(subject) return subject } + + func setupAppState() -> Client.AppState { + return AppState( + presentedComponents: PresentedComponentsState( + components: [ + .translationSettings( + TranslationSettingsState( + windowUUID: .XCTestDefaultUUID, + isTranslationsEnabled: true, + isEditing: false, + pendingLanguages: nil, + preferredLanguages: [], + supportedLanguages: [] + ) + ) + ] + ) + ) + } + + func setupStore() { + mockStore = MockStoreForMiddleware(state: setupAppState()) + StoreTestUtilityHelper.setupStore(with: mockStore) + } + + func resetStore() { + StoreTestUtilityHelper.resetStore() + } } diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/TranslationSettingsMiddlewareTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/TranslationSettingsMiddlewareTests.swift index 0ca83a6309dda..18c546a156b0b 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/TranslationSettingsMiddlewareTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/TranslationSettingsMiddlewareTests.swift @@ -203,21 +203,31 @@ final class TranslationSettingsMiddlewareTests: XCTestCase, StoreTestUtility { actionType: TranslationSettingsViewActionType.toggleTranslationsEnabled ) + let expectation = XCTestExpectation(description: "wait for actions to dispatch") + expectation.expectedFulfillmentCount = 3 + mockStore.dispatchCalled = { expectation.fulfill() } + subject.translationSettingsProvider(mockStore.state, action) + wait(for: [expectation], timeout: 1.0) + // Expects ToolbarAction + TranslationSettingsMiddlewareAction - XCTAssertEqual(mockStore.dispatchedActions.count, 2) + XCTAssertEqual(mockStore.dispatchedActions.count, 3) let toolbarAction = try XCTUnwrap(mockStore.dispatchedActions.first as? ToolbarAction) let toolbarActionType = try XCTUnwrap(toolbarAction.actionType as? ToolbarActionType) XCTAssertEqual(toolbarActionType, ToolbarActionType.didTranslationSettingsChange) - let settingsAction = try XCTUnwrap(mockStore.dispatchedActions.last as? TranslationSettingsMiddlewareAction) + let settingsAction = try XCTUnwrap(mockStore.dispatchedActions[1] as? TranslationSettingsMiddlewareAction) let settingsActionType = try XCTUnwrap(settingsAction.actionType as? TranslationSettingsMiddlewareActionType) - XCTAssertEqual(settingsActionType, TranslationSettingsMiddlewareActionType.didUpdateSettings) XCTAssertEqual(settingsAction.isTranslationsEnabled, false) XCTAssertEqual(mockProfile.prefs.boolForKey(PrefsKeys.Settings.translationsFeature), false) + + let resetStorageAction = try XCTUnwrap(mockStore.dispatchedActions.last as? TranslationSettingsMiddlewareAction) + let resetStorageActionType = try XCTUnwrap(resetStorageAction.actionType as? TranslationSettingsMiddlewareActionType) + + XCTAssertEqual(resetStorageActionType, TranslationSettingsMiddlewareActionType.didResetStorage) subject.translationSettingsProvider = { _, _ in } } @@ -244,6 +254,48 @@ final class TranslationSettingsMiddlewareTests: XCTestCase, StoreTestUtility { subject.translationSettingsProvider = { _, _ in } } + func test_toggleTranslationsEnabled_whenEnabled_resetsStorage() { + mockProfile.prefs.setBool(true, forKey: PrefsKeys.Settings.translationsFeature) + + let expectation = XCTestExpectation(description: "wait for actions to dispatch") + expectation.expectedFulfillmentCount = 3 + mockStore.dispatchCalled = { expectation.fulfill() } + + let subject = createSubject() + let action = TranslationSettingsViewAction( + windowUUID: .XCTestDefaultUUID, + actionType: TranslationSettingsViewActionType.toggleTranslationsEnabled + ) + + subject.translationSettingsProvider(mockStore.state, action) + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(mockStore.dispatchedActions.count, 3) + subject.translationSettingsProvider = { _, _ in } + } + + func test_toggleTranslationsEnabled_whenDisabled_doesNotResetStorage() { + mockProfile.prefs.setBool(false, forKey: PrefsKeys.Settings.translationsFeature) + let expectation = XCTestExpectation(description: "wait for actions to dispatch") + expectation.expectedFulfillmentCount = 2 + expectation.assertForOverFulfill = true + mockStore.dispatchCalled = { expectation.fulfill() } + + let subject = createSubject() + let action = TranslationSettingsViewAction( + windowUUID: .XCTestDefaultUUID, + actionType: TranslationSettingsViewActionType.toggleTranslationsEnabled + ) + + subject.translationSettingsProvider(mockStore.state, action) + + wait(for: [expectation], timeout: 2.0) + + XCTAssertEqual(mockStore.dispatchedActions.count, 2) + subject.translationSettingsProvider = { _, _ in } + } + // MARK: - toggleAutoTranslate func test_toggleAutoTranslate_whenDisabled_enablesAndDispatches() throws { diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/ASTranslationModelsFetcherTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/ASTranslationModelsFetcherTests.swift index cd910727d9048..740fce9176ca2 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/ASTranslationModelsFetcherTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/ASTranslationModelsFetcherTests.swift @@ -240,6 +240,16 @@ final class ASTranslationModelsFetcherTests: XCTestCase { XCTAssertEqual(versionSet.first, "2.0", "Expected version to be best highest version below max") } + func testResetStorage() async { + let settingsClient = MockRemoteSettingsClient( + records: [], + attachmentsById: [:] + ) + let subject = createSubject(modelsClient: settingsClient) + await subject.resetStorage() + XCTAssertTrue(settingsClient.resetStorageWasCalled) + } + private func createSubject( records: [RemoteSettingsRecord] = [], attachmentsById: [String: Data] = [:], diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockRemoteSettingsClient.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockRemoteSettingsClient.swift index 7c05f24e138d0..a0f1d7b275a93 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockRemoteSettingsClient.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockRemoteSettingsClient.swift @@ -6,6 +6,7 @@ import Foundation import MozillaAppServices final class MockRemoteSettingsClient: RemoteSettingsClientProtocol, @unchecked Sendable { + var resetStorageWasCalled = false private let collectionNameValue: String private let records: [RemoteSettingsRecord] private let attachmentsById: [String: Data] @@ -47,6 +48,6 @@ final class MockRemoteSettingsClient: RemoteSettingsClientProtocol, @unchecked S } func resetStorage() throws { - // no-op for tests for now + resetStorageWasCalled = true } } diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockTranslationModelsFetcher.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockTranslationModelsFetcher.swift index 66a58a103fce4..b78ef874034ab 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockTranslationModelsFetcher.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/MockTranslationModelsFetcher.swift @@ -4,6 +4,7 @@ import Foundation @testable import Client +import XCTest /// Minimal mock for TranslationModelsFetcherProtocol tests. This avoids going through remote settings. final class MockTranslationModelsFetcher: TranslationModelsFetcherProtocol, @unchecked Sendable { @@ -32,4 +33,8 @@ final class MockTranslationModelsFetcher: TranslationModelsFetcherProtocol, @unc func fetchSupportedTargetLanguages() async -> [String] { return supportedTargetLanguages } + + func resetStorage() async { + // no-op for now + } }