diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 37e48dcb73..c29918a5f3 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -498,6 +498,8 @@ extension Pixel { case bookmarksCleanupFailed case bookmarksCleanupAttemptedWhileSyncWasEnabled case favoritesCleanupFailed + case bookmarksFaviconsFetcherStateStoreInitializationFailed + case bookmarksFaviconsFetcherFailed case credentialsDatabaseCleanupFailed case credentialsCleanupAttemptedWhileSyncWasEnabled @@ -990,6 +992,8 @@ extension Pixel.Event { case .bookmarksCleanupFailed: return "m_d_bookmarks_cleanup_failed" case .bookmarksCleanupAttemptedWhileSyncWasEnabled: return "m_d_bookmarks_cleanup_attempted_while_sync_was_enabled" case .favoritesCleanupFailed: return "m_d_favorites_cleanup_failed" + case .bookmarksFaviconsFetcherStateStoreInitializationFailed: return "m_d_bookmarks_favicons_fetcher_state_store_initialization_failed" + case .bookmarksFaviconsFetcherFailed: return "m_d_bookmarks_favicons_fetcher_failed" case .credentialsDatabaseCleanupFailed: return "m_d_credentials_database_cleanup_failed_2" case .credentialsCleanupAttemptedWhileSyncWasEnabled: return "m_d_credentials_cleanup_attempted_while_sync_was_enabled" diff --git a/Core/SyncBookmarksAdapter.swift b/Core/SyncBookmarksAdapter.swift index 5752c090dc..8986aab98a 100644 --- a/Core/SyncBookmarksAdapter.swift +++ b/Core/SyncBookmarksAdapter.swift @@ -30,14 +30,40 @@ public protocol FavoritesDisplayModeStoring: AnyObject { var favoritesDisplayMode: FavoritesDisplayMode { get set } } +public class BookmarksFaviconsFetcherErrorHandler: EventMapping { + + public init() { + super.init { event, _, _, _ in + Pixel.fire(pixel: .bookmarksFaviconsFetcherFailed, error: event.underlyingError) + } + } + + override init(mapping: @escaping EventMapping.Mapping) { + fatalError("Use init()") + } +} + +public enum SyncBookmarksAdapterError: CustomNSError { + case unableToAccessFaviconsFetcherStateStoreDirectory + + public static let errorDomain: String = "SyncBookmarksAdapterError" + + public var errorCode: Int { + switch self { + case .unableToAccessFaviconsFetcherStateStoreDirectory: + return 1 + } + } +} + public final class SyncBookmarksAdapter { + public static let syncBookmarksPausedStateChanged = Notification.Name("com.duckduckgo.app.SyncPausedStateChanged") + public static let bookmarksSyncLimitReached = Notification.Name("com.duckduckgo.app.SyncBookmarksLimitReached") + public private(set) var provider: BookmarksProvider? public let databaseCleaner: BookmarkDatabaseCleaner public let syncDidCompletePublisher: AnyPublisher - public let widgetRefreshCancellable: AnyCancellable - public static let syncBookmarksPausedStateChanged = Notification.Name("com.duckduckgo.app.SyncPausedStateChanged") - public static let bookmarksSyncLimitReached = Notification.Name("com.duckduckgo.app.SyncBookmarksLimitReached") public var shouldResetBookmarksSyncTimestamp: Bool = false { willSet { @@ -55,6 +81,22 @@ public final class SyncBookmarksAdapter { @UserDefaultsWrapper(key: .syncBookmarksPausedErrorDisplayed, defaultValue: false) static private var didShowBookmarksSyncPausedError: Bool + @Published + public var isFaviconsFetchingEnabled: Bool = UserDefaultsWrapper(key: .syncAutomaticallyFetchFavicons, defaultValue: false).wrappedValue { + didSet { + var udWrapper = UserDefaultsWrapper(key: .syncAutomaticallyFetchFavicons, defaultValue: false) + udWrapper.wrappedValue = isFaviconsFetchingEnabled + if isFaviconsFetchingEnabled { + faviconsFetcher?.initializeFetcherState() + } else { + faviconsFetcher?.cancelOngoingFetchingIfNeeded() + } + } + } + + @UserDefaultsWrapper(key: .syncIsEligibleForFaviconsFetcherOnboarding, defaultValue: false) + public var isEligibleForFaviconsFetcherOnboarding: Bool + public init(database: CoreDataDatabase, favoritesDisplayModeStorage: FavoritesDisplayModeStoring) { self.database = database self.favoritesDisplayModeStorage = favoritesDisplayModeStorage @@ -74,6 +116,7 @@ public final class SyncBookmarksAdapter { if shouldEnable { databaseCleaner.scheduleRegularCleaning() handleFavoritesAfterDisablingSync() + isFaviconsFetchingEnabled = false } else { databaseCleaner.cancelCleaningSchedule() } @@ -84,19 +127,62 @@ public final class SyncBookmarksAdapter { return } + let faviconsFetcher = setUpFaviconsFetcher() + let provider = BookmarksProvider( database: database, metadataStore: metadataStore, - syncDidUpdateData: { [syncDidCompleteSubject] in - syncDidCompleteSubject.send() + syncDidUpdateData: { [weak self] in + self?.syncDidCompleteSubject.send() Self.isSyncBookmarksPaused = false Self.didShowBookmarksSyncPausedError = false + }, + syncDidFinish: { [weak self] faviconsFetcherInput in + if let faviconsFetcher, self?.isFaviconsFetchingEnabled == true { + if let faviconsFetcherInput { + faviconsFetcher.updateBookmarkIDs( + modified: faviconsFetcherInput.modifiedBookmarksUUIDs, + deleted: faviconsFetcherInput.deletedBookmarksUUIDs + ) + } + faviconsFetcher.startFetching() + } } ) if shouldResetBookmarksSyncTimestamp { provider.lastSyncTimestamp = nil } + bindSyncErrorPublisher(provider) + + self.provider = provider + self.faviconsFetcher = faviconsFetcher + } + + private func setUpFaviconsFetcher() -> BookmarksFaviconsFetcher? { + let stateStore: BookmarksFaviconsFetcherStateStore + do { + guard let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw SyncBookmarksAdapterError.unableToAccessFaviconsFetcherStateStoreDirectory + } + stateStore = try BookmarksFaviconsFetcherStateStore(applicationSupportURL: url) + } catch { + Pixel.fire(pixel: .bookmarksFaviconsFetcherStateStoreInitializationFailed, error: error) + os_log(.error, log: .syncLog, "Failed to initialize BookmarksFaviconsFetcherStateStore: %{public}s", String(reflecting: error)) + return nil + } + + return BookmarksFaviconsFetcher( + database: database, + stateStore: stateStore, + fetcher: FaviconFetcher(), + faviconStore: Favicons.shared, + errorEvents: BookmarksFaviconsFetcherErrorHandler(), + log: .syncLog + ) + } + + private func bindSyncErrorPublisher(_ provider: BookmarksProvider) { syncErrorCancellable = provider.syncErrorPublisher .sink { error in switch error { @@ -126,15 +212,31 @@ public final class SyncBookmarksAdapter { } os_log(.error, log: OSLog.syncLog, "Bookmarks Sync error: %{public}s", String(reflecting: error)) } - - self.provider = provider } - static private func notifyBookmarksSyncLimitReached() { - if !Self.didShowBookmarksSyncPausedError { - NotificationCenter.default.post(name: Self.bookmarksSyncLimitReached, object: nil) - Self.didShowBookmarksSyncPausedError = true + public func cancelFaviconsFetching(_ application: UIApplication) { + guard let faviconsFetcher else { + return } + if faviconsFetcher.isFetchingInProgress == true { + os_log(.debug, log: .syncLog, "Favicons Fetching is in progress. Starting background task to allow it to gracefully complete.") + + var taskID: UIBackgroundTaskIdentifier! + taskID = application.beginBackgroundTask(withName: "Cancelled Favicons Fetching Completion Task") { + os_log(.debug, log: .syncLog, "Forcing background task completion") + application.endBackgroundTask(taskID) + } + faviconsFetchingDidFinishCancellable?.cancel() + faviconsFetchingDidFinishCancellable = faviconsFetcher.$isFetchingInProgress.dropFirst().filter { !$0 } + .prefix(1) + .receive(on: DispatchQueue.main) + .sink { _ in + os_log(.debug, log: .syncLog, "Ending background task") + application.endBackgroundTask(taskID) + } + } + + faviconsFetcher.cancelOngoingFetchingIfNeeded() } private func handleFavoritesAfterDisablingSync() { @@ -158,8 +260,19 @@ public final class SyncBookmarksAdapter { } } + static private func notifyBookmarksSyncLimitReached() { + if !Self.didShowBookmarksSyncPausedError { + NotificationCenter.default.post(name: Self.bookmarksSyncLimitReached, object: nil) + Self.didShowBookmarksSyncPausedError = true + } + } + private var syncDidCompleteSubject = PassthroughSubject() private var syncErrorCancellable: AnyCancellable? + private let database: CoreDataDatabase private let favoritesDisplayModeStorage: FavoritesDisplayModeStoring + private var faviconsFetcher: BookmarksFaviconsFetcher? + private var faviconsFetchingDidFinishCancellable: AnyCancellable? + private var widgetRefreshCancellable: AnyCancellable? } diff --git a/Core/SyncCredentialsAdapter.swift b/Core/SyncCredentialsAdapter.swift index e5c2101954..d98f7ba068 100644 --- a/Core/SyncCredentialsAdapter.swift +++ b/Core/SyncCredentialsAdapter.swift @@ -39,6 +39,7 @@ public final class SyncCredentialsAdapter { NotificationCenter.default.post(name: syncCredentialsPausedStateChanged, object: nil) } } + @UserDefaultsWrapper(key: .syncCredentialsPausedErrorDisplayed, defaultValue: false) static private var didShowCredentialsSyncPausedError: Bool diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index d675ee1bfa..475dcb2408 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -101,6 +101,10 @@ public struct UserDefaultsWrapper { case syncCredentialsPaused = "com.duckduckgo.ios.sync-credentialsPaused" case syncBookmarksPausedErrorDisplayed = "com.duckduckgo.ios.sync-bookmarksPausedErrorDisplayed" case syncCredentialsPausedErrorDisplayed = "com.duckduckgo.ios.sync-credentialsPausedErrorDisplayed" + case syncAutomaticallyFetchFavicons = "com.duckduckgo.ios.sync-automatically-fetch-favicons" + case syncIsFaviconsFetcherEnabled = "com.duckduckgo.ios.sync-is-favicons-fetcher-enabled" + case syncIsEligibleForFaviconsFetcherOnboarding = "com.duckduckgo.ios.sync-is-eligible-for-favicons-fetcher-onboarding" + case syncDidPresentFaviconsFetcherOnboarding = "com.duckduckgo.ios.sync-did-present-favicons-fetcher-onboarding" case networkProtectionDebugOptionAlwaysOnDisabled = "com.duckduckgo.network-protection.always-on.disabled" case networkProtectionWaitlistTermsAndConditionsAccepted = "com.duckduckgo.ios.vpn.terms-and-conditions-accepted" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index feaaa47f33..2c9bd6a012 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -253,6 +253,7 @@ 3760DFED299315EF0045A446 /* Waitlist in Frameworks */ = {isa = PBXBuildFile; productRef = 3760DFEC299315EF0045A446 /* Waitlist */; }; 377D80222AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377D80212AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift */; }; 379E877429E97C8D001C8BB0 /* BookmarksCleanupErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379E877329E97C8D001C8BB0 /* BookmarksCleanupErrorHandling.swift */; }; + 37A6A8FE2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A6A8FD2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift */; }; 37CBCA9E2A8A659C0050218F /* SyncSettingsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CBCA9D2A8A659C0050218F /* SyncSettingsAdapter.swift */; }; 37CEFCAC2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEFCAB2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift */; }; 37DF000A29F9C416002B7D3E /* SyncMetadataDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DF000929F9C416002B7D3E /* SyncMetadataDatabase.swift */; }; @@ -1291,6 +1292,7 @@ 37445F962A155F7C0029F789 /* SyncDataProviders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDataProviders.swift; sourceTree = ""; }; 377D80212AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesDisplayModeSyncHandler.swift; sourceTree = ""; }; 379E877329E97C8D001C8BB0 /* BookmarksCleanupErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksCleanupErrorHandling.swift; sourceTree = ""; }; + 37A6A8FD2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FaviconsFetcherOnboarding.swift; sourceTree = ""; }; 37CBCA9D2A8A659C0050218F /* SyncSettingsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettingsAdapter.swift; sourceTree = ""; }; 37CEFCAB2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialsCleanupErrorHandling.swift; sourceTree = ""; }; 37DF000929F9C416002B7D3E /* SyncMetadataDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetadataDatabase.swift; sourceTree = ""; }; @@ -4076,6 +4078,7 @@ 85F98F8C296F0ED100742F4A /* Sync */ = { isa = PBXGroup; children = ( + 37A6A8FD2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift */, 377D80202AB4853A002AF251 /* SettingSyncHandlers */, 85F98F97296F4CB100742F4A /* SyncAssets.xcassets */, 85F0E97229952D7A003D5181 /* DuckDuckGo Recovery Document.pdf */, @@ -6466,6 +6469,7 @@ 85DB12ED2A1FED0C000A4A72 /* AppDelegate+AppDeepLinks.swift in Sources */, 98DA6ECA2181E41F00E65433 /* ThemeManager.swift in Sources */, C159DF072A430B60007834BB /* EmailSignupViewController.swift in Sources */, + 37A6A8FE2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift in Sources */, 1E016AB6294A5EB100F21625 /* CustomDaxDialog.swift in Sources */, 02341FA42A437999008A1531 /* OnboardingStepView.swift in Sources */, F1CA3C3B1F045B65005FADB3 /* Authenticator.swift in Sources */, @@ -9203,7 +9207,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 86.1.0; + version = 87.0.0; }; }; C14882EB27F211A000D59F0C /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 74efce3cce..904e27989d 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/DuckDuckGo/BrowserServicesKit", "state": { "branch": null, - "revision": "1400c9ca17dd770d6eb708288f97c9aab749d0ef", - "version": "86.1.0" + "revision": "f1ae021c12c2afe3ade7ec0e41712725c03c3da4", + "version": "87.0.0" } }, { @@ -156,7 +156,7 @@ }, { "package": "TrackerRadarKit", - "repositoryURL": "https://github.com/duckduckgo/TrackerRadarKit", + "repositoryURL": "https://github.com/duckduckgo/TrackerRadarKit.git", "state": { "branch": null, "revision": "4684440d03304e7638a2c8086895367e90987463", diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index f9671de5dd..4e626e307d 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -562,6 +562,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { lastBackgroundDate = Date() AppDependencyProvider.shared.autofillLoginSession.endSession() suspendSync() + syncDataProviders.bookmarksAdapter.cancelFaviconsFetching(application) } private func suspendSync() { diff --git a/DuckDuckGo/BookmarksDataSource.swift b/DuckDuckGo/BookmarksDataSource.swift index 13f2834691..b02c31212a 100644 --- a/DuckDuckGo/BookmarksDataSource.swift +++ b/DuckDuckGo/BookmarksDataSource.swift @@ -24,6 +24,7 @@ import Core class BookmarksDataSource: NSObject, UITableViewDataSource { let viewModel: BookmarkListInteracting + var onFaviconMissing: ((String) -> Void)? var isEmpty: Bool { viewModel.bookmarks.isEmpty @@ -50,7 +51,11 @@ class BookmarksDataSource: NSObject, UITableViewDataSource { return cell } else { let cell = BookmarksViewControllerCellFactory.makeBookmarkCell(tableView, forIndexPath: indexPath) - cell.faviconImageView.loadFavicon(forDomain: bookmark.urlObject?.host, usingCache: .fireproof) + cell.faviconImageView.loadFavicon(forDomain: bookmark.urlObject?.host, usingCache: .fireproof) { [weak self] _, isFake in + if isFake, let host = bookmark.urlObject?.host { + self?.onFaviconMissing?(host) + } + } cell.titleLabel.text = bookmark.title cell.favoriteImageViewContainer.isHidden = !bookmark.isFavorite(on: viewModel.favoritesDisplayMode.displayedFolder) return cell diff --git a/DuckDuckGo/BookmarksViewController.swift b/DuckDuckGo/BookmarksViewController.swift index 8363a61384..20a03d1efa 100644 --- a/DuckDuckGo/BookmarksViewController.swift +++ b/DuckDuckGo/BookmarksViewController.swift @@ -96,7 +96,14 @@ class BookmarksViewController: UIViewController, UITableViewDelegate { fileprivate let viewModel: BookmarkListInteracting fileprivate lazy var dataSource: BookmarksDataSource = { - return BookmarksDataSource(viewModel: viewModel) + let dataSource = BookmarksDataSource(viewModel: viewModel) + dataSource.onFaviconMissing = { [weak self] _ in + guard let self else { + return + } + self.faviconsFetcherOnboarding.presentOnboardingIfNeeded(from: self) + } + return dataSource }() var searchDataSource: SearchBookmarksDataSource @@ -833,6 +840,8 @@ class BookmarksViewController: UIViewController, UITableViewDelegate { } } + private(set) lazy var faviconsFetcherOnboarding: FaviconsFetcherOnboarding = + .init(syncService: syncService, syncBookmarksAdapter: syncDataProviders.bookmarksAdapter) } extension BookmarksViewController: UISearchBarDelegate { diff --git a/DuckDuckGo/Favicons.swift b/DuckDuckGo/Favicons.swift index ceda1bf9d3..bf8baa03e1 100644 --- a/DuckDuckGo/Favicons.swift +++ b/DuckDuckGo/Favicons.swift @@ -17,6 +17,7 @@ // limitations under the License. // +import Bookmarks import Common import Kingfisher import UIKit @@ -481,4 +482,35 @@ public class Favicons { } } + +extension Favicons: Bookmarks.FaviconStoring { + + public func hasFavicon(for domain: String) -> Bool { + guard let targetCache = Favicons.Constants.caches[.fireproof], + let resource = defaultResource(forDomain: domain) + else { + return false + } + + return targetCache.isCached(forKey: resource.cacheKey) + } + + public func storeFavicon(_ imageData: Data, with url: URL?, for documentURL: URL) async throws { + + guard let domain = documentURL.host, + let options = kfOptions(forDomain: domain, withURL: documentURL, usingCache: .fireproof), + let resource = defaultResource(forDomain: domain), + let targetCache = Favicons.Constants.caches[.fireproof], + let image = UIImage(data: imageData) + else { + return + } + + Task { + let image = self.scaleDownIfNeeded(image: image, toFit: Constants.maxFaviconSize) + targetCache.store(image, forKey: resource.cacheKey, options: .init(options)) + WidgetCenter.shared.reloadAllTimelines() + } + } +} // swiftlint:enable type_body_length file_length diff --git a/DuckDuckGo/FaviconsFetcherOnboarding.swift b/DuckDuckGo/FaviconsFetcherOnboarding.swift new file mode 100644 index 0000000000..ec1418f2a7 --- /dev/null +++ b/DuckDuckGo/FaviconsFetcherOnboarding.swift @@ -0,0 +1,77 @@ +// +// FaviconsFetcherOnboarding.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Core +import DDGSync +import Foundation +import SwiftUI +import SyncUI + +final class FaviconsFetcherOnboarding { + + init(syncService: DDGSyncing, syncBookmarksAdapter: SyncBookmarksAdapter) { + self.syncService = syncService + self.syncBookmarksAdapter = syncBookmarksAdapter + self.viewModel = FaviconsFetcherOnboardingViewModel() + faviconsFetcherCancellable = viewModel.$isFaviconsFetchingEnabled.sink { [weak self] isEnabled in + self?.shouldEnableFaviconsFetcherOnDismiss = isEnabled + } + } + + @MainActor + func presentOnboardingIfNeeded(from viewController: UIViewController) { + let isCurrentlyPresenting = viewController.presentedViewController != nil + guard shouldPresentOnboarding, !isCurrentlyPresenting else { + return + } + didPresentFaviconsFetchingOnboarding = true + + let controller = UIHostingController(rootView: FaviconsFetcherOnboardingView(model: viewModel)) + if #available(iOS 16.0, *) { + controller.sheetPresentationController?.detents = [.custom(resolver: { _ in 462 })] + } + + viewModel.onDismiss = { [weak self, weak viewController] in + viewController?.dismiss(animated: true) + if self?.shouldEnableFaviconsFetcherOnDismiss == true { + self?.syncBookmarksAdapter.isFaviconsFetchingEnabled = true + self?.syncService.scheduler.notifyDataChanged() + } + } + + viewController.present(controller, animated: true) + } + + private var shouldPresentOnboarding: Bool { + !didPresentFaviconsFetchingOnboarding + && !syncBookmarksAdapter.isFaviconsFetchingEnabled + && syncBookmarksAdapter.isEligibleForFaviconsFetcherOnboarding + } + + @UserDefaultsWrapper(key: .syncDidPresentFaviconsFetcherOnboarding, defaultValue: false) + private var didPresentFaviconsFetchingOnboarding: Bool + + private let syncService: DDGSyncing + private let syncBookmarksAdapter: SyncBookmarksAdapter + private let viewModel: FaviconsFetcherOnboardingViewModel + + private var shouldEnableFaviconsFetcherOnDismiss: Bool = false + private var faviconsFetcherCancellable: AnyCancellable? +} diff --git a/DuckDuckGo/FavoriteHomeCell.swift b/DuckDuckGo/FavoriteHomeCell.swift index 36cbeb83ab..a75c9714fa 100644 --- a/DuckDuckGo/FavoriteHomeCell.swift +++ b/DuckDuckGo/FavoriteHomeCell.swift @@ -87,7 +87,7 @@ class FavoriteHomeCell: UICollectionViewCell { self.onRemove?() } - func updateFor(favorite: BookmarkEntity) { + func updateFor(favorite: BookmarkEntity, onFaviconMissing: ((String) -> Void)? = nil) { self.favorite = favorite let host = favorite.host @@ -111,6 +111,7 @@ class FavoriteHomeCell: UICollectionViewCell { iconImage?.loadFavicon(forDomain: domain, usingCache: .fireproof, useFakeFavicon: false) { image, _ in guard let image = image else { iconImage?.image = fakeFavicon + onFaviconMissing?(domain) return } diff --git a/DuckDuckGo/FavoritesHomeViewSectionRenderer.swift b/DuckDuckGo/FavoritesHomeViewSectionRenderer.swift index 7921e72699..0003ab9127 100644 --- a/DuckDuckGo/FavoritesHomeViewSectionRenderer.swift +++ b/DuckDuckGo/FavoritesHomeViewSectionRenderer.swift @@ -54,6 +54,8 @@ class FavoritesHomeViewSectionRenderer: NSObject, HomeViewSectionRenderer { var isEditing = false + var onFaviconMissing: ((String) -> Void)? + private let allowsEditing: Bool private let cellWidth: CGFloat private let cellHeight: CGFloat @@ -148,7 +150,9 @@ class FavoritesHomeViewSectionRenderer: NSObject, HomeViewSectionRenderer { self?.removeFavorite(cell, collectionView) } - cell.updateFor(favorite: favorite) + cell.updateFor(favorite: favorite, onFaviconMissing: { [weak self] domain in + self?.onFaviconMissing?(domain) + }) cell.isEditing = isEditing return cell diff --git a/DuckDuckGo/HomeCollectionView.swift b/DuckDuckGo/HomeCollectionView.swift index ee0b6818ee..7d3a086e82 100644 --- a/DuckDuckGo/HomeCollectionView.swift +++ b/DuckDuckGo/HomeCollectionView.swift @@ -84,8 +84,12 @@ class HomeCollectionView: UICollectionView { renderers.install(renderer: NavigationSearchHomeViewSectionRenderer(fixed: fixed)) case .favorites: - renderers.install(renderer: FavoritesHomeViewSectionRenderer(viewModel: favoritesViewModel)) - + let renderer = FavoritesHomeViewSectionRenderer(viewModel: favoritesViewModel) + renderer.onFaviconMissing = { _ in + controller.faviconsFetcherOnboarding.presentOnboardingIfNeeded(from: controller) + } + renderers.install(renderer: renderer) + case .homeMessage: renderers.install(renderer: HomeMessageViewSectionRenderer(homePageConfiguration: homePageConfiguration)) diff --git a/DuckDuckGo/HomeViewController.swift b/DuckDuckGo/HomeViewController.swift index c90a9ac317..3d79ff3747 100644 --- a/DuckDuckGo/HomeViewController.swift +++ b/DuckDuckGo/HomeViewController.swift @@ -22,8 +22,12 @@ import Core import Bookmarks import Combine import Common +import DDGSync import Persistence +// swiftlint:disable file_length + +// swiftlint:disable:next type_body_length class HomeViewController: UIViewController { @IBOutlet weak var ctaContainerBottom: NSLayoutConstraint! @@ -64,21 +68,25 @@ class HomeViewController: UIViewController { private let tabModel: Tab private let favoritesViewModel: FavoritesListInteracting private let appSettings: AppSettings + private let syncService: DDGSyncing + private let syncDataProviders: SyncDataProviders private var viewModelCancellable: AnyCancellable? private var favoritesDisplayModeCancellable: AnyCancellable? #if APP_TRACKING_PROTECTION private let appTPHomeViewModel: AppTPHomeViewModel #endif - + #if APP_TRACKING_PROTECTION + // swiftlint:disable:next function_parameter_count static func loadFromStoryboard( model: Tab, favoritesViewModel: FavoritesListInteracting, appSettings: AppSettings, + syncService: DDGSyncing, + syncDataProviders: SyncDataProviders, appTPDatabase: CoreDataDatabase ) -> HomeViewController { - let storyboard = UIStoryboard(name: "Home", bundle: nil) let controller = storyboard.instantiateViewController(identifier: "HomeViewController", creator: { coder in HomeViewController( @@ -86,16 +94,31 @@ class HomeViewController: UIViewController { tabModel: model, favoritesViewModel: favoritesViewModel, appSettings: appSettings, + syncService: syncService, + syncDataProviders: syncDataProviders, appTPDatabase: appTPDatabase ) }) return controller } #else - static func loadFromStoryboard(model: Tab, favoritesViewModel: FavoritesListInteracting, appSettings: AppSettings) -> HomeViewController { + static func loadFromStoryboard( + model: Tab, + favoritesViewModel: FavoritesListInteracting, + appSettings: AppSettings, + syncService: DDGSyncing, + syncDataProviders: SyncDataProviders + ) -> HomeViewController { let storyboard = UIStoryboard(name: "Home", bundle: nil) let controller = storyboard.instantiateViewController(identifier: "HomeViewController", creator: { coder in - HomeViewController(coder: coder, tabModel: model, favoritesViewModel: favoritesViewModel, appSettings: appSettings) + HomeViewController( + coder: coder, + tabModel: model, + favoritesViewModel: favoritesViewModel, + appSettings: appSettings, + syncService: syncService, + syncDataProviders: syncDataProviders + ) }) return controller } @@ -107,20 +130,34 @@ class HomeViewController: UIViewController { tabModel: Tab, favoritesViewModel: FavoritesListInteracting, appSettings: AppSettings, + syncService: DDGSyncing, + syncDataProviders: SyncDataProviders, appTPDatabase: CoreDataDatabase ) { self.tabModel = tabModel self.favoritesViewModel = favoritesViewModel self.appSettings = appSettings + self.syncService = syncService + self.syncDataProviders = syncDataProviders + self.appTPHomeViewModel = AppTPHomeViewModel(appTrackingProtectionDatabase: appTPDatabase) super.init(coder: coder) } #else - required init?(coder: NSCoder, tabModel: Tab, favoritesViewModel: FavoritesListInteracting, appSettings: AppSettings) { + required init?( + coder: NSCoder, + tabModel: Tab, + favoritesViewModel: FavoritesListInteracting, + appSettings: AppSettings, + syncService: DDGSyncing, + syncDataProviders: SyncDataProviders + ) { self.tabModel = tabModel self.favoritesViewModel = favoritesViewModel self.appSettings = appSettings + self.syncService = syncService + self.syncDataProviders = syncDataProviders super.init(coder: coder) } @@ -330,6 +367,9 @@ class HomeViewController: UIViewController { func launchNewSearch() { collectionView.launchNewSearch() } + + private(set) lazy var faviconsFetcherOnboarding: FaviconsFetcherOnboarding = + .init(syncService: syncService, syncBookmarksAdapter: syncDataProviders.bookmarksAdapter) } extension HomeViewController: FavoritesHomeViewSectionRendererDelegate { diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 66785d4359..f043f52def 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -634,9 +634,15 @@ class MainViewController: UIViewController { let controller = HomeViewController.loadFromStoryboard(model: tabModel!, favoritesViewModel: favoritesViewModel, appSettings: appSettings, + syncService: syncService, + syncDataProviders: syncDataProviders, appTPDatabase: appTrackingProtectionDatabase) #else - let controller = HomeViewController.loadFromStoryboard(model: tabModel!, favoritesViewModel: favoritesViewModel, appSettings: appSettings) + let controller = HomeViewController.loadFromStoryboard(model: tabModel!, + favoritesViewModel: favoritesViewModel, + appSettings: appSettings, + syncService: syncService, + syncDataProviders: syncDataProviders) #endif homeController = controller diff --git a/DuckDuckGo/SettingsViewController.swift b/DuckDuckGo/SettingsViewController.swift index 42bc7ae375..85827c1b4d 100644 --- a/DuckDuckGo/SettingsViewController.swift +++ b/DuckDuckGo/SettingsViewController.swift @@ -388,7 +388,7 @@ class SettingsViewController: UITableViewController { } func showSync(animated: Bool = true) { - let controller = SyncSettingsViewController() + let controller = SyncSettingsViewController(syncService: syncService, syncBookmarksAdapter: syncDataProviders.bookmarksAdapter) navigationController?.pushViewController(controller, animated: animated) } diff --git a/DuckDuckGo/SyncAssets.xcassets/SyncFetchFavicons.imageset/Contents.json b/DuckDuckGo/SyncAssets.xcassets/SyncFetchFavicons.imageset/Contents.json new file mode 100644 index 0000000000..082f3893d1 --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/SyncFetchFavicons.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SyncFetchFavicons.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/SyncAssets.xcassets/SyncFetchFavicons.imageset/SyncFetchFavicons.svg b/DuckDuckGo/SyncAssets.xcassets/SyncFetchFavicons.imageset/SyncFetchFavicons.svg new file mode 100644 index 0000000000..a145705a8f --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/SyncFetchFavicons.imageset/SyncFetchFavicons.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/DuckDuckGo/SyncAssets.xcassets/SyncFetchFaviconsLogo.imageset/Contents.json b/DuckDuckGo/SyncAssets.xcassets/SyncFetchFaviconsLogo.imageset/Contents.json new file mode 100644 index 0000000000..b881c24166 --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/SyncFetchFaviconsLogo.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "SyncFetchFaviconsLogo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/DuckDuckGo/SyncAssets.xcassets/SyncFetchFaviconsLogo.imageset/SyncFetchFaviconsLogo.svg b/DuckDuckGo/SyncAssets.xcassets/SyncFetchFaviconsLogo.imageset/SyncFetchFaviconsLogo.svg new file mode 100644 index 0000000000..a76a6e302c --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/SyncFetchFaviconsLogo.imageset/SyncFetchFaviconsLogo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/SyncDebugViewController.swift b/DuckDuckGo/SyncDebugViewController.swift index b5fe9f351a..d674e85c59 100644 --- a/DuckDuckGo/SyncDebugViewController.swift +++ b/DuckDuckGo/SyncDebugViewController.swift @@ -46,6 +46,7 @@ class SyncDebugViewController: UITableViewController { case syncNow case logOut case toggleFavoritesDisplayMode + case resetFaviconsFetcherOnboardingDialog } @@ -111,6 +112,8 @@ class SyncDebugViewController: UITableViewController { cell.textLabel?.text = "Log out of sync in 10 seconds" case .toggleFavoritesDisplayMode: cell.textLabel?.text = "Toggle favorites display mode in 10 seconds" + case .resetFaviconsFetcherOnboardingDialog: + cell.textLabel?.text = "Reset Favicons Fetcher onboarding dialog" case .none: break } @@ -161,6 +164,7 @@ class SyncDebugViewController: UITableViewController { } } + // swiftlint:disable:next cyclomatic_complexity override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Sections(rawValue: indexPath.section) { case .info: @@ -184,6 +188,10 @@ class SyncDebugViewController: UITableViewController { AppDependencyProvider.shared.appSettings.favoritesDisplayMode = displayMode NotificationCenter.default.post(name: AppUserDefaults.Notifications.favoritesDisplayModeChange, object: nil) } + case .resetFaviconsFetcherOnboardingDialog: + var udWrapper = UserDefaultsWrapper(key: .syncDidPresentFaviconsFetcherOnboarding, defaultValue: false) + udWrapper.wrappedValue = false + default: break } case .environment: diff --git a/DuckDuckGo/SyncSettingsViewController.swift b/DuckDuckGo/SyncSettingsViewController.swift index 6bba63102b..7e1f5558ea 100644 --- a/DuckDuckGo/SyncSettingsViewController.swift +++ b/DuckDuckGo/SyncSettingsViewController.swift @@ -18,6 +18,7 @@ // import SwiftUI +import Core import Combine import SyncUI import DDGSync @@ -27,7 +28,8 @@ class SyncSettingsViewController: UIHostingController { lazy var authenticator = Authenticator() - let syncService: DDGSyncing! = (UIApplication.shared.delegate as? AppDelegate)!.syncService + let syncService: DDGSyncing + let syncBookmarksAdapter: SyncBookmarksAdapter var connector: RemoteConnecting? var recoveryCode: String { @@ -50,11 +52,15 @@ class SyncSettingsViewController: UIHostingController { var cancellables = Set() // For some reason, on iOS 14, the viewDidLoad wasn't getting called so do some setup here - convenience init(appSettings: AppSettings = AppDependencyProvider.shared.appSettings) { + init(syncService: DDGSyncing, syncBookmarksAdapter: SyncBookmarksAdapter, appSettings: AppSettings = AppDependencyProvider.shared.appSettings) { + self.syncService = syncService + self.syncBookmarksAdapter = syncBookmarksAdapter + let viewModel = SyncSettingsViewModel() - self.init(rootView: SyncSettingsView(model: viewModel)) + super.init(rootView: SyncSettingsView(model: viewModel)) + setUpFaviconsFetcherSwitch(viewModel) setUpFavoritesDisplayModeSwitch(viewModel, appSettings) setUpSyncPaused(viewModel, appSettings) refreshForState(syncService.authState) @@ -70,6 +76,41 @@ class SyncSettingsViewController: UIHostingController { rootView.model.delegate = self navigationItem.title = UserText.syncTitle } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpFaviconsFetcherSwitch(_ viewModel: SyncSettingsViewModel) { + viewModel.isFaviconsFetchingEnabled = syncBookmarksAdapter.isFaviconsFetchingEnabled + + syncBookmarksAdapter.$isFaviconsFetchingEnabled + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { isFaviconsFetchingEnabled in + if viewModel.isFaviconsFetchingEnabled != isFaviconsFetchingEnabled { + viewModel.isFaviconsFetchingEnabled = isFaviconsFetchingEnabled + } + } + .store(in: &cancellables) + + viewModel.$devices + .map { $0.count > 1 } + .removeDuplicates() + .sink { [weak self] hasMoreThanOneDevice in + self?.syncBookmarksAdapter.isEligibleForFaviconsFetcherOnboarding = hasMoreThanOneDevice + } + .store(in: &cancellables) + + viewModel.$isFaviconsFetchingEnabled + .sink { [weak self] isFaviconsFetchingEnabled in + self?.syncBookmarksAdapter.isFaviconsFetchingEnabled = isFaviconsFetchingEnabled + if isFaviconsFetchingEnabled { + self?.syncService.scheduler.notifyDataChanged() + } + } + .store(in: &cancellables) + } private func setUpFavoritesDisplayModeSwitch(_ viewModel: SyncSettingsViewModel, _ appSettings: AppSettings) { viewModel.isUnifiedFavoritesEnabled = appSettings.favoritesDisplayMode.isDisplayUnified diff --git a/DuckDuckGoTests/AppSettingsMock.swift b/DuckDuckGoTests/AppSettingsMock.swift index 72ec6924a2..1902cfb9fe 100644 --- a/DuckDuckGoTests/AppSettingsMock.swift +++ b/DuckDuckGoTests/AppSettingsMock.swift @@ -22,7 +22,6 @@ import Foundation @testable import DuckDuckGo class AppSettingsMock: AppSettings { - var isSyncBookmarksPaused: Bool = false var isSyncCredentialsPaused: Bool = false diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/FaviconsFetcherOnboardingViewModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/FaviconsFetcherOnboardingViewModel.swift new file mode 100644 index 0000000000..9556ccf246 --- /dev/null +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/FaviconsFetcherOnboardingViewModel.swift @@ -0,0 +1,28 @@ +// +// FaviconsFetcherOnboardingViewModel.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final public class FaviconsFetcherOnboardingViewModel: ObservableObject { + @Published public var isFaviconsFetchingEnabled: Bool = false + + public var onDismiss: () -> Void = {} + + public init() {} +} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift index 3e13d1c9a1..8f78f1ea59 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift @@ -76,7 +76,7 @@ public class SyncSettingsViewModel: ObservableObject { } @Published public var devices = [Device]() - @Published public var isFaviconsSyncEnabled = false + @Published public var isFaviconsFetchingEnabled = false @Published public var isUnifiedFavoritesEnabled = true @Published public var isSyncingDevices = false @Published public var isSyncBookmarksPaused = false diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/DeviceConnectedView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/DeviceConnectedView.swift index 2adec5fc5c..2a04672468 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/DeviceConnectedView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/DeviceConnectedView.swift @@ -19,8 +19,6 @@ import SwiftUI import DuckUI -import DesignResourcesKit - public struct DeviceConnectedView: View { diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/FaviconsFetcherOnboardingView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/FaviconsFetcherOnboardingView.swift new file mode 100644 index 0000000000..b60c96e350 --- /dev/null +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/FaviconsFetcherOnboardingView.swift @@ -0,0 +1,73 @@ +// +// FaviconsFetcherOnboardingView.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DuckUI +import DesignResourcesKit + +public struct FaviconsFetcherOnboardingView: View { + + public init(model: FaviconsFetcherOnboardingViewModel) { + self.model = model + } + + @ObservedObject public var model: FaviconsFetcherOnboardingViewModel + + public var body: some View { + VStack(spacing: 0) { + + VStack(spacing: 24) { + Image("SyncFetchFaviconsLogo") + + Text(UserText.fetchFaviconsOnboardingTitle) + .daxTitle1() + + Text(UserText.fetchFaviconsOnboardingMessage) + .multilineTextAlignment(.center) + .daxBodyRegular() + } + .padding(.horizontal, 24) + .padding(.vertical, 20) + + VStack(spacing: 8) { + Button { + withAnimation { + model.isFaviconsFetchingEnabled = true + model.onDismiss() + } + } label: { + Text(UserText.fetchFaviconsOnboardingButtonTitle) + } + .buttonStyle(PrimaryButtonStyle()) + .frame(maxWidth: 360) + + Button { + withAnimation { + model.onDismiss() + } + } label: { + Text(UserText.notNowButton) + } + .buttonStyle(SecondaryButtonStyle()) + .frame(maxWidth: 360) + } + .padding(.init(top: 24, leading: 24, bottom: 0, trailing: 24)) + } + } +} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Internal/UserText.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Internal/UserText.swift index 1fc5c3535b..060af63f53 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Internal/UserText.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Internal/UserText.swift @@ -39,6 +39,9 @@ struct UserText { static let recoverYourData = "Recover Your Data" static let options = "Options" + static let fetchFaviconsOptionTitle = "Fetch Bookmark Icons" + static let fetchFaviconsOptionCaption = "Automatically download icons for synced bookmarks." + static let unifiedFavoritesTitle = "Share Favorites" static let unifiedFavoritesInstruction = "Use the same favorites on all devices. Leave off to keep mobile and desktop favorites separate." @@ -118,6 +121,9 @@ struct UserText { static let bookmarksLimitExceededAction = "Manage Bookmarks" static let credentialsLimitExceededAction = "Manage Logins" + static let fetchFaviconsOnboardingTitle = "Download Missing Icons?" + static let fetchFaviconsOnboardingMessage = "Do you want this device to automatically download icons for any new bookmarks synced from your other devices? This will expose the download to your network any time a bookmark is synced." + static let fetchFaviconsOnboardingButtonTitle = "Keep Bookmarks Icons Updated" } // swiftlint:enable line_length diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsView.swift index f2fff4bf6c..5f8791bad5 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsView.swift @@ -58,11 +58,14 @@ public struct SyncSettingsView: View { syncNewDevice() - OptionsView(isUnifiedFavoritesEnabled: $model.isUnifiedFavoritesEnabled) - .onAppear(perform: { - model.delegate?.updateOptions() - }) - + OptionsView( + isFaviconsFetchingEnabled: $model.isFaviconsFetchingEnabled, + isUnifiedFavoritesEnabled: $model.isUnifiedFavoritesEnabled + ) + .onAppear { + model.delegate?.updateOptions() + } + saveRecoveryPDF() deleteAllData() @@ -359,9 +362,23 @@ extension View { public struct OptionsView: View { + @Binding var isFaviconsFetchingEnabled: Bool @Binding var isUnifiedFavoritesEnabled: Bool + public var body: some View { Section { + Toggle(isOn: $isFaviconsFetchingEnabled) { + HStack(spacing: 16) { + Image("SyncFetchFavicons") + VStack(alignment: .leading) { + Text(UserText.fetchFaviconsOptionTitle) + .foregroundColor(.primary) + Text(UserText.fetchFaviconsOptionCaption) + .daxBodyRegular() + .foregroundColor(.secondary) + } + } + } Toggle(isOn: $isUnifiedFavoritesEnabled) { HStack(spacing: 16) { Image("SyncAllDevices")