diff --git a/.github/workflows/sync-end-to-end.yml b/.github/workflows/sync-end-to-end.yml new file mode 100644 index 0000000000..df2a04df7c --- /dev/null +++ b/.github/workflows/sync-end-to-end.yml @@ -0,0 +1,63 @@ +name: Sync-End-to-End tests + +on: + schedule: + - cron: '0 5 * * *' # run at 5 AM UTC + +jobs: + sync-end-to-end-tests: + name: Sync End to end Tests + runs-on: macos-13 + + steps: + - name: Check out the code + uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Set cache key hash + run: | + has_only_tags=$(jq '[ .object.pins[].state | has("version") ] | all' DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved) + if [[ "$has_only_tags" == "true" ]]; then + echo "cache_key_hash=${{ hashFiles('DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}" >> $GITHUB_ENV + else + echo "Package.resolved contains dependencies specified by branch or commit, skipping cache." + fi + + - name: Cache SPM + if: env.cache_key_hash + uses: actions/cache@v3 + with: + path: DerivedData/SourcePackages + key: ${{ runner.os }}-spm-${{ env.cache_key_hash }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_$(<.xcode-version).app/Contents/Developer + + - name: Build for tests + run: | + set -o pipefail && xcodebuild \ + -scheme "DuckDuckGo" \ + -destination "platform=iOS Simulator,name=iPhone 14,OS=16.4" \ + -derivedDataPath "DerivedData" \ + | tee xcodebuild.log + + - name: Create test account for Sync and return the recovery code + uses: duckduckgo/sync_crypto/action@main + id: sync-recovery-code + with: + debug: true + + - name: Sync e2e tests + uses: mobile-dev-inc/action-maestro-cloud@v1.6.0 + with: + api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} + app-file: DerivedData/Build/Products/Debug-iphonesimulator/DuckDuckGo.app + workspace: .maestro + include-tags: sync + env: | + CODE=${{ steps.sync-recovery-code.outputs.recovery-code }} + + diff --git a/.gitignore b/.gitignore index d6fc1c4859..f723942232 100644 --- a/.gitignore +++ b/.gitignore @@ -70,9 +70,6 @@ fastlane/report.xml fastlane/Preview.html fastlane/test_output -# Mestro -.maestro/**/shared - # DuckDuckGo Configuration/ExternalDeveloper.xcconfig diff --git a/.maestro/shared/set_internal_user.yaml b/.maestro/shared/set_internal_user.yaml new file mode 100644 index 0000000000..97d6e6c9c4 --- /dev/null +++ b/.maestro/shared/set_internal_user.yaml @@ -0,0 +1,10 @@ +appId: com.duckduckgo.mobile.ios +--- + +- scroll +- scroll +- scroll +- assertVisible: Debug Menu +- tapOn: Debug Menu +- tapOn: Internal User State +- tapOn: Settings \ No newline at end of file diff --git a/.maestro/shared/sync_create.yaml b/.maestro/shared/sync_create.yaml new file mode 100644 index 0000000000..8164466ac3 --- /dev/null +++ b/.maestro/shared/sync_create.yaml @@ -0,0 +1,12 @@ +appId: com.duckduckgo.mobile.ios +--- + +- assertVisible: Sync +- tapOn: Sync +- assertVisible: Sync +- tapOn: "0" +- assertVisible: Turn on Sync? +- tapOn: Turn on Sync +- tapOn: Sync Another Device +- tapOn: Show QR Code +- assertVisible: "Go to Settings > Sync in the DuckDuckGo App on a different device and scan this QR code to sync." \ No newline at end of file diff --git a/.maestro/shared/sync_delete.yaml b/.maestro/shared/sync_delete.yaml new file mode 100644 index 0000000000..a82919c953 --- /dev/null +++ b/.maestro/shared/sync_delete.yaml @@ -0,0 +1,9 @@ +appId: com.duckduckgo.mobile.ios +--- + +- assertVisible: Sync +- scroll +- tapOn: + point: 50%,91% # TODO: Revisit after new setup flow has been implemented. +- assertVisible: Delete Server Data? +- tapOn: Delete Server Data \ No newline at end of file diff --git a/.maestro/sync_tests/01_create_account.yaml b/.maestro/sync_tests/01_create_account.yaml new file mode 100644 index 0000000000..6e39ee2fd4 --- /dev/null +++ b/.maestro/sync_tests/01_create_account.yaml @@ -0,0 +1,29 @@ +appId: com.duckduckgo.mobile.ios +tags: + - sync + +--- + +- clearState +- launchApp +- runFlow: + when: + visible: + text: "Let’s Do It!" + index: 0 + file: ../shared/onboarding.yaml + +- tapOn: Settings +- runFlow: + file: ../shared/set_internal_user.yaml +- runFlow: + file: ../shared/sync_create.yaml + + +# Clean up +- tapOn: Back +- tapOn: Cancel +- tapOn: Not Now +- assertVisible: Sync +- runFlow: + file: ../shared/sync_delete.yaml \ No newline at end of file diff --git a/.maestro/sync_tests/02_login_account.yaml b/.maestro/sync_tests/02_login_account.yaml new file mode 100644 index 0000000000..aef46f2912 --- /dev/null +++ b/.maestro/sync_tests/02_login_account.yaml @@ -0,0 +1,45 @@ +appId: com.duckduckgo.mobile.ios +tags: + - sync + +--- + +# Create an account +- clearState +- launchApp +- runFlow: + when: + visible: + text: "Let’s Do It!" + index: 0 + file: ../shared/onboarding.yaml + +- tapOn: Settings +- runFlow: + file: ../shared/set_internal_user.yaml +- runFlow: + file: ../shared/sync_create.yaml + +# Copy Sync Code and Log Out +- tapOn: Back +- tapOn: Cancel +- assertVisible: Save Recovery Key +- tapOn: Copy Key +- tapOn: Not Now +- tapOn: "1" +- assertVisible: Turn Off Sync? +- tapOn: Remove + +# Login +- tapOn: "0" +- tapOn: Recover Your Synced Data +- tapOn: Manually Enter Code +- tapOn: Paste +- assertVisible: Device Synced! +- tapOn: Next +- tapOn: Not Now + +# Clean up +- assertVisible: Sync +- runFlow: + file: ../shared/sync_delete.yaml \ No newline at end of file diff --git a/.maestro/sync_tests/03_recover_account.yaml b/.maestro/sync_tests/03_recover_account.yaml new file mode 100644 index 0000000000..265684b884 --- /dev/null +++ b/.maestro/sync_tests/03_recover_account.yaml @@ -0,0 +1,51 @@ +appId: com.duckduckgo.mobile.ios +tags: + - sync + +--- + +- clearState +- launchApp +- runFlow: + when: + visible: + text: "Let’s Do It!" + index: 0 + file: ../shared/onboarding.yaml + +# This is a workaround to: +# - Put the code in the clipboard on Maestro Cloud +# - Prevent iOS from showing the Paste permission alert as Maestro can't handle it +- tapOn: + id: searchEntry +- inputText: ${CODE} +- longPressOn: + id: searchEntry +- tapOn: Select All +- tapOn: Cut +- tapOn: + id: searchEntry +- longPressOn: + id: searchEntry +- tapOn: Paste +- tapOn: Cancel +# + +# Recover Account test +- tapOn: Settings +- runFlow: + file: ../shared/set_internal_user.yaml +- assertVisible: Sync +- tapOn: Sync +- assertVisible: Sync +- tapOn: "0" +- assertVisible: Turn on Sync? +- tapOn: Recover Your Synced Data +- assertVisible: Scan QR Code +- tapOn: Manually Enter Code +- tapOn: Paste +- assertVisible: Device Synced! +- tapOn: Next +- tapOn: Not Now +- tapOn: Settings +- tapOn: Done \ No newline at end of file diff --git a/.maestro/sync_tests/04_sync_data.yaml b/.maestro/sync_tests/04_sync_data.yaml new file mode 100644 index 0000000000..643cd1a431 --- /dev/null +++ b/.maestro/sync_tests/04_sync_data.yaml @@ -0,0 +1,162 @@ +appId: com.duckduckgo.mobile.ios +tags: + - sync + +--- + +- clearState +- launchApp +- runFlow: + when: + visible: + text: "Let’s Do It!" + index: 0 + file: ../shared/onboarding.yaml + +# Add local favorite and bookmark +- tapOn: + id: searchEntry +- inputText: www.duckduckgo.com +- pressKey: Enter +- runFlow: + when: + visible: + text: "Got It" + commands: + - tapOn: Got It +- tapOn: Browsing Menu +- tapOn: Add Favorite +- tapOn: + id: searchEntry +- inputText: www.spreadprivacy.com +- pressKey: Enter +- tapOn: Browsing Menu +- tapOn: Add Bookmark + +# Add local login +- tapOn: Browsing Menu +- tapOn: Settings +- tapOn: Logins +- tapOn: Add 24 +- tapOn: Title +- inputText: My Personal Website +- tapOn: username@example.com +- inputText: me@mypersonalwebsite.com +- tapOn: example.com +- inputText: mypersonalwebsite.com +- tapOn: Save +- tapOn: Logins +- tapOn: Settings +- tapOn: Done + +# Sync data +# This is a workaround to: +# - Put the code in the clipboard on Maestro Cloud +# - Prevent iOS from showing the Paste permission alert as Maestro can't handle it +- tapOn: + id: searchEntry +- inputText: ${CODE} +- longPressOn: + id: searchEntry +- runFlow: + when: + visible: + text: searchEntry + commands: + - tapOn: searchEntry +- tapOn: Select All +- tapOn: Cut +- tapOn: + id: searchEntry +- longPressOn: + id: searchEntry +- tapOn: Paste +- tapOn: Cancel + +- tapOn: Close Tabs and Clear Data +- tapOn: Close Tabs and Clear Data +- runFlow: + when: + visible: + text: "Cancel" + commands: + - tapOn: Cancel +# + +- tapOn: Settings +- runFlow: + file: ../shared/set_internal_user.yaml +- assertVisible: Sync +- tapOn: Sync +- assertVisible: Sync +- tapOn: "0" +- assertVisible: Turn on Sync? +- tapOn: Recover Your Synced Data +- assertVisible: Scan QR Code +- tapOn: Manually Enter Code +- tapOn: Paste +- assertVisible: Device Synced! +- tapOn: Next +- tapOn: Not Now +- tapOn: Settings +- tapOn: Done + +# Verify bookmarks and favorites have been merged +- tapOn: Bookmarks + +- assertVisible: Spread Privacy +- assertVisible: Stack Overflow - Where Developers Learn, Share, & Build Careers +- assertVisible: DuckDuckGo — Privacy, simplified. +- assertVisible: DuckDuckGo · GitHub +- assertVisible: "Wolfram|Alpha: Computational Intelligence" +- assertVisible: news +- assertVisible: code +- assertVisible: sports +- tapOn: news +- assertVisible: Breaking News, Latest News and Videos | CNN +- assertVisible: News, sport and opinion from the Guardian's global edition | The Guardian +- tapOn: Bookmarks +- tapOn: code +- assertVisible: "GitHub - duckduckgo/Android: DuckDuckGo Android App" +- assertVisible: "GitHub - duckduckgo/iOS: DuckDuckGo iOS Application" +- tapOn: Bookmarks +- tapOn: sports +- assertVisible: NFL.com | Official Site of the National Football League +- assertVisible: AS.com - Diario online deportivo. Fútbol, motor y mucho más +- tapOn: Bookmarks +- tapOn: Favorites +- assertVisible: DuckDuckGo — Privacy, simplified. +- assertVisible: NFL.com | Official Site of the National Football League +- assertVisible: DuckDuckGo · GitHub +- assertVisible: Stack Overflow - Where Developers Learn, Share, & Build Careers +- tapOn: Done + +# Verify logins +- tapOn: Settings +- tapOn: Logins +- assertVisible: Unlock device to access saved Logins +- tapOn: Passcode field +- inputText: "0000" +- pressKey: Enter +- assertVisible: Dax Login +- tapOn: Dax Login +- assertVisible: daxthetest +- assertVisible: duckduckgo.com +- tapOn: Logins +- assertVisible: Github +- tapOn: Github +- assertVisible: githubusername +- assertVisible: github.com +- tapOn: Logins +- assertVisible: StackOverflow +- tapOn: StackOverflow +- assertVisible: stacker +- assertVisible: stackoverflow.com +- tapOn: Logins +- assertVisible: My Personal Website +- tapOn: My Personal Website +- assertVisible: me@mypersonalwebsite.com +- assertVisible: mypersonalwebsite.com +- tapOn: Logins +- tapOn: Settings +- tapOn: Done \ No newline at end of file diff --git a/Core/BookmarksCachingSearch.swift b/Core/BookmarksCachingSearch.swift index 17b264b198..c43c3f2712 100644 --- a/Core/BookmarksCachingSearch.swift +++ b/Core/BookmarksCachingSearch.swift @@ -70,8 +70,8 @@ public class CoreDataBookmarksSearchStore: BookmarksSearchStore { fetchRequest.resultType = .dictionaryResultType fetchRequest.propertiesToFetch = [#keyPath(BookmarkEntity.title), #keyPath(BookmarkEntity.url), - #keyPath(BookmarkEntity.favoriteFolder), #keyPath(BookmarkEntity.objectID)] + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(BookmarkEntity.favoriteFolders)] context.perform { let result = try? context.fetch(fetchRequest) as? [Dictionary] @@ -131,7 +131,7 @@ public class BookmarksCachingSearch: BookmarksStringSearch { self.init(objectID: objectID, title: title, url: url, - isFavorite: bookmark[#keyPath(BookmarkEntity.favoriteFolder)] != nil) + isFavorite: (bookmark[#keyPath(BookmarkEntity.favoriteFolders)] as? Set)?.isEmpty != true) } public func togglingFavorite() -> BookmarksStringSearchResult { diff --git a/Core/BookmarksExporter.swift b/Core/BookmarksExporter.swift index 53fb563af9..294b59a076 100644 --- a/Core/BookmarksExporter.swift +++ b/Core/BookmarksExporter.swift @@ -29,9 +29,11 @@ public enum BookmarksExporterError: Error { public struct BookmarksExporter { private(set) var coreDataStorage: CoreDataDatabase + private let favoritesDisplayMode: FavoritesDisplayMode - public init(coreDataStore: CoreDataDatabase) { + public init(coreDataStore: CoreDataDatabase, favoritesDisplayMode: FavoritesDisplayMode) { coreDataStorage = coreDataStore + self.favoritesDisplayMode = favoritesDisplayMode } public func exportBookmarksTo(url: URL) throws { @@ -64,7 +66,7 @@ public struct BookmarksExporter { content.append(Template.bookmark(level: level, title: entity.title!.escapedForHTML, url: entity.url!, - isFavorite: entity.isFavorite)) + isFavorite: entity.isFavorite(on: favoritesDisplayMode.displayedFolder))) } } return content diff --git a/Core/BookmarksImporter.swift b/Core/BookmarksImporter.swift index a98a7c6668..177dcf2955 100644 --- a/Core/BookmarksImporter.swift +++ b/Core/BookmarksImporter.swift @@ -42,8 +42,8 @@ final public class BookmarksImporter { private(set) var importedBookmarks: [BookmarkOrFolder] = [] private(set) var coreDataStorage: BookmarkCoreDataImporter - public init(coreDataStore: CoreDataDatabase) { - coreDataStorage = BookmarkCoreDataImporter(database: coreDataStore) + public init(coreDataStore: CoreDataDatabase, favoritesDisplayMode: FavoritesDisplayMode) { + coreDataStorage = BookmarkCoreDataImporter(database: coreDataStore, favoritesDisplayMode: favoritesDisplayMode) } func isDocumentInSafariFormat(_ document: Document) -> Bool { diff --git a/Core/BookmarksModelsErrorHandling.swift b/Core/BookmarksModelsErrorHandling.swift index aad7935bbe..5d25ef7c80 100644 --- a/Core/BookmarksModelsErrorHandling.swift +++ b/Core/BookmarksModelsErrorHandling.swift @@ -82,18 +82,22 @@ public extension BookmarkEditorViewModel { convenience init(editingEntityID: NSManagedObjectID, bookmarksDatabase: CoreDataDatabase, + favoritesDisplayMode: FavoritesDisplayMode, syncService: DDGSyncing?) { self.init(editingEntityID: editingEntityID, bookmarksDatabase: bookmarksDatabase, + favoritesDisplayMode: favoritesDisplayMode, errorEvents: BookmarksModelsErrorHandling(syncService: syncService)) } convenience init(creatingFolderWithParentID parentFolderID: NSManagedObjectID?, bookmarksDatabase: CoreDataDatabase, + favoritesDisplayMode: FavoritesDisplayMode, syncService: DDGSyncing?) { self.init(creatingFolderWithParentID: parentFolderID, bookmarksDatabase: bookmarksDatabase, + favoritesDisplayMode: favoritesDisplayMode, errorEvents: BookmarksModelsErrorHandling(syncService: syncService)) } } @@ -102,25 +106,25 @@ public extension BookmarkListViewModel { convenience init(bookmarksDatabase: CoreDataDatabase, parentID: NSManagedObjectID?, + favoritesDisplayMode: FavoritesDisplayMode, syncService: DDGSyncing?) { self.init(bookmarksDatabase: bookmarksDatabase, parentID: parentID, + favoritesDisplayMode: favoritesDisplayMode, errorEvents: BookmarksModelsErrorHandling(syncService: syncService)) } } public extension FavoritesListViewModel { - convenience init(bookmarksDatabase: CoreDataDatabase) { - self.init(bookmarksDatabase: bookmarksDatabase, - errorEvents: BookmarksModelsErrorHandling()) + convenience init(bookmarksDatabase: CoreDataDatabase, favoritesDisplayMode: FavoritesDisplayMode) { + self.init(bookmarksDatabase: bookmarksDatabase, errorEvents: BookmarksModelsErrorHandling(), favoritesDisplayMode: favoritesDisplayMode) } } public extension MenuBookmarksViewModel { convenience init(bookmarksDatabase: CoreDataDatabase, syncService: DDGSyncing?) { - self.init(bookmarksDatabase: bookmarksDatabase, - errorEvents: BookmarksModelsErrorHandling(syncService: syncService)) + self.init(bookmarksDatabase: bookmarksDatabase, errorEvents: BookmarksModelsErrorHandling(syncService: syncService)) } } diff --git a/Core/LegacyBookmarksStoreMigration.swift b/Core/LegacyBookmarksStoreMigration.swift index 248d96f12b..dfd6a4c371 100644 --- a/Core/LegacyBookmarksStoreMigration.swift +++ b/Core/LegacyBookmarksStoreMigration.swift @@ -39,7 +39,7 @@ public class LegacyBookmarksStoreMigration { } } else { // Initialize structure if needed - BookmarkUtils.prepareFoldersStructure(in: context) + BookmarkUtils.prepareLegacyFoldersStructure(in: context) if context.hasChanges { do { try context.save(onErrorFire: .bookmarksCouldNotPrepareDatabase) @@ -82,7 +82,8 @@ public class LegacyBookmarksStoreMigration { BookmarkUtils.prepareFoldersStructure(in: destination) guard let newRoot = BookmarkUtils.fetchRootFolder(destination), - let newFavoritesRoot = BookmarkUtils.fetchFavoritesFolder(destination) else { + let newFavoritesRoot = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.unified.rawValue, in: destination), + let newMobileFavoritesRoot = BookmarkUtils.fetchFavoritesFolder(withUUID: FavoritesFolderID.mobile.rawValue, in: destination) else { Pixel.fire(pixel: .bookmarksMigrationCouldNotPrepareDatabase) Thread.sleep(forTimeInterval: 2) fatalError("Could not write to Bookmarks DB") @@ -169,6 +170,8 @@ public class LegacyBookmarksStoreMigration { }() bookmark.addToFavorites(insertAt: 0, favoritesRoot: newFavoritesRoot) + bookmark.addToFavorites(insertAt: 0, + favoritesRoot: newMobileFavoritesRoot) } do { @@ -176,7 +179,7 @@ public class LegacyBookmarksStoreMigration { } catch { destination.reset() - BookmarkUtils.prepareFoldersStructure(in: destination) + BookmarkUtils.prepareLegacyFoldersStructure(in: destination) do { try destination.save(onErrorFire: .bookmarksMigrationCouldNotPrepareDatabaseOnFailedMigration) } catch { diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index e3b1b8dc82..a2005a22f2 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -473,10 +473,15 @@ extension Pixel { case bookmarksMigrationCouldNotPrepareDatabaseOnFailedMigration case bookmarksMigrationCouldNotValidateDatabase case bookmarksMigrationCouldNotRemoveOldStore + case bookmarksMigrationCouldNotPrepareMultipleFavoriteFolders case syncFailedToMigrate case syncFailedToLoadAccount case syncFailedToSetupEngine + case syncBookmarksCountLimitExceededDaily + case syncCredentialsCountLimitExceededDaily + case syncBookmarksRequestSizeLimitExceededDaily + case syncCredentialsRequestSizeLimitExceededDaily case syncSentUnauthenticatedRequest case syncMetadataCouldNotLoadDatabase @@ -489,6 +494,7 @@ extension Pixel { case bookmarksCleanupFailed case bookmarksCleanupAttemptedWhileSyncWasEnabled + case favoritesCleanupFailed case credentialsDatabaseCleanupFailed case credentialsCleanupAttemptedWhileSyncWasEnabled @@ -951,10 +957,15 @@ extension Pixel.Event { return "m_d_bookmarks_migration_could_not_prepare_database_on_failed_migration" case .bookmarksMigrationCouldNotValidateDatabase: return "m_d_bookmarks_migration_could_not_validate_database" case .bookmarksMigrationCouldNotRemoveOldStore: return "m_d_bookmarks_migration_could_not_remove_old_store" + case .bookmarksMigrationCouldNotPrepareMultipleFavoriteFolders: return "m_d_bookmarks_migration_could_not_prepare_multiple_favorite_folders" case .syncFailedToMigrate: return "m_d_sync_failed_to_migrate" case .syncFailedToLoadAccount: return "m_d_sync_failed_to_load_account" case .syncFailedToSetupEngine: return "m_d_sync_failed_to_setup_engine" + case .syncBookmarksCountLimitExceededDaily: return "m_d_sync_bookmarks_count_limit_exceeded_daily" + case .syncCredentialsCountLimitExceededDaily: return "m_d_sync_credentials_count_limit_exceeded_daily" + case .syncBookmarksRequestSizeLimitExceededDaily: return "m_d_sync_bookmarks_request_size_limit_exceeded_daily" + case .syncCredentialsRequestSizeLimitExceededDaily: return "m_d_sync_credentials_request_size_limit_exceeded_daily" case .syncSentUnauthenticatedRequest: return "m_d_sync_sent_unauthenticated_request" case .syncMetadataCouldNotLoadDatabase: return "m_d_sync_metadata_could_not_load_database" @@ -968,6 +979,7 @@ 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 .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 5578e2422c..5752c090dc 100644 --- a/Core/SyncBookmarksAdapter.swift +++ b/Core/SyncBookmarksAdapter.swift @@ -26,14 +26,38 @@ import Persistence import SyncDataProviders import WidgetKit +public protocol FavoritesDisplayModeStoring: AnyObject { + var favoritesDisplayMode: FavoritesDisplayMode { get set } +} + public final class SyncBookmarksAdapter { 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 { + assert(provider == nil, "Setting this value has no effect after provider has been instantiated") + } + } - public init(database: CoreDataDatabase) { + @UserDefaultsWrapper(key: .syncBookmarksPaused, defaultValue: false) + static public var isSyncBookmarksPaused: Bool { + didSet { + NotificationCenter.default.post(name: syncBookmarksPausedStateChanged, object: nil) + } + } + + @UserDefaultsWrapper(key: .syncBookmarksPausedErrorDisplayed, defaultValue: false) + static private var didShowBookmarksSyncPausedError: Bool + + public init(database: CoreDataDatabase, favoritesDisplayModeStorage: FavoritesDisplayModeStoring) { + self.database = database + self.favoritesDisplayModeStorage = favoritesDisplayModeStorage syncDidCompletePublisher = syncDidCompleteSubject.eraseToAnyPublisher() databaseCleaner = BookmarkDatabaseCleaner( bookmarkDatabase: database, @@ -49,6 +73,7 @@ public final class SyncBookmarksAdapter { databaseCleaner.cleanUpDatabaseNow() if shouldEnable { databaseCleaner.scheduleRegularCleaning() + handleFavoritesAfterDisablingSync() } else { databaseCleaner.cancelCleaningSchedule() } @@ -64,14 +89,33 @@ public final class SyncBookmarksAdapter { metadataStore: metadataStore, syncDidUpdateData: { [syncDidCompleteSubject] in syncDidCompleteSubject.send() + Self.isSyncBookmarksPaused = false + Self.didShowBookmarksSyncPausedError = false } ) + if shouldResetBookmarksSyncTimestamp { + provider.lastSyncTimestamp = nil + } syncErrorCancellable = provider.syncErrorPublisher .sink { error in switch error { case let syncError as SyncError: Pixel.fire(pixel: .syncBookmarksFailed, error: syncError) + switch syncError { + case .unexpectedStatusCode(409): + // If bookmarks count limit has been exceeded + Self.isSyncBookmarksPaused = true + DailyPixel.fire(pixel: .syncBookmarksCountLimitExceededDaily) + Self.notifyBookmarksSyncLimitReached() + case .unexpectedStatusCode(413): + // If bookmarks request size limit has been exceeded + Self.isSyncBookmarksPaused = true + DailyPixel.fire(pixel: .syncBookmarksRequestSizeLimitExceededDaily) + Self.notifyBookmarksSyncLimitReached() + default: + break + } default: let nsError = error as NSError if nsError.domain != NSURLErrorDomain { @@ -86,6 +130,36 @@ public final class SyncBookmarksAdapter { self.provider = provider } + static private func notifyBookmarksSyncLimitReached() { + if !Self.didShowBookmarksSyncPausedError { + NotificationCenter.default.post(name: Self.bookmarksSyncLimitReached, object: nil) + Self.didShowBookmarksSyncPausedError = true + } + } + + private func handleFavoritesAfterDisablingSync() { + let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + do { + if favoritesDisplayModeStorage.favoritesDisplayMode.isDisplayUnified { + BookmarkUtils.copyFavorites(from: .unified, to: .mobile, clearingNonNativeFavoritesFolder: .desktop, in: context) + favoritesDisplayModeStorage.favoritesDisplayMode = .displayNative(.mobile) + } else { + BookmarkUtils.copyFavorites(from: .mobile, to: .unified, clearingNonNativeFavoritesFolder: .desktop, in: context) + } + try context.save() + } catch { + let nsError = error as NSError + let processedErrors = CoreDataErrorsParser.parse(error: nsError) + let params = processedErrors.errorPixelParameters + Pixel.fire(pixel: .favoritesCleanupFailed, error: error, withAdditionalParameters: params) + } + } + } + private var syncDidCompleteSubject = PassthroughSubject() private var syncErrorCancellable: AnyCancellable? + private let database: CoreDataDatabase + private let favoritesDisplayModeStorage: FavoritesDisplayModeStoring } diff --git a/Core/SyncCredentialsAdapter.swift b/Core/SyncCredentialsAdapter.swift index e4b96e01ef..e5c2101954 100644 --- a/Core/SyncCredentialsAdapter.swift +++ b/Core/SyncCredentialsAdapter.swift @@ -30,6 +30,17 @@ public final class SyncCredentialsAdapter { public private(set) var provider: CredentialsProvider? public let databaseCleaner: CredentialsDatabaseCleaner public let syncDidCompletePublisher: AnyPublisher + public static let syncCredentialsPausedStateChanged = SyncBookmarksAdapter.syncBookmarksPausedStateChanged + public static let credentialsSyncLimitReached = Notification.Name("com.duckduckgo.app.SyncCredentialsLimitReached") + + @UserDefaultsWrapper(key: .syncCredentialsPaused, defaultValue: false) + static public var isSyncCredentialsPaused: Bool { + didSet { + NotificationCenter.default.post(name: syncCredentialsPausedStateChanged, object: nil) + } + } + @UserDefaultsWrapper(key: .syncCredentialsPausedErrorDisplayed, defaultValue: false) + static private var didShowCredentialsSyncPausedError: Bool public init(secureVaultFactory: AutofillVaultFactory = AutofillSecureVaultFactory, secureVaultErrorReporter: SecureVaultErrorReporting) { syncDidCompletePublisher = syncDidCompleteSubject.eraseToAnyPublisher() @@ -63,6 +74,8 @@ public final class SyncCredentialsAdapter { metadataStore: metadataStore, syncDidUpdateData: { [weak self] in self?.syncDidCompleteSubject.send() + Self.isSyncCredentialsPaused = false + Self.didShowCredentialsSyncPausedError = false } ) @@ -71,6 +84,21 @@ public final class SyncCredentialsAdapter { switch error { case let syncError as SyncError: Pixel.fire(pixel: .syncCredentialsFailed, error: syncError) + + switch syncError { + case .unexpectedStatusCode(409): + // If credentials count limit has been exceeded + Self.isSyncCredentialsPaused = true + DailyPixel.fire(pixel: .syncCredentialsCountLimitExceededDaily) + Self.notifyCredentialsSyncLimitReached() + case .unexpectedStatusCode(413): + // If credentials request size limit has been exceeded + Self.isSyncCredentialsPaused = true + DailyPixel.fire(pixel: .syncCredentialsRequestSizeLimitExceededDaily) + Self.notifyCredentialsSyncLimitReached() + default: + break + } default: let nsError = error as NSError if nsError.domain != NSURLErrorDomain { @@ -91,6 +119,13 @@ public final class SyncCredentialsAdapter { } } + static private func notifyCredentialsSyncLimitReached() { + if !Self.didShowCredentialsSyncPausedError { + NotificationCenter.default.post(name: Self.credentialsSyncLimitReached, object: nil) + Self.didShowCredentialsSyncPausedError = true + } + } + private var syncDidCompleteSubject = PassthroughSubject() private var syncErrorCancellable: AnyCancellable? private let secureVaultErrorReporter: SecureVaultErrorReporting diff --git a/Core/SyncDataProviders.swift b/Core/SyncDataProviders.swift index e705889a54..a70b4acd4f 100644 --- a/Core/SyncDataProviders.swift +++ b/Core/SyncDataProviders.swift @@ -17,6 +17,7 @@ // limitations under the License. // +import Bookmarks import BrowserServicesKit import Combine import Common @@ -84,14 +85,16 @@ public class SyncDataProviders: DataProvidersSource { public init( bookmarksDatabase: CoreDataDatabase, secureVaultFactory: AutofillVaultFactory = AutofillSecureVaultFactory, - secureVaultErrorReporter: SecureVaultErrorReporting + secureVaultErrorReporter: SecureVaultErrorReporting, + settingHandlers: [SettingSyncHandler], + favoritesDisplayModeStorage: FavoritesDisplayModeStoring ) { self.bookmarksDatabase = bookmarksDatabase self.secureVaultFactory = secureVaultFactory self.secureVaultErrorReporter = secureVaultErrorReporter - bookmarksAdapter = SyncBookmarksAdapter(database: bookmarksDatabase) + bookmarksAdapter = SyncBookmarksAdapter(database: bookmarksDatabase, favoritesDisplayModeStorage: favoritesDisplayModeStorage) credentialsAdapter = SyncCredentialsAdapter(secureVaultFactory: secureVaultFactory, secureVaultErrorReporter: secureVaultErrorReporter) - settingsAdapter = SyncSettingsAdapter() + settingsAdapter = SyncSettingsAdapter(settingHandlers: settingHandlers) } private func initializeMetadataDatabaseIfNeeded() { diff --git a/Core/SyncSettingsAdapter.swift b/Core/SyncSettingsAdapter.swift index b6b5fd80ef..9bfe132bb4 100644 --- a/Core/SyncSettingsAdapter.swift +++ b/Core/SyncSettingsAdapter.swift @@ -30,7 +30,8 @@ public final class SyncSettingsAdapter { public private(set) var emailManager: EmailManager? public let syncDidCompletePublisher: AnyPublisher - public init() { + public init(settingHandlers: [SettingSyncHandler]) { + self.settingHandlers = settingHandlers syncDidCompletePublisher = syncDidCompleteSubject.eraseToAnyPublisher() } @@ -41,12 +42,14 @@ public final class SyncSettingsAdapter { guard provider == nil else { return } + let emailManager = EmailManager() + let emailProtectionSyncHandler = EmailProtectionSyncHandler(emailManager: emailManager) let provider = SettingsProvider( metadataDatabase: metadataDatabase, metadataStore: metadataStore, - emailManager: emailManager, + settingsHandlers: settingHandlers + [emailProtectionSyncHandler], syncDidUpdateData: { [weak self] in self?.syncDidCompleteSubject.send() } @@ -77,6 +80,7 @@ public final class SyncSettingsAdapter { self.emailManager = emailManager } + private let settingHandlers: [SettingSyncHandler] private var syncDidCompleteSubject = PassthroughSubject() private var syncErrorCancellable: AnyCancellable? } diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 7949c231c3..78a4507ba0 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -69,7 +69,7 @@ public struct UserDefaultsWrapper { case downloadedTrackerDataSetCount = "com.duckduckgo.app.downloadedTrackerDataSetCount" case downloadedPrivacyConfigurationCount = "com.duckduckgo.app.downloadedPrivacyConfigurationCount" case textSize = "com.duckduckgo.ios.textSize" - + case emailWaitlistShouldReceiveNotifications = "com.duckduckgo.ios.showWaitlistNotification" case unseenDownloadsAvailable = "com.duckduckgo.app.unseenDownloadsAvailable" @@ -97,6 +97,10 @@ public struct UserDefaultsWrapper { case defaultBrowserUsageLastSeen = "com.duckduckgo.ios.default-browser-usage-last-seen" case syncEnvironment = "com.duckduckgo.ios.sync-environment" + case syncBookmarksPaused = "com.duckduckgo.ios.sync-bookmarksPaused" + case syncCredentialsPaused = "com.duckduckgo.ios.sync-credentialsPaused" + case syncBookmarksPausedErrorDisplayed = "com.duckduckgo.ios.sync-bookmarksPausedErrorDisplayed" + case syncCredentialsPausedErrorDisplayed = "com.duckduckgo.ios.sync-credentialsPausedErrorDisplayed" case networkProtectionDebugOptionAlwaysOnDisabled = "com.duckduckgo.network-protection.always-on.disabled" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9930057c0b..aace48e673 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -246,8 +246,12 @@ 31DD208427395A5A008FB313 /* VoiceSearchHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DD208327395A5A008FB313 /* VoiceSearchHelper.swift */; }; 31E69A63280F4CB600478327 /* DuckUI in Frameworks */ = {isa = PBXBuildFile; productRef = 31E69A62280F4CB600478327 /* DuckUI */; }; 31EF52E1281B3BDC0034796E /* AutofillLoginListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31EF52E0281B3BDC0034796E /* AutofillLoginListItemViewModel.swift */; }; + 373608902ABB1E6C00629E7F /* FavoritesDisplayModeStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3736088F2ABB1E6C00629E7F /* FavoritesDisplayModeStorage.swift */; }; + 373608922ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373608912ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift */; }; + 373608932ABB432600629E7F /* FavoritesDisplayMode+UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373608912ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift */; }; 37445F972A155F7C0029F789 /* SyncDataProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37445F962A155F7C0029F789 /* SyncDataProviders.swift */; }; 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 */; }; 37CBCA9E2A8A659C0050218F /* SyncSettingsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CBCA9D2A8A659C0050218F /* SyncSettingsAdapter.swift */; }; 37CEFCAC2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEFCAB2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift */; }; @@ -1259,7 +1263,10 @@ 31CC224828369B38001654A4 /* AutofillLoginSettingsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginSettingsListViewController.swift; sourceTree = ""; }; 31DD208327395A5A008FB313 /* VoiceSearchHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSearchHelper.swift; sourceTree = ""; }; 31EF52E0281B3BDC0034796E /* AutofillLoginListItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginListItemViewModel.swift; sourceTree = ""; }; + 3736088F2ABB1E6C00629E7F /* FavoritesDisplayModeStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesDisplayModeStorage.swift; sourceTree = ""; }; + 373608912ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoritesDisplayMode+UserDefaults.swift"; sourceTree = ""; }; 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 = ""; }; 37CBCA9D2A8A659C0050218F /* SyncSettingsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettingsAdapter.swift; sourceTree = ""; }; 37CEFCAB2A673B90001EF741 /* CredentialsCleanupErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialsCleanupErrorHandling.swift; sourceTree = ""; }; @@ -3329,6 +3336,14 @@ path = LocalPackages; sourceTree = ""; }; + 377D80202AB4853A002AF251 /* SettingSyncHandlers */ = { + isa = PBXGroup; + children = ( + 377D80212AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift */, + ); + name = SettingSyncHandlers; + sourceTree = ""; + }; 37DF000829F9C3F0002B7D3E /* Sync */ = { isa = PBXGroup; children = ( @@ -3985,6 +4000,7 @@ 85F98F8C296F0ED100742F4A /* Sync */ = { isa = PBXGroup; children = ( + 377D80202AB4853A002AF251 /* SettingSyncHandlers */, 85F98F97296F4CB100742F4A /* SyncAssets.xcassets */, 85F0E97229952D7A003D5181 /* DuckDuckGo Recovery Document.pdf */, 85DD44232976C7A8005CC388 /* Controllers */, @@ -4844,6 +4860,7 @@ F1D796EF1E7B07610019D451 /* BookmarksViewControllerCells.swift */, 85E58C2B28FDA94F006A801A /* FavoritesViewController.swift */, F1D796EB1E7AB8930019D451 /* SaveBookmarkActivity.swift */, + 3736088F2ABB1E6C00629E7F /* FavoritesDisplayModeStorage.swift */, ); name = Bookmarks; sourceTree = ""; @@ -5038,6 +5055,7 @@ 98B31291218CCB8C00E54DE1 /* AppDependencyProvider.swift */, 85BA58591F3506AE00C6E8CA /* AppSettings.swift */, 85BA58541F34F49E00C6E8CA /* AppUserDefaults.swift */, + 373608912ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift */, 850250B220D803F4002199C7 /* AtbAndVariantCleanup.swift */, 983EABB7236198F6003948D1 /* DatabaseMigration.swift */, 853C5F6021C277C7001F7A05 /* global.swift */, @@ -6244,6 +6262,7 @@ 980891A52237D4F500313A70 /* FeedbackNavigator.swift in Sources */, C1B7B52328941F2A0098FD6A /* RemoteMessagingStore.swift in Sources */, 1E8AD1C927BFAD1500ABA377 /* DirectoryMonitor.swift in Sources */, + 377D80222AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */, 1E8AD1D127C000AB00ABA377 /* OngoingDownloadRow.swift in Sources */, 85058366219AE9EA00ED4EDB /* HomePageConfiguration.swift in Sources */, EE0153E12A6EABE0002A8B26 /* NetworkProtectionConvenienceInitialisers.swift in Sources */, @@ -6298,6 +6317,7 @@ F46FEC5727987A5F0061D9DF /* KeychainItemsDebugViewController.swift in Sources */, 02341FA62A4379CC008A1531 /* OnboardingStepViewModel.swift in Sources */, 850365F323DE087800D0F787 /* UIImageViewExtension.swift in Sources */, + 373608922ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift in Sources */, C160544129D6044D00B715A1 /* AutofillInterfaceUsernameTruncator.swift in Sources */, 02A54A9A2A094A17000C8FED /* AppTPHomeView.swift in Sources */, 31C70B5528045E3500FB6AD1 /* SecureVaultErrorReporter.swift in Sources */, @@ -6400,6 +6420,7 @@ 85010502292FB1000033978F /* FireproofFaviconUpdater.swift in Sources */, F1C4A70E1E57725800A6CA1B /* OmniBar.swift in Sources */, 981CA7EA2617797500E119D5 /* MainViewController+AddFavoriteFlow.swift in Sources */, + 373608902ABB1E6C00629E7F /* FavoritesDisplayModeStorage.swift in Sources */, 9872D205247DCAC100CEF398 /* TabPreviewsSource.swift in Sources */, F130D73A1E5776C500C45811 /* OmniBarDelegate.swift in Sources */, 85DFEDEF24C7EA3B00973FE7 /* SmallOmniBarState.swift in Sources */, @@ -6642,6 +6663,7 @@ buildActionMask = 2147483647; files = ( 853273AE24FEF49600E3C778 /* ColorExtension.swift in Sources */, + 373608932ABB432600629E7F /* FavoritesDisplayMode+UserDefaults.swift in Sources */, 853273B324FF114700E3C778 /* DeepLinks.swift in Sources */, 853273B424FFB36100E3C778 /* UIColorExtension.swift in Sources */, 853273AB24FEF27500E3C778 /* WidgetViews.swift in Sources */, @@ -9058,7 +9080,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 82.2.2; + version = 83.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 39d7ce6624..d9cc92bff7 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": "989e306052bc284a1202fad1087f8b88e515a966", - "version": "82.2.2" + "revision": "f7e20cd37bbc0d25ae3c3f25ef52d319366613e7", + "version": "83.0.0" } }, { diff --git a/DuckDuckGo/AddOrEditBookmarkViewController.swift b/DuckDuckGo/AddOrEditBookmarkViewController.swift index 1b31051f25..188764d2ba 100644 --- a/DuckDuckGo/AddOrEditBookmarkViewController.swift +++ b/DuckDuckGo/AddOrEditBookmarkViewController.swift @@ -41,19 +41,23 @@ class AddOrEditBookmarkViewController: UIViewController { private let viewModel: BookmarkEditorViewModel private let bookmarksDatabase: CoreDataDatabase private let syncService: DDGSyncing + private let appSettings: AppSettings private var viewModelCancellable: AnyCancellable? init?(coder: NSCoder, editingEntityID: NSManagedObjectID, bookmarksDatabase: CoreDataDatabase, - syncService: DDGSyncing) { + syncService: DDGSyncing, + appSettings: AppSettings) { self.bookmarksDatabase = bookmarksDatabase self.viewModel = BookmarkEditorViewModel(editingEntityID: editingEntityID, bookmarksDatabase: bookmarksDatabase, + favoritesDisplayMode: appSettings.favoritesDisplayMode, syncService: syncService) self.syncService = syncService + self.appSettings = appSettings super.init(coder: coder) } @@ -61,13 +65,16 @@ class AddOrEditBookmarkViewController: UIViewController { init?(coder: NSCoder, parentFolderID: NSManagedObjectID?, bookmarksDatabase: CoreDataDatabase, - syncService: DDGSyncing) { + syncService: DDGSyncing, + appSettings: AppSettings) { self.bookmarksDatabase = bookmarksDatabase self.viewModel = BookmarkEditorViewModel(creatingFolderWithParentID: parentFolderID, bookmarksDatabase: bookmarksDatabase, + favoritesDisplayMode: appSettings.favoritesDisplayMode, syncService: syncService) self.syncService = syncService + self.appSettings = appSettings super.init(coder: coder) } @@ -138,7 +145,8 @@ class AddOrEditBookmarkViewController: UIViewController { coder: coder, parentFolderID: viewModel.bookmark.parent?.objectID, bookmarksDatabase: bookmarksDatabase, - syncService: syncService + syncService: syncService, + appSettings: appSettings ) else { fatalError("Failed to create controller") } diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index d49f873e3d..9b3a32e512 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -161,7 +161,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DatabaseMigration.migrate(to: context) } - bookmarksDatabase.loadStore { context, error in + var shouldResetBookmarksSyncTimestamp = false + + bookmarksDatabase.loadStore { [weak self] context, error in guard let context = context else { if let error = error { Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase, @@ -184,6 +186,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate { to: context) legacyStorage?.removeStore() + do { + BookmarkUtils.migrateToFormFactorSpecificFavorites(byCopyingExistingTo: .mobile, in: context) + if context.hasChanges { + try context.save(onErrorFire: .bookmarksMigrationCouldNotPrepareMultipleFavoriteFolders) + if let syncDataProviders = self?.syncDataProviders { + syncDataProviders.bookmarksAdapter.shouldResetBookmarksSyncTimestamp = true + } else { + shouldResetBookmarksSyncTimestamp = true + } + } + } catch { + Thread.sleep(forTimeInterval: 1) + fatalError("Could not prepare Bookmarks DB structure") + } + WidgetCenter.shared.reloadAllTimelines() } @@ -235,7 +252,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ).wrappedValue ) ?? defaultEnvironment - syncDataProviders = SyncDataProviders(bookmarksDatabase: bookmarksDatabase, secureVaultErrorReporter: SecureVaultErrorReporter.shared) + syncDataProviders = SyncDataProviders( + bookmarksDatabase: bookmarksDatabase, + secureVaultErrorReporter: SecureVaultErrorReporter.shared, + settingHandlers: [FavoritesDisplayModeSyncHandler()], + favoritesDisplayModeStorage: FavoritesDisplayModeStorage() + ) + syncDataProviders.bookmarksAdapter.shouldResetBookmarksSyncTimestamp = shouldResetBookmarksSyncTimestamp + let syncService = DDGSync(dataProvidersSource: syncDataProviders, errorEvents: SyncErrorHandler(), log: .syncLog, environment: environment) syncService.initializeIfNeeded() self.syncService = syncService @@ -275,7 +299,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Having both in `didBecomeActive` can sometimes cause the exception when running on a physical device, so registration happens here. AppConfigurationFetch.registerBackgroundRefreshTaskHandler() WindowsBrowserWaitlist.shared.registerBackgroundRefreshTaskHandler() - RemoteMessaging.registerBackgroundRefreshTaskHandler(bookmarksDatabase: bookmarksDatabase) + RemoteMessaging.registerBackgroundRefreshTaskHandler( + bookmarksDatabase: bookmarksDatabase, + favoritesDisplayMode: AppDependencyProvider.shared.appSettings.favoritesDisplayMode + ) UNUserNotificationCenter.current().delegate = self @@ -479,7 +506,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func refreshRemoteMessages() { Task { - try? await RemoteMessaging.fetchAndProcess(bookmarksDatabase: self.bookmarksDatabase) + try? await RemoteMessaging.fetchAndProcess( + bookmarksDatabase: self.bookmarksDatabase, + favoritesDisplayMode: AppDependencyProvider.shared.appSettings.favoritesDisplayMode + ) } } diff --git a/DuckDuckGo/AppSettings.swift b/DuckDuckGo/AppSettings.swift index 45b1354046..0c3f6dd748 100644 --- a/DuckDuckGo/AppSettings.swift +++ b/DuckDuckGo/AppSettings.swift @@ -17,6 +17,8 @@ // limitations under the License. // +import Bookmarks + protocol AppSettings: AnyObject { var autocomplete: Bool { get set } var currentThemeName: ThemeName { get set } @@ -34,6 +36,8 @@ protocol AppSettings: AnyObject { var currentAddressBarPosition: AddressBarPosition { get set } var textSize: Int { get set } + + var favoritesDisplayMode: FavoritesDisplayMode { get set } var autofillCredentialsEnabled: Bool { get set } var autofillCredentialsSavePromptShowAtLeastOnce: Bool { get set } @@ -47,4 +51,7 @@ protocol AppSettings: AnyObject { var autoconsentPromptSeen: Bool { get set } var autoconsentEnabled: Bool { get set } + + var isSyncBookmarksPaused: Bool { get } + var isSyncCredentialsPaused: Bool { get } } diff --git a/DuckDuckGo/AppUserDefaults.swift b/DuckDuckGo/AppUserDefaults.swift index efbfe51d59..f06c8d7180 100644 --- a/DuckDuckGo/AppUserDefaults.swift +++ b/DuckDuckGo/AppUserDefaults.swift @@ -18,6 +18,7 @@ // import Foundation +import Bookmarks import Core import WidgetKit @@ -27,6 +28,9 @@ public class AppUserDefaults: AppSettings { public static let doNotSellStatusChange = Notification.Name("com.duckduckgo.app.DoNotSellStatusChange") public static let currentFireButtonAnimationChange = Notification.Name("com.duckduckgo.app.CurrentFireButtonAnimationChange") public static let textSizeChange = Notification.Name("com.duckduckgo.app.TextSizeChange") + public static let favoritesDisplayModeChange = Notification.Name("com.duckduckgo.app.FavoritesDisplayModeChange") + public static let syncPausedStateChanged = SyncBookmarksAdapter.syncBookmarksPausedStateChanged + public static let syncCredentialsPausedStateChanged = SyncCredentialsAdapter.syncCredentialsPausedStateChanged public static let autofillEnabledChange = Notification.Name("com.duckduckgo.app.AutofillEnabledChange") public static let didVerifyInternalUser = Notification.Name("com.duckduckgo.app.DidVerifyInternalUser") public static let inspectableWebViewsToggled = Notification.Name("com.duckduckgo.app.DidToggleInspectableWebViews") @@ -35,7 +39,7 @@ public class AppUserDefaults: AppSettings { private let groupName: String - private struct Keys { + struct Keys { static let autocompleteKey = "com.duckduckgo.app.autocompleteDisabledKey" static let currentThemeNameKey = "com.duckduckgo.app.currentThemeNameKey" @@ -62,6 +66,8 @@ public class AppUserDefaults: AppSettings { static let autofillCredentialsEnabled = "com.duckduckgo.ios.autofillCredentialsEnabled" static let autofillIsNewInstallForOnByDefault = "com.duckduckgo.ios.autofillIsNewInstallForOnByDefault" + + static let favoritesDisplayMode = "com.duckduckgo.ios.favoritesDisplayMode" } private struct DebugKeys { @@ -72,6 +78,10 @@ public class AppUserDefaults: AppSettings { return UserDefaults(suiteName: groupName) } + private var bookmarksUserDefaults: UserDefaults? { + UserDefaults(suiteName: "group.com.duckduckgo.bookmarks") + } + lazy var featureFlagger = AppDependencyProvider.shared.featureFlagger init(groupName: String = "group.com.duckduckgo.app") { @@ -195,6 +205,25 @@ public class AppUserDefaults: AppSettings { @UserDefaultsWrapper(key: .textSize, defaultValue: 100) var textSize: Int + @UserDefaultsWrapper(key: .syncBookmarksPaused, defaultValue: false) + var isSyncBookmarksPaused: Bool + + @UserDefaultsWrapper(key: .syncCredentialsPaused, defaultValue: false) + var isSyncCredentialsPaused: Bool + + public var favoritesDisplayMode: FavoritesDisplayMode { + get { + guard let string = userDefaults?.string(forKey: Keys.favoritesDisplayMode), let favoritesDisplayMode = FavoritesDisplayMode(string) else { + return .default + } + return favoritesDisplayMode + } + set { + userDefaults?.setValue(newValue.description, forKey: Keys.favoritesDisplayMode) + bookmarksUserDefaults?.setValue(newValue.description, forKey: Keys.favoritesDisplayMode) + } + } + private func setAutofillCredentialsEnabledAutomaticallyIfNecessary() { if autofillCredentialsHasBeenEnabledAutomaticallyIfNecessary { return diff --git a/DuckDuckGo/Base.lproj/Settings.storyboard b/DuckDuckGo/Base.lproj/Settings.storyboard index ac8a7668d7..679524ecd2 100644 --- a/DuckDuckGo/Base.lproj/Settings.storyboard +++ b/DuckDuckGo/Base.lproj/Settings.storyboard @@ -104,7 +104,7 @@ -