Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix FXIOS-10996 Fix app crashes in HistoryPanel due to duplicate Site identifiers #24116

Open
wants to merge 37 commits into
base: main
Choose a base branch
from

Conversation

ih-codes
Copy link
Collaborator

📜 Tickets

Jira ticket
Github issue

💡 Description

This PR refactors the Site class and its inheriting classes to instead be a Site struct which synthesizes its Equatable and Hashable methods.

The purpose of this change is to clean up some old code, and ultimately ensure all Sites are unique and strictly Identifiable for diffing (as required on iOS 18 with the History Panel diffable data source).

This work should hopefully resolve crashes we are seeing on Sentry due to "duplicate" Sites, which is a regression on a past fix in this PR: #23494 (possibly caused by a backport related to the xcode 16 upgrade).

Main Changes:

  • The Site object was built as a class, with several types inheriting from it and adding additional functionality (suggested sites, sponsored tiles, pinned sites…). This felt a bit non-swifty. I tried making Site a protocol at first, but ran into issues with making other types that had any Site Equatable, as a concrete type was needed. So I opted to make an enum to identify each SiteType.
  • Because Site is inside the Storage target, it was necessary for me to move the associated types for suggested, sponsored, and pinned sites to Storage as well (as Storage cannot access Client).
  • Updated code in Client, Storage, and ClientTests for the refactor.
  • Refactored the Site implementation to use Codable instead of custom encode/decode methods (used for the WidgetExtension)
  • Refactored Site implementation to automatically synthesize Hashable and Equatable conformance vs. explicit implementations, which has caused bugs in the past due to the implementations not being symmetrical
  • Cleaned up some naming for clarity (e.g. "sponsored tiles" -> "sponsored sites").

Testing

Test cases are listed in the attached JIRA ticket. 🙏

📝 Checklist

You have to check all boxes before merging

  • Filled in the above information (tickets numbers and description of your work)
  • Updated the PR name to follow our PR naming guidelines
  • Wrote unit tests and/or ensured the tests suite is passing
  • When working on UI, I checked and implemented accessibility (minimum Dynamic Text and VoiceOver)
  • If needed, I updated documentation / comments for complex code and public methods
  • If needed, added a backport comment (example @Mergifyio backport release/v120)

…wift Int ID's larger than Int32 will crash.
… Improved existing tests and refactored force unwraps. Fixed misleading assert messages.
… protocol. This allows for automatic conformance to Equatable and Hashable, which should reduce programmer error and crashes related to diffable data sources.
…er, TabManagerMiddleware, ContextMenuHelper, SponsoredTileTelemetry, UnifiedAdsCallbackTelemetry, JumpBackInViewModel, HistoryPanelViewModel, and the WidgetExtension to use the new Site type.
…tension factory helper for creating a sponsored site using a Contile in the Client target,
…10996-refactor-history-panel-hashable-conformance

# Conflicts:
#	firefox-ios/Storage/SQL/SQLiteHistoryFactories.swift
…ration from previous versions). Add documentation.
…tor-history-panel-hashable-conformance

# Conflicts:
#	firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesManager.swift
#	firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesMiddleware.swift
#	firefox-ios/Client/Frontend/Library/Bookmarks/BookmarksViewController.swift
#	firefox-ios/Storage/SQL/SQLiteHistoryFactories.swift
#	firefox-ios/firefox-ios-tests/Tests/StorageTests/TestSQLitePinnedSites.swift
@ih-codes ih-codes requested a review from Cramsden January 13, 2025 21:58
@ih-codes ih-codes requested a review from a team as a code owner January 13, 2025 21:58
@mobiletest-ci-bot
Copy link

mobiletest-ci-bot commented Jan 13, 2025

Warnings
⚠️ Pull Request size seems relatively large. If this Pull Request contains multiple changes, please split each into separate PR will helps faster, easier review. Consider using epic branches for work that would affect main.
Messages
📖 Project coverage: 34.23%
📖 Edited 67 files
📖 Created 6 files

Client.app: Coverage: 32.38

