Skip to content

Commit

Permalink
Add FXIOS-10914 [Homepage] [Top Sites] calculating top sites concurre…
Browse files Browse the repository at this point in the history
…ntly (#23935)
  • Loading branch information
cyndichin authored Dec 27, 2024
1 parent c0a82b0 commit 8099fa3
Show file tree
Hide file tree
Showing 7 changed files with 404 additions and 129 deletions.
8 changes: 8 additions & 0 deletions firefox-ios/Client.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,8 @@
8AEE62CA2756BA34003207D1 /* TrackingProtectionStats.js in Resources */ = {isa = PBXBuildFile; fileRef = 8AEE62C72756BA34003207D1 /* TrackingProtectionStats.js */; };
8AEE62CB2756BA34003207D1 /* DownloadHelper.js in Resources */ = {isa = PBXBuildFile; fileRef = 8AEE62C82756BA34003207D1 /* DownloadHelper.js */; };
8AEF41602D15D6290013925D /* TopSitesSectionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE9381A2CD91FDB0020E6CF /* TopSitesSectionStateTests.swift */; };
8AEF41622D15EDBC0013925D /* TopSitesMiddlewareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEF41612D15EDBA0013925D /* TopSitesMiddlewareTests.swift */; };
8AEF41642D15EE1D0013925D /* MockTopSitesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEF41632D15EE190013925D /* MockTopSitesManager.swift */; };
8AF10D8A29D713F50086351D /* LaunchScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF10D8929D713F50086351D /* LaunchScreenViewModelTests.swift */; };
8AF10D8F29D774090086351D /* SceneSetupHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF10D8E29D774090086351D /* SceneSetupHelper.swift */; };
8AF10D9129D7761A0086351D /* MockLaunchScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF10D9029D776190086351D /* MockLaunchScreenManager.swift */; };
Expand Down Expand Up @@ -7858,6 +7860,8 @@
8AEE62C62756BA34003207D1 /* LoginsHelper.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = LoginsHelper.js; path = AllFrames/AtDocumentStart/LoginsHelper.js; sourceTree = "<group>"; };
8AEE62C72756BA34003207D1 /* TrackingProtectionStats.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = TrackingProtectionStats.js; path = AllFrames/AtDocumentStart/TrackingProtectionStats.js; sourceTree = "<group>"; };
8AEE62C82756BA34003207D1 /* DownloadHelper.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = DownloadHelper.js; path = AllFrames/AtDocumentStart/DownloadHelper.js; sourceTree = "<group>"; };
8AEF41612D15EDBA0013925D /* TopSitesMiddlewareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSitesMiddlewareTests.swift; sourceTree = "<group>"; };
8AEF41632D15EE190013925D /* MockTopSitesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTopSitesManager.swift; sourceTree = "<group>"; };
8AF10D8929D713F50086351D /* LaunchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchScreenViewModelTests.swift; sourceTree = "<group>"; };
8AF10D8E29D774090086351D /* SceneSetupHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneSetupHelper.swift; sourceTree = "<group>"; };
8AF10D9029D776190086351D /* MockLaunchScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLaunchScreenManager.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -11649,6 +11653,7 @@
8A552AC22CB43A7000564C98 /* Redux */ = {
isa = PBXGroup;
children = (
8AEF41612D15EDBA0013925D /* TopSitesMiddlewareTests.swift */,
8A552AC62CB43AB300564C98 /* HeaderStateTests.swift */,
8A552AC52CB43AB300564C98 /* HomepageStateTests.swift */,
8A454D332CB85C7D009436D9 /* PocketStateTests.swift */,
Expand Down Expand Up @@ -11828,6 +11833,7 @@
8A87B4352CC1A8FD003A9239 /* Mock */ = {
isa = PBXGroup;
children = (
8AEF41632D15EE190013925D /* MockTopSitesManager.swift */,
8A87B4362CC1A910003A9239 /* MockPocketManager.swift */,
8A6B799C2CDBDAE4003C3077 /* MockContileProvider.swift */,
8A6B799F2CDBDB0C003C3077 /* MockGoogleTopSiteManager.swift */,
Expand Down Expand Up @@ -17245,6 +17251,7 @@
8A4EA0D92C01127C00E4E4F1 /* MicrosurveyMockModel.swift in Sources */,
1D4D79472BF2F4FD007C6796 /* Throttler.swift in Sources */,
6A3E5D8A283831D1001E706E /* DownloadQueueTests.swift in Sources */,
8AEF41642D15EE1D0013925D /* MockTopSitesManager.swift in Sources */,
8AE80BB62891AEA100BC12EA /* MockDispatchGroup.swift in Sources */,
8AA6ADB52742B567004EEE23 /* TelemetryWrapperTests.swift in Sources */,
21534904288201E300FADB4D /* GleanPlumbMessageManagerTests.swift in Sources */,
Expand Down Expand Up @@ -17489,6 +17496,7 @@
814B71FF2CBEDC3B001B134A /* MainMenuDetailsStateTests.swift in Sources */,
5AE371842A4DD6F50092A760 /* PasswordManagerListViewControllerSpy.swift in Sources */,
8A2825352760399B00395E66 /* KeyboardPressesHandlerTests.swift in Sources */,
8AEF41622D15EDBC0013925D /* TopSitesMiddlewareTests.swift in Sources */,
ABEF80D92A2F283E003F52C4 /* CreditCardBottomSheetViewModelTests.swift in Sources */,
6A5F591D28627C0100FABA92 /* TabManagerNavDelegateTests.swift in Sources */,
8AABBD052A0041380089941E /* MockCoordinator.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,35 @@ import Common
import Shared
import Storage

// TODO: FXIOS-10165 - Add full logic + tests for retrieving top sites
protocol TopSitesManagerInterface {
/// Returns a list of top sites state using the top site history manager to fetch the other sites
/// which is composed of history-based (Frecency) + pinned + default suggested tiles
func getOtherSites() async -> [TopSiteState]

/// Returns a list of sponsored tiles using the contile provider
func fetchSponsoredSites() async -> [SponsoredTile]

/// Returns a list of top sites used to show the user
///
/// Top sites are composed of pinned sites, history, sponsored tiles and google top site.
/// In terms of space, pinned tiles has precedence over the Google tile,
/// which has precedence over sponsored and frecency tiles.
///
/// From a user perspective, Google top site is always first (from left to right),
/// then comes the sponsored tiles, pinned sites and then frecency top sites.
/// We only add Google or sponsored tiles if number of pinned tiles doesn't exceeds the available number shown of tiles.
func recalculateTopSites(otherSites: [TopSiteState], sponsoredSites: [SponsoredTile]) async -> [TopSiteState]
}

/// Manager to fetch the top sites data, the data gets updated from notifications on specific user actions
class TopSitesManager {
class TopSitesManager: TopSitesManagerInterface {
private var logger: Logger
private let prefs: Prefs
private let contileProvider: ContileProviderInterface
private let googleTopSiteManager: GoogleTopSiteManagerProvider
private let topSiteHistoryManager: TopSiteHistoryManagerProvider
private let searchEnginesManager: SearchEnginesManagerProvider

// TODO: FXIOS-10477 - Add number of rows calculation and device size updates
private let maxTopSites: Int
private let maxNumberOfSponsoredTile: Int = 2

Expand All @@ -39,26 +57,12 @@ class TopSitesManager {
self.maxTopSites = maxTopSites
}

func getTopSites() async -> [TopSiteState] {
return await calculateTopSites()
}

/// Top sites are composed of pinned sites, history, sponsored tiles and google top site.
/// In terms of space, pinned tiles has precedence over the Google tile,
/// which has precedence over sponsored and frecency tiles.
///
/// From a user perspective, Google top site is always first (from left to right),
/// then comes the sponsored tiles, pinned sites and then frecency top sites.
/// We only add Google or sponsored tiles if number of pinned tiles doesn't exceeds the available number shown of tiles.
private func calculateTopSites() async -> [TopSiteState] {
// TODO: FXIOS-10477 - Look into creating task groups to run asynchronous methods concurrently
let otherSites = await getOtherSites()

func recalculateTopSites(otherSites: [TopSiteState], sponsoredSites: [SponsoredTile]) async -> [TopSiteState] {
let availableSpaceCount = getAvailableSpaceCount(with: otherSites)
let googleTopSite = addGoogleTopSite(with: availableSpaceCount)

let updatedSpaceCount = getUpdatedSpaceCount(with: googleTopSite, and: availableSpaceCount)
let sponsoredSites = await getSponsoredSites(with: updatedSpaceCount, and: otherSites)
let sponsoredSites = await filterSponsoredSites(contiles: sponsoredSites, with: updatedSpaceCount, and: otherSites)

let totalTopSites = googleTopSite + sponsoredSites + otherSites

Expand All @@ -77,25 +81,7 @@ class TopSitesManager {
}

// MARK: Sponsored tiles (Contiles)
private var shouldLoadSponsoredTiles: Bool {
return prefs.boolForKey(PrefsKeys.UserFeatureFlagPrefs.SponsoredShortcuts) ?? true
}

private func getSponsoredSites(with availableSpaceCount: Int, and otherSites: [TopSiteState]) async -> [TopSiteState] {
guard availableSpaceCount > 0, shouldLoadSponsoredTiles else { return [] }

let contiles = await fetchSponsoredSites()

guard !contiles.isEmpty else { return [] }

let filteredContiles = contiles
.filter { shouldShowSponsoredSite(with: $0, and: otherSites) }
.compactMap { TopSiteState(site: $0) }

return filteredContiles
}

private func fetchSponsoredSites() async -> [SponsoredTile] {
func fetchSponsoredSites() async -> [SponsoredTile] {
let contiles = await withCheckedContinuation { continuation in
contileProvider.fetchContiles { [weak self] result in
if case .success(let contiles) = result {
Expand All @@ -111,9 +97,28 @@ class TopSitesManager {
}
}

return contiles
return contiles.compactMap { SponsoredTile(contile: $0) }
}

private var shouldLoadSponsoredTiles: Bool {
return prefs.boolForKey(PrefsKeys.UserFeatureFlagPrefs.SponsoredShortcuts) ?? true
}

private func filterSponsoredSites(
contiles: [SponsoredTile],
with availableSpaceCount: Int,
and otherSites: [TopSiteState]
) async -> [TopSiteState] {
guard availableSpaceCount > 0, shouldLoadSponsoredTiles else { return [] }

guard !contiles.isEmpty else { return [] }

let filteredContiles = contiles
.prefix(maxNumberOfSponsoredTile)
.compactMap { SponsoredTile(contile: $0) }
.filter { shouldShowSponsoredSite(with: $0, and: otherSites) }
.compactMap { TopSiteState(site: $0) }

return filteredContiles
}

/// Show the sponsored site only if site is not already present in the pinned sites
Expand All @@ -132,8 +137,8 @@ class TopSitesManager {
return !sponsoredSiteIsAlreadyPresent && shouldAddDefaultEngine
}

// MARK: Other Sites = History-based (Frencency) + Pinned + Default suggested tiles
private func getOtherSites() async -> [TopSiteState] {
// MARK: Other Sites
func getOtherSites() async -> [TopSiteState] {
let otherSites = await withCheckedContinuation { continuation in
topSiteHistoryManager.getTopSites { sites in
continuation.resume(returning: sites)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ import Foundation
import Redux

final class TopSitesMiddleware {
private let topSitesManager: TopSitesManager
private let topSitesManager: TopSitesManagerInterface

init(profile: Profile = AppContainer.shared.resolve()) {
self.topSitesManager = TopSitesManager(
// Raw data to build top sites with, we may want to revisit and fetch only the number of top sites we want
// but keeping logic consistent for now
private var otherSites: [TopSiteState] = []
private var sponsoredTiles: [SponsoredTile] = []

init(profile: Profile = AppContainer.shared.resolve(), topSitesManager: TopSitesManagerInterface? = nil) {
self.topSitesManager = topSitesManager ?? TopSitesManager(
prefs: profile.prefs,
googleTopSiteManager: GoogleTopSiteManager(
prefs: profile.prefs
Expand All @@ -32,14 +37,49 @@ final class TopSitesMiddleware {

private func getTopSitesDataAndUpdateState(for action: Action) {
Task {
let topSites = await topSitesManager.getTopSites()
store.dispatch(
TopSitesAction(
topSites: topSites,
windowUUID: action.windowUUID,
actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites
await withTaskGroup(of: Void.self) { group in
group.addTask {
self.otherSites = await self.topSitesManager.getOtherSites()
await self.updateTopSites(
for: action.windowUUID,
otherSites: self.otherSites,
sponsoredTiles: self.sponsoredTiles
)
}
group.addTask {
self.sponsoredTiles = await self.topSitesManager.fetchSponsoredSites()
await self.updateTopSites(
for: action.windowUUID,
otherSites: self.otherSites,
sponsoredTiles: self.sponsoredTiles
)
}

await group.waitForAll()
await updateTopSites(
for: action.windowUUID,
otherSites: self.otherSites,
sponsoredTiles: self.sponsoredTiles
)
)
}
}
}

private func updateTopSites(
for windowUUID: WindowUUID,
otherSites: [TopSiteState],
sponsoredTiles: [SponsoredTile]
) async {
let topSites = await self.topSitesManager.recalculateTopSites(
otherSites: otherSites,
sponsoredSites: sponsoredTiles
)
store.dispatch(
TopSitesAction(
topSites: topSites,
windowUUID: windowUUID,
actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import Redux
/// store implementation (e.g. storing a completion handler for asynchronous middleware actions so you can await expectations
/// in your tests).
class MockStoreForMiddleware<State: StateType>: DefaultDispatchStore {
private let lock = NSLock()

var state: State

/// Records all actions dispatched to the mock store. Check this property to ensure that your middleware correctly
Expand Down Expand Up @@ -49,7 +51,11 @@ class MockStoreForMiddleware<State: StateType>: DefaultDispatchStore {
// TODO: if you need it
}

/// We implemented the lock to ensure that this is thread safe
/// since actions can be dispatch in concurrent tasks
func dispatch(_ action: Redux.Action) {
lock.lock()
defer { lock.unlock() }
dispatchedActions.append(action)
dispatchCalled?()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Foundation
import Storage

@testable import Client

final class MockTopSitesManager: TopSitesManagerInterface {
var getOtherSitesCalledCount = 0
var fetchSponsoredSitesCalledCount = 0
var recalculateTopSitesCalledCount = 0

func getOtherSites() async -> [TopSiteState] {
getOtherSitesCalledCount += 1
return createSites(count: 15, subtitle: ": otherSites")
}

func fetchSponsoredSites() async -> [SponsoredTile] {
fetchSponsoredSitesCalledCount += 1

let contiles = MockContileProvider.defaultSuccessData
return contiles.compactMap { SponsoredTile(contile: $0) }
}

func recalculateTopSites(otherSites: [TopSiteState], sponsoredSites: [SponsoredTile]) async -> [TopSiteState] {
recalculateTopSitesCalledCount += 1
return createSites(subtitle: ": total top sites")
}

func createSites(count: Int = 30, subtitle: String = "") -> [TopSiteState] {
var sites = [TopSiteState]()
(0..<count).forEach {
let site = Site(url: "www.url\($0).com",
title: "Title \($0) \(subtitle)")
sites.append(TopSiteState(site: site))
}
return sites
}
}
Loading

0 comments on commit 8099fa3

Please sign in to comment.