Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions firefox-ios/Client.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1391,7 +1391,7 @@
AA742C7A2E90358400CF55B3 /* MockMicrosurveyTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA742C792E90357E00CF55B3 /* MockMicrosurveyTelemetry.swift */; };
AA742C7C2E904B9B00CF55B3 /* MicrosurveyTelemetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA742C7B2E904B9500CF55B3 /* MicrosurveyTelemetryTests.swift */; };
AAB4321B2E8187190075E47F /* RecentSearchProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAB4321A2E8187130075E47F /* RecentSearchProvider.swift */; };
AAB4321D2E8189390075E47F /* RecentSearchProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAB4321C2E8189310075E47F /* RecentSearchProviderTests.swift */; };
AAB4321D2E8189390075E47F /* DefaultRecentSearchProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAB4321C2E8189310075E47F /* DefaultRecentSearchProviderTests.swift */; };
AAB434062E82F0600075E47F /* MockTrendingSearchProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAB434052E82F05C0075E47F /* MockTrendingSearchProvider.swift */; };
AB2AC6632BCFD0A200022AAB /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = AB2AC6622BCFD0A200022AAB /* X509 */; };
AB2AC6662BD15E6300022AAB /* CertificatesHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2AC6652BD15E6300022AAB /* CertificatesHandler.swift */; };
Expand Down Expand Up @@ -9424,7 +9424,7 @@
AA80494199BED7BAA77241A9 /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-CN"; path = "zh-CN.lproj/Storage.strings"; sourceTree = "<group>"; };
AAAB41FC97F6C8F8A1506603 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/AuthenticationManager.strings; sourceTree = "<group>"; };
AAB4321A2E8187130075E47F /* RecentSearchProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearchProvider.swift; sourceTree = "<group>"; };
AAB4321C2E8189310075E47F /* RecentSearchProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearchProviderTests.swift; sourceTree = "<group>"; };
AAB4321C2E8189310075E47F /* DefaultRecentSearchProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultRecentSearchProviderTests.swift; sourceTree = "<group>"; };
AAB434052E82F05C0075E47F /* MockTrendingSearchProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTrendingSearchProvider.swift; sourceTree = "<group>"; };
AAF64ABAB8A585208603D45B /* dsb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = dsb; path = dsb.lproj/LoginManager.strings; sourceTree = "<group>"; };
AB2AC6652BD15E6300022AAB /* CertificatesHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatesHandler.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -13093,7 +13093,7 @@
isa = PBXGroup;
children = (
AA4D99422E857AEE00BB039D /* MockRecentSearchProvider.swift */,
AAB4321C2E8189310075E47F /* RecentSearchProviderTests.swift */,
AAB4321C2E8189310075E47F /* DefaultRecentSearchProviderTests.swift */,
AAB434052E82F05C0075E47F /* MockTrendingSearchProvider.swift */,
AA24AD332E7C3ACC008659A3 /* MockTrendingSearchEngine.swift */,
AA24AD312E7C3A85008659A3 /* TrendingSearchClientTests.swift */,
Expand Down Expand Up @@ -19559,7 +19559,7 @@
8A9D316A2D135EB000171502 /* EditBookmarkViewModelTests.swift in Sources */,
3B6F40181DC7849C00656CC6 /* TopSitesViewModelTests.swift in Sources */,
8A00BD882CAB401700680AF9 /* HomepageViewControllerTests.swift in Sources */,
AAB4321D2E8189390075E47F /* RecentSearchProviderTests.swift in Sources */,
AAB4321D2E8189390075E47F /* DefaultRecentSearchProviderTests.swift in Sources */,
C869915528917803007ACC5C /* WallpaperMetadataTestProvider.swift in Sources */,
8A83C58E2DDF887300FF60EF /* ProfilePrefsReaderTests.swift in Sources */,
BC003F5E2B59F44600929ECB /* BrowserViewControllerTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,10 @@ class BrowserViewController: UIViewController,
return featureFlags.isFeatureEnabled(.trendingSearches, checking: .buildOnly)
}

var isRecentSearchEnabled: Bool {
return featureFlags.isFeatureEnabled(.recentSearches, checking: .buildOnly)
}

// MARK: Computed vars

lazy var isBottomSearchBar: Bool = {
Expand Down Expand Up @@ -1975,7 +1979,7 @@ class BrowserViewController: UIViewController,

let trendingClient = TrendingSearchClient(searchEngine: searchEnginesManager.defaultEngine)

let recentSearchProvider = getRecentSearchProvider(with: searchEnginesManager.defaultEngine?.engineID)
let recentSearchProvider = DefaultRecentSearchProvider(historyStorage: profile.places)

let searchViewModel = SearchViewModel(
isPrivate: isPrivate,
Expand Down Expand Up @@ -2003,19 +2007,6 @@ class BrowserViewController: UIViewController,
self.searchSessionState = .active
}

private func getRecentSearchProvider(with engineID: String?) -> RecentSearchProvider? {
// TODO: FXIOS-13684 We should investigate in making defaultSearchEngine non-nil
guard let engineID else {
logger.log(
"Unable to retrieve engineID",
level: .warning,
category: .searchEngines
)
return nil
}
return DefaultRecentSearchProvider(searchEngineID: engineID)
}

func showSearchController() {
createSearchControllerIfNeeded()

Expand Down Expand Up @@ -3025,6 +3016,19 @@ class BrowserViewController: UIViewController,
searchTelemetry.shouldSetUrlTypeSearch = true

finishEditingAndSubmit(searchURL, visitType: VisitType.typed, forTab: tab)

dispatchSubmitSearchTermAction(with: searchURL, searchTerm: text)
}

private func dispatchSubmitSearchTermAction(with searchURL: URL, searchTerm: String) {
guard isRecentSearchEnabled else { return }
let action = ToolbarAction(
url: searchURL,
searchTerm: searchTerm,
windowUUID: windowUUID,
actionType: ToolbarActionType.didSubmitSearchTerm
)
store.dispatch(action)
}

// MARK: Opening New Tabs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,12 @@ class SearchViewModel: FeatureFlaggable, LoaderListener {
recentSearches = []
return
}
let results = recentSearchProvider.recentSearches
recentSearches = results
recentSearchProvider.loadRecentSearches { [weak self] searchTerms in
self?.recentSearches = searchTerms
ensureMainThread { [weak self] in
self?.delegate?.reloadTableView()
}
Comment on lines +326 to +328
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, you could adapt the loadRecentSearches function signature to use a @MainActor completion. Then in the loadRecentSearches method you call the completion on the main thread, and here you wouldn't need this.

I just suggest it since loading the engines somewhat suggests to me that we'd perform some UI work after some networking. 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to stay away from mixing completion handlers / modern swift concurrency here and we're more explicit in what we want to ensure is on main thread. However, it does seem cleaner in theory though and can't think of a case where we wouldn't want it on the main thread.

I tried to update the function signature, but I get this error, let me know if I misunderstood what you meant. Thanks!

image

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can go either way then, now that I understand your goal!

In this case you'd need to Dispatch to the main queue to ensure the completion handler is always called on the main thread, that's expected actually. 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah okay, I wasn't sure if there was a route without dispatching to main, lets leave this as is then. I'll need to revisit this area when I use the new method anyway.

}
}

@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,66 +4,68 @@

import Common
import Shared
import Storage

/// Abstraction for any search client that can return trending searches. Able to mock for testing.
protocol RecentSearchProvider {
var recentSearches: [String] { get }
func addRecentSearch(_ term: String)
func clearRecentSearches()
func addRecentSearch(_ term: String, url: String?)
func loadRecentSearches(completion: @escaping ([String]) -> Void)
}

/// A provider that manages recent search terms for a specific search engine.
/// A provider that manages recent search terms from a user's history storage.
struct DefaultRecentSearchProvider: RecentSearchProvider {
Copy link
Contributor Author

@cyndichin cyndichin Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are reviewing this file, I would say to review the Raw file since it might be easier as the whole file is basically rewritten.

If you are wondering why we are using Int64.min in this file below, this is because I am following Android's implementation that uses Long.MIN_VALUE and there's no method to fetch X number of search terms currently.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the min is used in terms of the date timestamp. Does this mean we're fetching everything from all of time? 🤔

Copy link
Collaborator

@issammani issammani Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean we're fetching everything from all of time?

No it's limited to a 1000. Anyways I didn't like how we have to get x items and have to filter it out to the limit we want. I opened mozilla/application-services#7002 to add a convenience method that returns n most recent items. This is not a blocker though. We can merge this and update later when the new method is available. @cyndichin can you please add a //TODO(FXIOS-xxx)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @issammani for opening that PR, it'll be helpful for Android too! You 🪨 ! It seems requirements may have change in that we may want to fetch all recent searches (with no limits). Still TBD, but the method looks great regardless!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated here: d62599d

private let searchEngineID: String
private let prefs: Prefs
private let historyStorage: HistoryHandler
private let logger: Logger
private let nimbus: FxNimbus

private let baseKey = PrefsKeys.Search.recentSearchesCache

// Namespaced key = "recentSearchesCacheBaseKey.[engineID]"
private var recentSearchesKey: String {
"\(baseKey).\(searchEngineID)"
}

var recentSearches: [String] {
prefs.objectForKey(recentSearchesKey) ?? []
}

private var maxNumberOfSuggestions: Int {
return nimbus.features.recentSearchesFeature.value().maxSuggestions
}

init(
profile: Profile = AppContainer.shared.resolve(),
searchEngineID: String,
historyStorage: HistoryHandler,
logger: Logger = DefaultLogger.shared,
nimbus: FxNimbus = FxNimbus.shared
) {
self.searchEngineID = searchEngineID
self.prefs = profile.prefs
self.historyStorage = historyStorage
self.logger = logger
self.nimbus = nimbus
}

/// Adds a search term to the persisted recent searches list, ensuring it avoid duplicates,
/// and does not exceed `maxNumberOfSuggestions`.
/// Adds a search term to our history storage, `Rust Places` and saved in `places.db` locally.
///
/// - Parameter term: The search term to store.
func addRecentSearch(_ term: String) {
func addRecentSearch(_ term: String, url: String?) {
guard let url else {
logger.log("Url is needed to store recent search in history, but was nil.",
level: .debug,
category: .searchEngines)
return
}
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }

var searches = recentSearches

searches.removeAll { $0.caseInsensitiveCompare(trimmed) == .orderedSame }
searches.insert(trimmed, at: 0)

if searches.count > maxNumberOfSuggestions {
searches = Array(searches.prefix(maxNumberOfSuggestions))
}

prefs.setObject(searches, forKey: recentSearchesKey)
historyStorage.noteHistoryMetadata(
for: trimmed.lowercased(),
and: url,
completion: { _ in }
)
}

func clearRecentSearches() {
prefs.removeObjectForKey(recentSearchesKey)
/// Retrieves list of search terms from our history storage, `Rust Places` and saved in `places.db` locally.
///
/// Only care about returning the `maxNumberOfSuggestions`.
/// We don't have an interface to fetch only a certain amount, so we follow what Android does for now.
func loadRecentSearches(completion: @escaping ([String]) -> Void) {
// TODO: FXIOS-13782 Use get_most_recent method to fetch history
historyStorage.getHistoryMetadataSince(since: Int64.min) { result in
if case .success(let historyMetadata) = result {
let searches = historyMetadata.compactMap { $0.searchTerm }
let recentSearches = Array(searches.prefix(maxNumberOfSuggestions))
completion(recentSearches)
} else {
completion([])
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ enum ToolbarActionType: ActionType {
case clearSearch
case didDeleteSearchTerm
case didEnterSearchTerm
// User submitted a search term to load the search request
case didSubmitSearchTerm
Comment on lines 119 to +121
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thatswinnie I wasn't sure why we need to make this distinction, but didEnterSearchTerm did not seem to be triggered as I thought it would be. Maybe we can add a comment to clarify the difference. We can chat live on this as well!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we are just using this action in the ToolbarMiddleware so we should make this a ToolbarMiddlewareAction instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @thatswinnie ! I believe that the naming is based on where the action is called and not the consequences.

Here's our wiki guidelines on that:

When it comes to naming convention, we dispatch general action names from the view and the middleware dispatches middleware actions.

i.e. `GeneralActionType.show` lives in the view and `GeneralMiddlewareActionType.show` lives in the middleware.

I did write these when I was discussing with @OrlaM on Redux and think I brought it to the team in the early days. Happy to sync or update this if something has changed and I'm unaware.

https://github.com/mozilla-mobile/firefox-ios/wiki/Redux-Guidelines---FAQs#faqs

case didSetSearchTerm
case didStartTyping
case translucencyDidChange
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class ToolbarMiddleware: FeatureFlaggable {
private let logger: Logger
private let toolbarTelemetry: ToolbarTelemetry
private let prefs: Prefs
private let recentSearchProvider: RecentSearchProvider
private let summarizerNimbusUtils: SummarizerNimbusUtils
private let summarizationChecker: SummarizationCheckerProtocol
private let summarizerServiceFactory: SummarizerServiceFactory
Expand All @@ -35,6 +36,7 @@ final class ToolbarMiddleware: FeatureFlaggable {
summarizerNimbusUtils: SummarizerNimbusUtils = DefaultSummarizerNimbusUtils(),
summarizerServiceFactory: SummarizerServiceFactory = DefaultSummarizerServiceFactory(),
summarizationChecker: SummarizationCheckerProtocol = SummarizationChecker(),
recentSearchProvider: RecentSearchProvider? = nil,
windowManager: WindowManager = AppContainer.shared.resolve(),
logger: Logger = DefaultLogger.shared) {
self.summarizerNimbusUtils = summarizerNimbusUtils
Expand All @@ -44,6 +46,7 @@ final class ToolbarMiddleware: FeatureFlaggable {
self.toolbarHelper = toolbarHelper
self.toolbarTelemetry = toolbarTelemetry
self.prefs = profile.prefs
self.recentSearchProvider = recentSearchProvider ?? DefaultRecentSearchProvider(historyStorage: profile.places)
self.windowManager = windowManager
self.logger = logger
}
Expand Down Expand Up @@ -173,6 +176,12 @@ final class ToolbarMiddleware: FeatureFlaggable {
actionType: SearchEngineSelectionMiddlewareActionType.didClearAlternativeSearchEngine
)
store.dispatchLegacy(action)

case ToolbarActionType.didSubmitSearchTerm:
// After a user submits a search term, we want to record it in our history storage via recent search provider
guard let url = action.url, let searchTerm = action.searchTerm else { return }
recentSearchProvider.addRecentSearch(searchTerm, url: url.absoluteString)

default:
break
}
Expand Down
43 changes: 43 additions & 0 deletions firefox-ios/Storage/Rust/RustPlaces.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import struct MozillaAppServices.TopFrecentSiteInfo
import struct MozillaAppServices.Url
import struct MozillaAppServices.VisitObservation
import struct MozillaAppServices.VisitTransitionSet
import struct MozillaAppServices.HistoryMetadata

// TODO: FXIOS-12903 Bookmark Data from Rust components is not Sendable
extension BookmarkNodeData: @unchecked @retroactive Sendable {}
Expand Down Expand Up @@ -60,6 +61,16 @@ public protocol BookmarksHandler {
public protocol HistoryHandler {
func applyObservation(visitObservation: VisitObservation,
completion: @escaping (Result<Void, any Error>) -> Void)
func getHistoryMetadataSince(
since startDate: Int64,
completion: @Sendable @escaping (Result<[HistoryMetadata], any Error>) -> Void
)

func noteHistoryMetadata(
for searchTerm: String,
and urlString: String,
completion: @Sendable @escaping (Result<(), any Error>) -> Void
)
}

// TODO: FXIOS-13208 Make RustPlaces actually Sendable
Expand Down Expand Up @@ -538,6 +549,38 @@ public class RustPlaces: @unchecked Sendable, BookmarksHandler, HistoryHandler {
}

// MARK: History metadata
/// Currently only used to get the recent searches from the user's history storage.
public func getHistoryMetadataSince(
since startDate: Int64,
completion: @Sendable @escaping (Result<[HistoryMetadata], any Error>) -> Void
) {
withReader({ connection in
return try connection.getHistoryMetadataSince(since: startDate)
}, completion: completion)
}

/// As part of the recent searches work, we are interesting in saving the search term in our history storage.
/// - Parameters:
/// - searchTerm: The search term used to find a page.
/// - urlString: The url of the page.
public func noteHistoryMetadata(
for searchTerm: String,
and urlString: String,
completion: @Sendable @escaping (Result<(), any Error>) -> Void
) {
withWriter(
{ connection in
return try connection.noteHistoryMetadataObservationViewTime(
key: HistoryMetadataKey(
url: urlString,
searchTerm: searchTerm,
referrerUrl: nil
), viewTime: nil
)
},
completion: completion)
}
Comment on lines +552 to +582
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for using callbacks and not more Deferred. 🙏


// We are not collecting history metadata anymore since FXIOS-6729, but let's keep the possibility
// to delete metadata for a while.
public func deleteHistoryMetadataOlderThan(olderThan: Int64) -> Deferred<Maybe<Void>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,62 @@ import XCTest

final class MockHistoryHandler: HistoryHandler {
var applied: [VisitObservation] = []
var appplyObservationCallCount = 0
var applyObservationCallCount = 0
var nextResult: Result<Void, Error> = .success(())
var onApply: (() -> Void)?

// MARK: History Metadata
var getHistoryMetadataSinceCallCount = 0
var noteHistoryMetadataCallCount = 0
var result: Result<[MozillaAppServices.HistoryMetadata], Error> = .success(
[
HistoryMetadata(
url: "https://example.com",
title: nil,
previewImageUrl: nil,
createdAt: 1,
updatedAt: 1,
totalViewTime: 1,
searchTerm: "search term 1",
documentType: .regular,
referrerUrl: nil
),
HistoryMetadata(
url: "https://example.com",
title: nil,
previewImageUrl: nil,
createdAt: 2,
updatedAt: 2,
totalViewTime: 2,
searchTerm: "search term 2",
documentType: .regular,
referrerUrl: nil
)
]
)
var searchTermList: [String] = []

func applyObservation(visitObservation: VisitObservation, completion: (Result<Void, any Error>) -> Void) {
appplyObservationCallCount += 1
applyObservationCallCount += 1
applied.append(visitObservation)
completion(nextResult)
onApply?()
}

func getHistoryMetadataSince(
since startDate: Int64,
completion: @escaping @Sendable (Result<[MozillaAppServices.HistoryMetadata], any Error>) -> Void
) {
getHistoryMetadataSinceCallCount += 1
completion(result)
}

func noteHistoryMetadata(
for searchTerm: String,
and urlString: String,
completion: @escaping @Sendable (Result<(), any Error>) -> Void
) {
noteHistoryMetadataCallCount += 1
searchTermList.append(searchTerm)
}
}
Loading
Loading