File Coverage
BrowserViewController.swift 3.47% ⚠️
ContextMenuConfiguration.swift 90.0%
TabManagerMiddleware.swift 7.12% ⚠️
Profile.swift 22.21% ⚠️
SearchLoader.swift 0.0% ⚠️
BackForwardListViewController.swift 12.79% ⚠️
SearchHighlightItem.swift 0.0% ⚠️
HomepageContextMenuHelper.swift 3.72% ⚠️
PhotonActionSheetViewModel.swift 15.56% ⚠️
MainMenuActionHelper.swift 0.0% ⚠️
TopSitesProvider.swift 98.03%
LegacyBookmarksPanel.swift 38.63% ⚠️
BookmarksViewController.swift 8.66% ⚠️
ContextMenuState.swift 2.82% ⚠️
TopSitesViewModel.swift 34.11% ⚠️
SearchViewController.swift 23.44% ⚠️
HistoryPanelViewModel.swift 84.46%
HomepageContextMenuProtocol.swift 0.0% ⚠️
UnifiedAdsCallbackTelemetry.swift 77.94%
TopSitesManager.swift 100.0%
SearchTelemetry.swift 5.34% ⚠️
LegacyHomepageViewController.swift 36.42% ⚠️
TopSite.swift 38.98% ⚠️
BookmarksViewModel.swift 27.73% ⚠️
ReaderPanel.swift 29.13% ⚠️
TopSitesMiddleware.swift 100.0%
SearchViewModel.swift 50.0% ⚠️
TopSitesWidgetManager.swift 0.0% ⚠️
TopSiteState.swift 30.36% ⚠️
SponsoredTileTelemetry.swift 83.33%
JumpBackInViewModel.swift 38.36% ⚠️
RecentlyClosedTabsPanel.swift 7.32% ⚠️
GoogleTopSiteManager.swift 80.0%
Site+createSponsoredSite.swift 100.0%
TopSiteItemCell.swift 0.0% ⚠️
TopSiteCell.swift 0.0% ⚠️
PocketViewModel.swift 72.95%
TopSitesDataAdaptor.swift 96.51%

CredentialProvider.appex: Coverage: 21.41

File Coverage
Profile.swift 22.21% ⚠️

NotificationService.appex: Coverage: 25.99

File Coverage
Profile.swift 22.21% ⚠️

ShareTo.appex: Coverage: 31.44

File Coverage
Profile.swift 22.21% ⚠️

WidgetKitExtension.appex: Coverage: 7.07

File Coverage
TopSitesProvider.swift 0.0% ⚠️

libStorage.a: Coverage: 56.71

File Coverage
RustPlaces.swift 80.88%
SiteType.swift 66.67%
SponsoredSiteInfo.swift 100.0%
SQLiteHistoryFactories.swift 85.71%
Site.swift 60.61%
PageMetadata.swift 33.33% ⚠️
DefaultSuggestedSites.swift 90.91%

Generated by 🚫 Danger Swift against 3f75507

Comment on lines -79 to -93
// MARK: - Encode & Decode

public static func encode(with encoder: JSONEncoder, data: [Site]) throws -> Data {
let storage = data.map { site in
return site.storage
}
return try encoder.encode(storage)
}

public static func decode(from decoder: JSONDecoder, data: Data) throws -> [Site] {
let storage = try decoder.decode([Storage].self, from: data)
return storage.map {
return Site(from: $0)
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was the previous custom encoding logic

Comment on lines -37 to -44
// Created since to avoid making Sites Codable which involes making also PageMetadata and Visit Codable too
private struct Storage: Codable {
let resource: SiteResource?
let title: String
let id: Int?
let guid: String?
let url: String
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was used for the custom encoding logic and was removed

Comment on lines -96 to -115
// MARK: - Hashable
extension Site: Hashable {
public func hash(into hasher: inout Hasher) {
// The == operator below must match the same requirements as this method
hasher.combine(id)
hasher.combine(guid)
hasher.combine(title)
hasher.combine(url)
hasher.combine(faviconResource)
}

public static func == (lhs: Site, rhs: Site) -> Bool {
// The hash method above must match the same requirements as this operator
return lhs.id == rhs.id
&& lhs.guid == rhs.guid
&& lhs.title == rhs.title
&& lhs.url == rhs.url
&& lhs.faviconResource == rhs.faviconResource
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These were the removed methods we want to auto synthesize to hopefully help with the crash

Copy link
Contributor

@Cramsden Cramsden left a comment

Choose a reason for hiding this comment

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

Some thoughts!

self.viewModel.profile.places.deleteBookmarksWithURL(url: site.url) >>== {
site.setBookmarked(false)
}
self.viewModel.profile.places.deleteBookmarksWithURL(url: site.url) >>== {}
Copy link
Contributor

Choose a reason for hiding this comment

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

This syntactical sugar is beyond me but do we still need the deferment chaos here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

😅 Looks like these are the options:
_ = self.viewModel.profile.places.deleteBookmarksWithURL(url: site.url)
and
self.viewModel.profile.places.deleteBookmarksWithURL(url: site.url) >>== {}

I'll go with the first one since it's slightly shorter / more obvious... and going to add a comment too.

// Return our bundled G icon for all of the Google Suite.
// Parse example: "https://drive.google.com/drive/home" > "drive.google.com" > "google"
imageResource = GoogleTopSiteManager.Constants.faviconResource
switch topSite.site.type {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I kinda wish this var was topSiteState instead of topsite or we could put a var on TopSiteState:

var type: SiteType {
     return site.type
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea, will add that!

case .pinnedSite, .suggestedSite:
imageResource = topSite.site.faviconResource
default:
if let siteURL = URL(string: siteURLString),
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this piece actually work? Isn't the google site a pinned site?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was to override ALL google favicons with our embedded google image (since google has notoriously low quality favicons for some reason)... The pinned google tile should have a faviconResource already attached. (Actually, funny story, it didn't, but thanks to this comment I double checked and fixed it at the call site in GoogleTopSiteManager.swift 😂 )

Comment on lines 138 to 140
if let domainMap = DefaultSuggestedSites.urlMap[site.url],
let localizedURL = domainMap[locale.identifier] {
return Site(copiedFromSite: site, withLocalizedURLString: localizedURL)
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this need to be a suggested Site instead of the default initializer

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good call out, since this looks wrong at a glance. But I think we're ok here. This copies the current Site (so .suggestedSite remains the same) and just updates the (nonmutable) url property on Site... a little hokey but I really didn't want url to be a var on the struct.

@@ -722,7 +722,7 @@ extension RustPlaces {
}
// Note: FXIOS-10740 Necessary to have unique Site ID iOS 18 HistoryPanel crash with diffable data sources
let hashValue = "\(info.url)_\(info.timestamp)".hashValue
let site = Site(id: hashValue, url: info.url, title: title)
var site = Site(id: hashValue, url: info.url, title: title, type: .basic)
Copy link
Contributor

Choose a reason for hiding this comment

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

Sometimes we are using the default initializer and sometimes we are using the static funcs to initialize... should we just pick one and make the others private?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmmm yes I see what you mean...

This is one of the rare places we explicitly want to define the ID rather than auto generate one, which is why it's like this...

I'm going to make the default init private, and we can add an optional first ID param to the createBasicSite factory method.

Comment on lines +27 to +29
return URL(string: url, invalidCharacters: false) ?? URL(string: "about:blank")!
default:
return URL(string: url, invalidCharacters: false)?.domainURL ?? URL(string: "about:blank")!
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this preferred to just having an optional url? I just worry if this is nil then the issue is obfuscated.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I honestly have no idea. Everyone wants to remove the force unwraps but I just copied what was already in the app and moved it here... 😬 I'm open to suggestions. 😆

Copy link
Contributor

Choose a reason for hiding this comment

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

hmmm I guess I would alternatively just have the optional value returned here and then we could unwrap it somewhere else more meaningful? But also this is fine for now because I imagine that is a bigger change.

Comment on lines +15 to +37
public var isPinnedSite: Bool {
switch self {
case .pinnedSite:
return true
default:
return false
}
}

public var isSponsoredSite: Bool {
switch self {
case .sponsoredSite:
return true
default:
return false
}
}

public var isSuggestedSite: Bool {
switch self {
case .suggestedSite:
return true
default:
Copy link
Contributor

Choose a reason for hiding this comment

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

This might be overkill but it might be nice to have these helpers in Site so that instead of accessing site.type.isSuggestedSite we could just access site.isSuggestedSite

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea, I'll add that! 👍

Copy link
Contributor

mergify bot commented Jan 15, 2025

This pull request has conflicts when rebasing. Could you fix it @ih-codes? 🙏

Copy link
Collaborator Author

@ih-codes ih-codes left a comment

Choose a reason for hiding this comment

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

Ok, made the requested changes, ready for another look! cc @Cramsden

// Return our bundled G icon for all of the Google Suite.
// Parse example: "https://drive.google.com/drive/home" > "drive.google.com" > "google"
imageResource = GoogleTopSiteManager.Constants.faviconResource
switch topSite.site.type {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea, will add that!

self.viewModel.profile.places.deleteBookmarksWithURL(url: site.url) >>== {
site.setBookmarked(false)
}
self.viewModel.profile.places.deleteBookmarksWithURL(url: site.url) >>== {}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

😅 Looks like these are the options:
_ = self.viewModel.profile.places.deleteBookmarksWithURL(url: site.url)
and
self.viewModel.profile.places.deleteBookmarksWithURL(url: site.url) >>== {}

I'll go with the first one since it's slightly shorter / more obvious... and going to add a comment too.

Comment on lines 138 to 140
if let domainMap = DefaultSuggestedSites.urlMap[site.url],
let localizedURL = domainMap[locale.identifier] {
return Site(copiedFromSite: site, withLocalizedURLString: localizedURL)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good call out, since this looks wrong at a glance. But I think we're ok here. This copies the current Site (so .suggestedSite remains the same) and just updates the (nonmutable) url property on Site... a little hokey but I really didn't want url to be a var on the struct.

Comment on lines +27 to +29
return URL(string: url, invalidCharacters: false) ?? URL(string: "about:blank")!
default:
return URL(string: url, invalidCharacters: false)?.domainURL ?? URL(string: "about:blank")!
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I honestly have no idea. Everyone wants to remove the force unwraps but I just copied what was already in the app and moved it here... 😬 I'm open to suggestions. 😆

Comment on lines +15 to +37
public var isPinnedSite: Bool {
switch self {
case .pinnedSite:
return true
default:
return false
}
}

public var isSponsoredSite: Bool {
switch self {
case .sponsoredSite:
return true
default:
return false
}
}

public var isSuggestedSite: Bool {
switch self {
case .suggestedSite:
return true
default:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea, I'll add that! 👍

@@ -722,7 +722,7 @@ extension RustPlaces {
}
// Note: FXIOS-10740 Necessary to have unique Site ID iOS 18 HistoryPanel crash with diffable data sources
let hashValue = "\(info.url)_\(info.timestamp)".hashValue
let site = Site(id: hashValue, url: info.url, title: title)
var site = Site(id: hashValue, url: info.url, title: title, type: .basic)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmmm yes I see what you mean...

This is one of the rare places we explicitly want to define the ID rather than auto generate one, which is why it's like this...

I'm going to make the default init private, and we can add an optional first ID param to the createBasicSite factory method.

case .pinnedSite, .suggestedSite:
imageResource = topSite.site.faviconResource
default:
if let siteURL = URL(string: siteURLString),
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was to override ALL google favicons with our embedded google image (since google has notoriously low quality favicons for some reason)... The pinned google tile should have a faviconResource already attached. (Actually, funny story, it didn't, but thanks to this comment I double checked and fixed it at the call site in GoogleTopSiteManager.swift 😂 )

@ih-codes ih-codes requested a review from Cramsden January 17, 2025 17:21
@ih-codes
Copy link
Collaborator Author

@Cramsden Argh, looks like some more merge conflicts on this branch -- will fix tomorrow morning. Just FYI if you re-review tonight.

Copy link
Contributor

mergify bot commented Jan 22, 2025

This pull request has conflicts when rebasing. Could you fix it @ih-codes? 🙏

Copy link
Contributor

@Cramsden Cramsden left a comment

Choose a reason for hiding this comment

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

I think there are some merge conflicts that need to be resolved but the additional changes look good to me!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants