diff --git a/.github/workflows/release_create_tag.yml b/.github/workflows/release_create_tag.yml index 8a58cddea784..22028791c6ce 100644 --- a/.github/workflows/release_create_tag.yml +++ b/.github/workflows/release_create_tag.yml @@ -27,6 +27,7 @@ jobs: with: submodules: recursive token: ${{ secrets.GT_DAXMOBILE }} + fetch-depth: 0 - name: Set up ruby env uses: ruby/setup-ruby@v1 @@ -34,9 +35,15 @@ jobs: ruby-version: 2.7.2 bundler-cache: true + - name: Set up git config + run: | + git remote set-url origin https://${{ secrets.GT_DAXMOBILE }}@github.com/duckduckgo/Android.git/ + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + - name: Use fastlane lane to create and push tagged release id: create_git_tag - run: | + run: | bundle exec fastlane android tag_and_push_release_version app_version:${{ github.event.inputs.app-version }} - name: Create Asana task when workflow failed diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 56fc4d41e6ef..b2119558732e 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -8,6 +8,7 @@ on: env: ASANA_PAT: ${{ secrets.GH_ASANA_SECRET }} GOOGLE_APPLICATION_CREDENTIALS: '#{ENV["HOME"]}/jenkins_static/com.duckduckgo.mobile.android/ddg-upload-firebase.json' + GH_TOKEN: ${{ secrets.GT_DAXMOBILE }} jobs: create-tag: @@ -20,11 +21,7 @@ jobs: with: submodules: recursive token: ${{ secrets.GT_DAXMOBILE }} - - - name: Set Git permissions - uses: oleksiyrudenko/gha-git-credentials@v2-latest - with: - token: '${{ secrets.GT_DAXMOBILE }}' + fetch-depth: 0 - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -38,11 +35,16 @@ jobs: ruby-version: 2.7.2 bundler-cache: true + - name: Set up git config + run: | + git remote set-url origin https://${{ secrets.GT_DAXMOBILE }}@github.com/duckduckgo/Android.git/ + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + - name: Get latest tag id: get_latest_tag run: | - git fetch --tags - output=$(git describe --tags `git rev-list --tags --max-count=1`) + output=$(git for-each-ref --sort=taggerdate --format='%(refname:short)' refs/tags | tail -n 1) echo "Latest tag: $output" echo "latest_tag=$output" >> $GITHUB_OUTPUT @@ -101,6 +103,7 @@ jobs: - name: Tag Nightly release id: tag_nightly_release run: | + git checkout develop git tag -a ${{ steps.generate_version_name.outputs.version }} -m "Create tag ${{ steps.generate_version_name.outputs.version }} for nightly release." git push origin ${{ steps.generate_version_name.outputs.version }} diff --git a/.github/workflows/release_upload_play_store.yml b/.github/workflows/release_upload_play_store.yml new file mode 100644 index 000000000000..754904402463 --- /dev/null +++ b/.github/workflows/release_upload_play_store.yml @@ -0,0 +1,99 @@ +name: Release - Production Release to Play Store and Github + +on: + workflow_dispatch: + +env: + ASANA_PAT: ${{ secrets.GH_ASANA_SECRET }} + GH_TOKEN: ${{ secrets.GT_DAXMOBILE }} + GOOGLE_APPLICATION_CREDENTIALS: '#{ENV["HOME"]}/jenkins_static/com.duckduckgo.mobile.android/ddg-upload-firebase.json' + +jobs: + release-production: + runs-on: ubuntu-latest + name: Publish Bundle to Play Store Internal track + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.GT_DAXMOBILE }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + + - name: Set up ruby env + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7.2 + bundler-cache: true + + - name: Set up git config + run: | + git remote set-url origin https://${{ secrets.GT_DAXMOBILE }}@github.com/duckduckgo/Android.git/ + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + - name: Decode upload keys + uses: davidSchuppa/base64Secret-toFile-action@199e78f212c854d2284fada7f3cd3aba3e37d208 + with: + secret: ${{ secrets.UPLOAD_RELEASE_PROPERTIES }} + fileName: ddg_android_build_upload.properties + destination-path: $HOME/jenkins_static/com.duckduckgo.mobile.android/ + + - name: Decode key file + uses: davidSchuppa/base64Secret-toFile-action@199e78f212c854d2284fada7f3cd3aba3e37d208 + with: + secret: ${{ secrets.UPLOAD_RELEASE_KEY }} + fileName: ddg-upload-keystore.jks + destination-path: $HOME/jenkins_static/com.duckduckgo.mobile.android/ + + - name: Decode Play Store credentials file + uses: davidSchuppa/base64Secret-toFile-action@199e78f212c854d2284fada7f3cd3aba3e37d208 + with: + secret: ${{ secrets.UPLOAD_PLAY_CREDENTIALS }} + fileName: api.json + destination-path: $HOME/jenkins_static/com.duckduckgo.mobile.android/ + + - name: Assemble the bundle + run: ./gradleW bundleRelease -PuseUploadSigning + + - name: Capture App Bundle Path + id: capture_output + run: | + output=$(find app/build/outputs/bundle/playRelease -name "*.aab") + echo "bundle_path=$output" >> $GITHUB_OUTPUT + + - name: Upload bundle to Play Store + id: create_app_bundle + run: | + bundle exec fastlane deploy_playstore + + - name: Upload Universal APK to Github + id: create_app_bundle + run: | + bundle exec fastlane deploy_github + + - name: Upload APK as artifact + uses: actions/upload-artifact@v4 + with: + name: duckduckgo-${{ steps.generate_version_name.outputs.version }}.apk + path: duckduckgo.apk + + - name: Create Asana task when workflow failed + if: ${{ failure() }} + uses: duckduckgo/native-github-asana-sync@v1.1 + with: + asana-pat: ${{ secrets.GH_ASANA_SECRET }} + asana-project: ${{ vars.GH_ANDROID_APP_PROJECT_ID }} + asana-section: ${{ vars.GH_ANDROID_APP_INCOMING_SECTION_ID }} + asana-task-name: GH Workflow Failure - Production Release + asana-task-description: The Production Release to Play Store and Github workflow has failed. See https://github.com/duckduckgo/Android/actions/runs/${{ github.run_id }} + action: 'create-asana-task' \ No newline at end of file diff --git a/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesRemoteFeature.kt b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesRemoteFeature.kt index 4ca05267ffb3..bd5205973c98 100644 --- a/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesRemoteFeature.kt +++ b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesRemoteFeature.kt @@ -55,6 +55,7 @@ annotation class ContributesRemoteFeature( val featureName: String, /** The class that implements the [FeatureSettings.Store] interface */ + @Deprecated("Not needed anymore. Settings is now supported in top-level and sub-features and Toggle#getSettings returns it") val settingsStore: KClass<*> = Unit::class, /** The class that implements the [FeatureExceptions.Store] interface */ diff --git a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt index 24816de89729..063c7edfcc13 100644 --- a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt +++ b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt @@ -498,6 +498,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { minSupportedVersion = feature.minSupportedVersion, targets = emptyList(), cohorts = emptyList(), + settings = feature.settings?.toString(), ) ) @@ -528,7 +529,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { weight = cohort.weight, ) } ?: emptyList() - val config = jsonToggle?.config ?: emptyMap() + val settings = jsonToggle?.settings?.toString() this.feature.get().invokeMethod(subfeature.key).setRawStoredState( Toggle.State( remoteEnableState = newStateValue, @@ -539,7 +540,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { assignedCohort = previousAssignedCohort, targets = targets, cohorts = cohorts, - config = config, + settings = settings, ), ) } catch(e: Throwable) { @@ -781,10 +782,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { "cohorts", List::class.asClassName().parameterizedBy(FqName("JsonToggleCohort").asClassName(module)), ) - .addParameter( - "config", - Map::class.asClassName().parameterizedBy(String::class.asClassName(), String::class.asClassName()), - ) + .addParameter("settings", FqName("org.json.JSONObject").asClassName(module).copy(nullable = true)) .build(), ) .addProperty( @@ -819,8 +817,8 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { ) .addProperty( PropertySpec - .builder("config", Map::class.asClassName().parameterizedBy(String::class.asClassName(), String::class.asClassName())) - .initializer("config") + .builder("settings", FqName("org.json.JSONObject").asClassName(module).copy(nullable = true)) + .initializer("settings") .build(), ) .build() diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 150a7432996d..2bd56dd64447 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.browser +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.Bitmap @@ -27,7 +28,6 @@ import android.print.PrintAttributes import android.view.MenuItem import android.view.MotionEvent import android.view.View -import android.webkit.GeolocationPermissions import android.webkit.HttpAuthHandler import android.webkit.PermissionRequest import android.webkit.SslErrorHandler @@ -58,6 +58,8 @@ import com.duckduckgo.app.autocomplete.api.AutoCompleteApi import com.duckduckgo.app.autocomplete.api.AutoCompleteScorer import com.duckduckgo.app.autocomplete.api.AutoCompleteService import com.duckduckgo.app.autocomplete.impl.AutoCompleteRepository +import com.duckduckgo.app.browser.AndroidFeaturesHeaderPlugin.Companion.TEST_VALUE +import com.duckduckgo.app.browser.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER import com.duckduckgo.app.browser.LongPressHandler.RequiredAction import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.DownloadFile import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.OpenInNewTab @@ -116,7 +118,6 @@ import com.duckduckgo.app.browser.viewstate.FindInPageViewState import com.duckduckgo.app.browser.viewstate.GlobalLayoutViewState import com.duckduckgo.app.browser.viewstate.HighlightableButton import com.duckduckgo.app.browser.viewstate.LoadingViewState -import com.duckduckgo.app.browser.viewstate.OmnibarViewState import com.duckduckgo.app.browser.webview.SslWarningLayout.Action import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId @@ -133,6 +134,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepositoryImpl import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.events.db.UserEventsStore import com.duckduckgo.app.global.install.AppInstallStore @@ -140,10 +142,7 @@ import com.duckduckgo.app.global.model.PrivacyShield.PROTECTED import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactoryImpl import com.duckduckgo.app.location.GeoLocationPermissions -import com.duckduckgo.app.location.data.LocationPermissionEntity -import com.duckduckgo.app.location.data.LocationPermissionType import com.duckduckgo.app.location.data.LocationPermissionsDao -import com.duckduckgo.app.location.data.LocationPermissionsRepositoryImpl import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.onboarding.store.AppStage.ESTABLISHED import com.duckduckgo.app.onboarding.store.OnboardingStore @@ -193,10 +192,13 @@ import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.AUTO +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.OVERLAY import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.Off @@ -205,9 +207,8 @@ import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.Unavailab import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory -import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.history.api.HistoryEntry.VisitedPage @@ -216,14 +217,9 @@ import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels import com.duckduckgo.privacy.config.api.AmpLinkInfo import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking -import com.duckduckgo.privacy.config.api.GpcException -import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.TrackingParameters -import com.duckduckgo.privacy.config.api.UnprotectedTemporary -import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER_VALUE -import com.duckduckgo.privacy.config.store.features.gpc.GpcRepository import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels @@ -239,6 +235,7 @@ import com.duckduckgo.savedsites.api.models.SavedSite.Bookmark import com.duckduckgo.savedsites.api.models.SavedSite.Favorite import com.duckduckgo.savedsites.impl.SavedSitesPixelName import com.duckduckgo.site.permissions.api.SitePermissionsManager +import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions import com.duckduckgo.subscriptions.api.Subscriptions @@ -252,7 +249,6 @@ import java.security.cert.X509Certificate import java.security.interfaces.RSAPublicKey import java.time.LocalDateTime import java.util.UUID -import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel @@ -298,6 +294,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") @FlowPreview class BrowserTabViewModelTest { @@ -376,12 +373,6 @@ class BrowserTabViewModelTest { private val mockAppLinksHandler: AppLinksHandler = mock() - private val mockFeatureToggle: FeatureToggle = mock() - - private val mockGpcRepository: GpcRepository = mock() - - private val mockUnprotectedTemporary: UnprotectedTemporary = mock() - private val mockAmpLinks: AmpLinks = mock() private val mockTrackingParameters: TrackingParameters = mock() @@ -410,7 +401,7 @@ class BrowserTabViewModelTest { private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() - private var loadingBarExperimentManager: LoadingBarExperimentManager = mock() + private val mockShowOnAppLaunchHandler: ShowOnAppLaunchOptionHandler = mock() private lateinit var remoteMessagingModel: RemoteMessagingModel @@ -498,6 +489,7 @@ class BrowserTabViewModelTest { private val protectionTogglePluginPoint = FakePluginPoint(protectionTogglePlugin) private var fakeAndroidConfigBrowserFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val mockAutocompleteTabsFeature: AutocompleteTabsFeature = mock() + private val fakeCustomHeadersPlugin = FakeCustomHeadersProvider(emptyMap()) @Before fun before() = runTest { @@ -539,13 +531,13 @@ class BrowserTabViewModelTest { whenever(mockSSLCertificatesFeature.allowBypass()).thenReturn(mockEnabledToggle) whenever(subscriptions.shouldLaunchPrivacyProForUrl(any())).thenReturn(false) whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoUrl(any())).thenReturn(false) - whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) - whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) whenever(mockDuckPlayer.isDuckPlayerUri(anyString())).thenReturn(false) whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) whenever(changeOmnibarPositionFeature.refactor()).thenReturn(mockEnabledToggle) whenever(mockAutocompleteTabsFeature.self()).thenReturn(mockEnabledToggle) whenever(mockAutocompleteTabsFeature.self().isEnabled()).thenReturn(true) + whenever(mockSitePermissionsManager.hasSitePermanentPermission(any(), any())).thenReturn(false) remoteMessagingModel = givenRemoteMessagingModel(mockRemoteMessagingRepository, mockPixel, coroutineRule.testDispatcherProvider) @@ -621,16 +613,9 @@ class BrowserTabViewModelTest { dispatchers = coroutineRule.testDispatcherProvider, fireproofWebsiteRepository = fireproofWebsiteRepositoryImpl, savedSitesRepository = mockSavedSitesRepository, - locationPermissionsRepository = LocationPermissionsRepositoryImpl( - locationPermissionsDao, - lazyFaviconManager, - coroutineRule.testDispatcherProvider, - ), - geoLocationPermissions = geoLocationPermissions, navigationAwareLoginDetector = mockNavigationAwareLoginDetector, userEventsStore = mockUserEventsStore, fileDownloader = mockFileDownloader, - gpc = RealGpc(mockFeatureToggle, mockGpcRepository, mockUnprotectedTemporary, mockUserAllowListRepository), fireproofDialogsEventHandler = fireproofDialogsEventHandler, emailManager = mockEmailManager, appCoroutineScope = TestScope(), @@ -664,11 +649,12 @@ class BrowserTabViewModelTest { httpErrorPixels = { mockHttpErrorPixels }, duckPlayer = mockDuckPlayer, duckPlayerJSHelper = DuckPlayerJSHelper(mockDuckPlayer, mockAppBuildConfig, mockPixel, mockDuckDuckGoUrlDetector), - loadingBarExperimentManager = loadingBarExperimentManager, refreshPixelSender = refreshPixelSender, changeOmnibarPositionFeature = changeOmnibarPositionFeature, highlightsOnboardingExperimentManager = mockHighlightsOnboardingExperimentManager, privacyProtectionTogglePlugin = protectionTogglePluginPoint, + showOnAppLaunchOptionHandler = mockShowOnAppLaunchHandler, + customHeadersProvider = fakeCustomHeadersPlugin, ) testee.loadData("abc", null, false, false) @@ -1958,7 +1944,7 @@ class BrowserTabViewModelTest { val url = "http://youtube-nocookie.com/videoID=1234" val title = "Duck Player" whenever(mockDuckPlayer.isDuckPlayerUri(anyString())).thenReturn(true) - whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyUri())).thenReturn(true) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(true) whenever(mockDuckPlayer.createDuckPlayerUriFromYoutubeNoCookie(any())).thenReturn("duck://player/1234") whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) @@ -2985,261 +2971,14 @@ class BrowserTabViewModelTest { assertCommandIssued() } - @Test - fun whenDeviceLocationSharingIsDisabledThenSitePermissionIsDenied() = runTest { - val domain = "https://www.example.com/" - - givenDeviceLocationSharingIsEnabled(false) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - verify(geoLocationPermissions).clear(domain) - } - - @Test - fun whenCurrentDomainAndPermissionRequestingDomainAreDifferentThenSitePermissionIsDenied() = runTest { - givenDeviceLocationSharingIsEnabled(true) - givenCurrentSite("https://wwww.example.com/") - givenNewPermissionRequestFromDomain("https://wwww.anotherexample.com/") - - verify(geoLocationPermissions).clear("https://wwww.anotherexample.com/") - } - - @Test - fun whenDomainRequestsSitePermissionThenAppChecksSystemLocationPermission() = runTest { - val domain = "https://www.example.com/" - - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - assertCommandIssued() - } - - @Test - fun whenDomainRequestsSitePermissionAndAlreadyRepliedThenAppChecksSystemLocationPermission() = runTest { - val domain = "https://www.example.com/" - - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.DENY_ALWAYS) - - givenNewPermissionRequestFromDomain(domain) - - verify(geoLocationPermissions).clear(domain) - } - - @Test - fun whenDomainRequestsSitePermissionAndAllowedThenAppChecksSystemLocationPermission() = runTest { - val domain = "https://www.example.com/" - - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.ALLOW_ALWAYS) - - givenNewPermissionRequestFromDomain(domain) - - assertCommandIssued() - } - - @Test - fun whenDomainRequestsSitePermissionAndUserAllowedSessionPermissionThenPermissionIsAllowed() = runTest { - val domain = "https://www.example.com/" - - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - testee.onSiteLocationPermissionSelected(domain, LocationPermissionType.ALLOW_ONCE) - - givenNewPermissionRequestFromDomain(domain) - - assertCommandIssuedTimes(times = 1) - } - - @Test - fun whenAppLocationPermissionIsDeniedThenSitePermissionIsDenied() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(false) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - verify(geoLocationPermissions).clear(domain) - } - - @Test - fun whenSystemPermissionIsDeniedThenSitePermissionIsCleared() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionDeniedOneTime() - - verify(mockPixel).fire(AppPixelName.PRECISE_LOCATION_SETTINGS_LOCATION_PERMISSION_DISABLE) - verify(geoLocationPermissions).clear(domain) - } - - @Test - fun whenUserGrantsSystemLocationPermissionThenSettingsLocationPermissionShouldBeEnabled() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionGranted() - - verify(mockSettingsStore).appLocationPermission = true - } - - @Test - fun whenUserGrantsSystemLocationPermissionThenPixelIsFired() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionGranted() - - verify(mockPixel).fire(AppPixelName.PRECISE_LOCATION_SETTINGS_LOCATION_PERMISSION_ENABLE) - } - - @Test - fun whenUserChoosesToAlwaysAllowSitePermissionThenGeoPermissionIsAllowed() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.ALLOW_ALWAYS) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionGranted() - - verify(geoLocationPermissions, atLeastOnce()).allow(domain) - } - - @Test - fun whenUserChoosesToAlwaysDenySitePermissionThenGeoPermissionIsAllowed() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.DENY_ALWAYS) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionGranted() - - verify(geoLocationPermissions, atLeastOnce()).clear(domain) - } - - @Test - fun whenUserChoosesToAllowSitePermissionThenGeoPermissionIsAllowed() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.ALLOW_ONCE) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionGranted() - - assertCommandIssued() - } - - @Test - fun whenUserChoosesToDenySitePermissionThenGeoPermissionIsAllowed() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.DENY_ONCE) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionGranted() - - assertCommandIssued() - } - - @Test - fun whenNewDomainRequestsForPermissionThenUserShouldBeAskedToGivePermission() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionGranted() - - assertCommandIssued() - } - - @Test - fun whenSystemLocationPermissionIsDeniedThenSitePermissionIsDenied() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionNotAllowed() - - verify(mockPixel).fire(AppPixelName.PRECISE_LOCATION_SYSTEM_DIALOG_LATER) - verify(geoLocationPermissions).clear(domain) - } - - @Test - fun whenSystemLocationPermissionIsNeverAllowedThenSitePermissionIsDenied() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionNeverAllowed() - - verify(mockPixel).fire(AppPixelName.PRECISE_LOCATION_SYSTEM_DIALOG_NEVER) - verify(geoLocationPermissions).clear(domain) - assertEquals(locationPermissionsDao.getPermission(domain)!!.permission, LocationPermissionType.DENY_ALWAYS) - } - - @Test - fun whenSystemLocationPermissionIsAllowedThenAppAsksForSystemPermission() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionAllowed() - - assertCommandIssued() - } - - @Test - fun whenUserDeniesSitePermissionThenSitePermissionIsDenied() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSiteLocationPermissionAlwaysDenied() - - verify(geoLocationPermissions).clear(domain) - } - @Test fun whenUserVisitsDomainWithPermanentLocationPermissionThenMessageIsShown() = runTest { val domain = "https://www.example.com/" - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.ALLOW_ALWAYS) + whenever(mockSitePermissionsManager.hasSitePermanentPermission(domain, LocationPermissionRequest.RESOURCE_LOCATION_PERMISSION)).thenReturn( + true, + ) + givenCurrentSite(domain) givenDeviceLocationSharingIsEnabled(true) givenLocationPermissionIsEnabled(true) @@ -3253,7 +2992,10 @@ class BrowserTabViewModelTest { fun whenUserVisitsDomainWithoutPermanentLocationPermissionThenMessageIsNotShown() = runTest { val domain = "https://www.example.com/" - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.DENY_ALWAYS) + whenever(mockSitePermissionsManager.hasSitePermanentPermission(domain, LocationPermissionRequest.RESOURCE_LOCATION_PERMISSION)).thenReturn( + false, + ) + givenCurrentSite(domain) givenDeviceLocationSharingIsEnabled(true) givenLocationPermissionIsEnabled(true) @@ -3290,7 +3032,10 @@ class BrowserTabViewModelTest { fun whenUserRefreshesASiteLocationMessageIsNotShownAgain() = runTest { val domain = "https://www.example.com/" - givenUserAlreadySelectedPermissionForDomain(domain, LocationPermissionType.ALLOW_ALWAYS) + whenever(mockSitePermissionsManager.hasSitePermanentPermission(domain, LocationPermissionRequest.RESOURCE_LOCATION_PERMISSION)).thenReturn( + true, + ) + givenCurrentSite(domain) givenDeviceLocationSharingIsEnabled(true) givenLocationPermissionIsEnabled(true) @@ -3300,73 +3045,6 @@ class BrowserTabViewModelTest { assertCommandIssuedTimes(1) } - @Test - fun whenUserSelectsPermissionAndRefreshesPageThenLocationMessageIsNotShown() = runTest { - val domain = "http://example.com" - - givenCurrentSite(domain) - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - - testee.onSiteLocationPermissionSelected(domain, LocationPermissionType.ALLOW_ALWAYS) - - loadUrl(domain, isBrowserShowing = true) - - assertCommandNotIssued() - } - - @Test - fun whenSystemLocationPermissionIsDeniedThenSiteLocationPermissionIsAlwaysDenied() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionDeniedOneTime() - - verify(geoLocationPermissions).clear(domain) - } - - @Test - fun whenSystemLocationPermissionIsDeniedForeverThenSiteLocationPermissionIsAlwaysDenied() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionDeniedForever() - - verify(geoLocationPermissions).clear(domain) - } - - @Test - fun whenSystemLocationPermissionIsDeniedForeverThenSettingsFlagIsUpdated() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionDeniedForever() - - verify(mockSettingsStore).appLocationPermissionDeniedForever = true - } - - @Test - fun whenSystemLocationIsGrantedThenSettingsFlagIsUpdated() = runTest { - val domain = "https://www.example.com/" - givenDeviceLocationSharingIsEnabled(true) - givenLocationPermissionIsEnabled(true) - givenCurrentSite(domain) - givenNewPermissionRequestFromDomain(domain) - - testee.onSystemLocationPermissionGranted() - - verify(mockSettingsStore).appLocationPermissionDeniedForever = false - } - @Test fun whenPrefetchFaviconThenFetchFaviconForCurrentTab() = runTest { val url = "https://www.example.com/" @@ -3483,38 +3161,6 @@ class BrowserTabViewModelTest { verify(mockFaviconManager, never()).storeFavicon(any(), any()) } - @Test - fun whenOnSiteLocationPermissionSelectedAndPermissionIsAllowAlwaysThenPersistFavicon() = runTest { - val url = "http://example.com" - val permission = LocationPermissionType.ALLOW_ALWAYS - givenNewPermissionRequestFromDomain(url) - - testee.onSiteLocationPermissionSelected(url, permission) - - verify(mockFaviconManager).persistCachedFavicon(any(), eq(url)) - } - - @Test - fun whenOnSiteLocationPermissionSelectedAndPermissionIsDenyAlwaysThenPersistFavicon() = runTest { - val url = "http://example.com" - val permission = LocationPermissionType.DENY_ALWAYS - givenNewPermissionRequestFromDomain(url) - - testee.onSiteLocationPermissionSelected(url, permission) - - verify(mockFaviconManager).persistCachedFavicon(any(), eq(url)) - } - - @Test - fun whenOnSystemLocationPermissionNeverAllowedThenPersistFavicon() = runTest { - val url = "http://example.com" - givenNewPermissionRequestFromDomain(url) - - testee.onSystemLocationPermissionNeverAllowed() - - verify(mockFaviconManager).persistCachedFavicon(any(), eq(url)) - } - @Test fun whenBookmarkAddedThenPersistFavicon() = runTest { val url = "http://example.com" @@ -3588,7 +3234,7 @@ class BrowserTabViewModelTest { @Test fun whenUserSubmittedQueryIfGpcIsEnabledAndUrlIsValidThenAddHeaderToUrl() { - givenUrlCanUseGpc() + givenCustomHeadersProviderReturnsGpcHeader() whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") testee.onUserSubmittedQuery("foo") @@ -3599,9 +3245,9 @@ class BrowserTabViewModelTest { } @Test - fun whenUserSubmittedQueryIfGpcIsEnabledAndUrlIsNotValidThenDoNotAddHeaderToUrl() { + fun whenUserSubmittedQueryIfGpcReturnsNoHeaderThenDoNotAddHeaderToUrl() { val url = "foo.com" - givenUrlCannotUseGpc(url) + givenCustomHeadersProviderReturnsNoHeaders() whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn(url) testee.onUserSubmittedQuery("foo") @@ -3612,20 +3258,8 @@ class BrowserTabViewModelTest { } @Test - fun whenUserSubmittedQueryIfGpcIsDisabledThenDoNotAddHeaderToUrl() { - givenGpcIsDisabled() - whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") - - testee.onUserSubmittedQuery("foo") - verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - - val command = commandCaptor.lastValue as Navigate - assertTrue(command.headers.isEmpty()) - } - - @Test - fun whenOnDesktopSiteModeToggledIfGpcIsEnabledAndUrlIsValidThenAddHeaderToUrl() { - givenUrlCanUseGpc() + fun whenOnDesktopSiteModeToggledIfGpcReturnsHeaderThenAddHeaderToUrl() { + givenCustomHeadersProviderReturnsGpcHeader() loadUrl("http://m.example.com") setDesktopBrowsingMode(false) testee.onChangeBrowserModeClicked() @@ -3636,32 +3270,8 @@ class BrowserTabViewModelTest { } @Test - fun whenOnDesktopSiteModeToggledIfGpcIsEnabledAndUrlIsNotValidThenDoNotAddHeaderToUrl() { - givenUrlCannotUseGpc("example.com") - loadUrl("http://m.example.com") - setDesktopBrowsingMode(false) - testee.onChangeBrowserModeClicked() - verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - - val command = commandCaptor.lastValue as Navigate - assertTrue(command.headers.isEmpty()) - } - - @Test - fun whenOnDesktopSiteModeToggledIfGpcIsDisabledThenDoNotAddHeaderToUrl() { - givenGpcIsDisabled() - loadUrl("http://m.example.com") - setDesktopBrowsingMode(false) - testee.onChangeBrowserModeClicked() - verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - - val command = commandCaptor.lastValue as Navigate - assertTrue(command.headers.isEmpty()) - } - - @Test - fun whenExternalAppLinkClickedIfGpcIsEnabledAndUrlIsValidThenAddHeaderToUrl() { - givenUrlCanUseGpc() + fun whenExternalAppLinkClickedIfGpcReturnsHeaderThenAddHeaderToUrl() { + givenCustomHeadersProviderReturnsGpcHeader() val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), "fallback") testee.nonHttpAppLinkClicked(intentType) @@ -3672,8 +3282,8 @@ class BrowserTabViewModelTest { } @Test - fun whenExternalAppLinkClickedIfGpcIsEnabledAndFallbackUrlIsNullThenDoNotAddHeaderToUrl() { - givenUrlCanUseGpc() + fun whenExternalAppLinkClickedIfGpcReturnsNoHeaderThenDoNotAddHeaderToUrl() { + givenCustomHeadersProviderReturnsNoHeaders() val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), null) testee.nonHttpAppLinkClicked(intentType) @@ -3684,27 +3294,26 @@ class BrowserTabViewModelTest { } @Test - fun whenExternalAppLinkClickedIfGpcIsEnabledAndUrlIsNotValidThenDoNotAddHeaderToUrl() { - val url = "fallback" - givenUrlCannotUseGpc(url) - val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), url) + fun whenUserSubmittedQueryIfAndroidFeaturesReturnsHeaderThenAddHeaderToUrl() { + givenCustomHeadersProviderReturnsAndroidFeaturesHeader() + whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") - testee.nonHttpAppLinkClicked(intentType) + testee.onUserSubmittedQuery("foo") verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - val command = commandCaptor.lastValue as Command.HandleNonHttpAppLink - assertTrue(command.headers.isEmpty()) + val command = commandCaptor.lastValue as Navigate + assertEquals(TEST_VALUE, command.headers[X_DUCKDUCKGO_ANDROID_HEADER]) } @Test - fun whenExternalAppLinkClickedIfGpcIsDisabledThenDoNotAddHeaderToUrl() { - givenGpcIsDisabled() - val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), "fallback") + fun whenUserSubmittedQueryIfAndroidFeaturesReturnsNoHeaderThenDoNotAddHeaderToUrl() { + givenCustomHeadersProviderReturnsNoHeaders() + whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") - testee.nonHttpAppLinkClicked(intentType) + testee.onUserSubmittedQuery("foo") verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - val command = commandCaptor.lastValue as Command.HandleNonHttpAppLink + val command = commandCaptor.lastValue as Navigate assertTrue(command.headers.isEmpty()) } @@ -5135,6 +4744,38 @@ class BrowserTabViewModelTest { assertCommandIssued() } + @Test + fun whenProcessJsCallbackMessageOpenDuckPlayerWithUrlAndOpenInNewTabThenSetOrigin() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + whenever(mockDuckPlayer.shouldOpenDuckPlayerInNewTab()).thenReturn(Off) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "openDuckPlayer", + "id", + JSONObject("""{ href: "duck://player/1234" }"""), + false, + { "someUrl" }, + ) + verify(mockDuckPlayer).setDuckPlayerOrigin(OVERLAY) + } + + @Test + fun whenProcessJsCallbackMessageOpenDuckPlayerWithDuckPlayerAlwaysEnabledUrlAndOpenInNewTabThenSetOrigin() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = Enabled)) + whenever(mockDuckPlayer.shouldOpenDuckPlayerInNewTab()).thenReturn(Off) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "openDuckPlayer", + "id", + JSONObject("""{ href: "duck://player/1234" }"""), + false, + { "someUrl" }, + ) + verify(mockDuckPlayer).setDuckPlayerOrigin(AUTO) + } + @Test fun whenProcessJsCallbackMessageOpenDuckPlayerWithUrlAndOpenInNewTabUnavailableThenNavigate() = runTest { whenever(mockEnabledToggle.isEnabled()).thenReturn(true) @@ -6016,45 +5657,6 @@ class BrowserTabViewModelTest { } } - @Test - fun whenExperimentEnabledShowOmnibarImmediately() = runTest { - setBrowserShowing(true) - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(changeOmnibarPositionFeature.refactor()).thenReturn(mockDisabledToggle) - val observer = mock<(OmnibarViewState) -> Unit>() - testee.omnibarViewState.observeForever { observer(it) } - - testee.navigationStateChanged(buildWebNavigation("https://example.com")) - - val captor = argumentCaptor() - verify(observer, times(4)).invoke(captor.capture()) - - assertFalse(captor.allValues[0].navigationChange) - assertTrue(captor.allValues[1].navigationChange) - assertFalse(captor.allValues[2].navigationChange) - assertFalse(captor.allValues[3].navigationChange) - - testee.omnibarViewState.removeObserver { observer(it) } - } - - @Test - fun whenExperimentDisabledDoNotShowOmnibarImmediately() = runTest { - setBrowserShowing(true) - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) - val observer = mock<(OmnibarViewState) -> Unit>() - testee.omnibarViewState.observeForever { observer(it) } - - testee.navigationStateChanged(buildWebNavigation("https://example.com")) - - val captor = argumentCaptor() - verify(observer, times(2)).invoke(captor.capture()) - - assertFalse(captor.allValues[0].navigationChange) - assertFalse(captor.allValues[1].navigationChange) - - testee.omnibarViewState.removeObserver { observer(it) } - } - @Test fun whenHandleMenuRefreshActionThenSendMenuRefreshPixels() { testee.handleMenuRefreshAction() @@ -6127,6 +5729,13 @@ class BrowserTabViewModelTest { } } + @Test + fun whenNavigationStateChangedCalledThenHandleResolvedUrlIsChecked() = runTest { + testee.navigationStateChanged(buildWebNavigation("https://example.com")) + + verify(mockShowOnAppLaunchHandler).handleResolvedUrlStorage(eq("https://example.com"), any(), any()) + } + private fun aCredential(): LoginCredentials { return LoginCredentials(domain = null, username = null, password = null) } @@ -6154,22 +5763,16 @@ class BrowserTabViewModelTest { testee.navigationStateChanged(buildWebNavigation(navigationHistory = history)) } - private fun givenUrlCanUseGpc() { - whenever(mockFeatureToggle.isFeatureEnabled(any(), any())).thenReturn(true) - whenever(mockGpcRepository.isGpcEnabled()).thenReturn(true) - whenever(mockGpcRepository.exceptions).thenReturn(CopyOnWriteArrayList()) + private fun givenCustomHeadersProviderReturnsGpcHeader() { + fakeCustomHeadersPlugin.headers = mapOf(GPC_HEADER to GPC_HEADER_VALUE) } - private fun givenUrlCannotUseGpc(url: String) { - val exceptions = CopyOnWriteArrayList().apply { add(GpcException(url)) } - whenever(mockFeatureToggle.isFeatureEnabled(eq(PrivacyFeatureName.GpcFeatureName.value), any())).thenReturn(true) - whenever(mockGpcRepository.isGpcEnabled()).thenReturn(true) - whenever(mockGpcRepository.exceptions).thenReturn(exceptions) + private fun givenCustomHeadersProviderReturnsNoHeaders() { + fakeCustomHeadersPlugin.headers = emptyMap() } - private fun givenGpcIsDisabled() { - whenever(mockFeatureToggle.isFeatureEnabled(any(), any())).thenReturn(true) - whenever(mockGpcRepository.isGpcEnabled()).thenReturn(false) + private fun givenCustomHeadersProviderReturnsAndroidFeaturesHeader() { + fakeCustomHeadersPlugin.headers = mapOf(X_DUCKDUCKGO_ANDROID_HEADER to TEST_VALUE) } private suspend fun givenFireButtonPulsing() { @@ -6178,10 +5781,6 @@ class BrowserTabViewModelTest { dismissedCtaDaoChannel.send(listOf(DismissedCta(CtaId.DAX_DIALOG_TRACKERS_FOUND))) } - private fun givenNewPermissionRequestFromDomain(domain: String) { - testee.onSiteLocationPermissionRequested(domain, StubPermissionCallback()) - } - private fun givenDeviceLocationSharingIsEnabled(state: Boolean) { whenever(geoLocationPermissions.isDeviceLocationEnabled()).thenReturn(state) } @@ -6190,23 +5789,6 @@ class BrowserTabViewModelTest { whenever(mockSettingsStore.appLocationPermission).thenReturn(state) } - private fun givenUserAlreadySelectedPermissionForDomain( - domain: String, - permission: LocationPermissionType, - ) { - locationPermissionsDao.insert(LocationPermissionEntity(domain, permission)) - } - - class StubPermissionCallback : GeolocationPermissions.Callback { - override fun invoke( - p0: String?, - p1: Boolean, - p2: Boolean, - ) { - // nothing to see - } - } - private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val issuedCommand = commandCaptor.allValues.find { it is T } @@ -6424,4 +6006,10 @@ class BrowserTabViewModelTest { toggleOn++ } } + + class FakeCustomHeadersProvider(var headers: Map) : CustomHeadersProvider { + override fun getCustomHeaders(url: String): Map { + return headers + } + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index e576cada78fc..aa51829145a6 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -57,10 +57,9 @@ import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList import com.duckduckgo.app.browser.pageloadpixel.PageLoadedHandler import com.duckduckgo.app.browser.pageloadpixel.firstpaint.PagePaintedHandler import com.duckduckgo.app.browser.print.PrintInjector +import com.duckduckgo.app.browser.uriloaded.UriLoadedManager import com.duckduckgo.app.global.model.Site -import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.autoconsent.api.Autoconsent import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.InternalTestUserChecker @@ -72,11 +71,11 @@ import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.cookies.api.CookieManagerProvider import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.SERP_AUTO import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.Off import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.On import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.Unavailable -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.subscriptions.api.Subscriptions @@ -146,9 +145,10 @@ class BrowserWebViewClientTest { private val subscriptions: Subscriptions = mock() private val mockDuckPlayer: DuckPlayer = mock() private val navigationHistory: NavigationHistory = mock() - private val loadingBarExperimentManager: LoadingBarExperimentManager = mock() private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private val openInNewTabFlow: MutableSharedFlow = MutableSharedFlow() + private val mockUriLoadedManager: UriLoadedManager = mock() + private val mockAndroidFeaturesHeaderPlugin = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mock()) @UiThreadTest @Before @@ -183,8 +183,9 @@ class BrowserWebViewClientTest { mediaPlayback, subscriptions, mockDuckPlayer, - loadingBarExperimentManager, mockDuckDuckGoUrlDetector, + mockUriLoadedManager, + mockAndroidFeaturesHeaderPlugin, ) testee.webViewClientListener = listener whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) @@ -409,7 +410,7 @@ class BrowserWebViewClientTest { @UiThreadTest @Test - fun whenShouldOverrideWithShouldNavigateToDuckPlayerFromSerpThenAddQueryParam() = runTest { + fun whenShouldOverrideWithShouldNavigateToDuckPlayerSetOriginToSerpAuto() = runTest { val urlType = SpecialUrlDetector.UrlType.ShouldLaunchDuckPlayerLink("duck://player/1234".toUri()) whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any())).thenReturn(urlType) whenever(webResourceRequest.isForMainFrame).thenReturn(true) @@ -424,8 +425,8 @@ class BrowserWebViewClientTest { whenever(mockWebView.url).thenReturn("www.duckduckgo.com") openInNewTabFlow.emit(Off) - assertTrue(testee.shouldOverrideUrlLoading(mockWebView, webResourceRequest)) - verify(mockWebView).loadUrl("www.youtube.com/watch?v=1234&origin=serp_auto") + assertFalse(testee.shouldOverrideUrlLoading(mockWebView, webResourceRequest)) + verify(mockDuckPlayer).setDuckPlayerOrigin(SERP_AUTO) } @Test @@ -534,7 +535,7 @@ class BrowserWebViewClientTest { @UiThreadTest @Test - fun whenShouldOverrideWithShouldNavigateToDuckPlayerFromSerpAndOpenInNewTabThenAddQueryParam() = runTest { + fun whenShouldOverrideWithShouldNavigateToDuckPlayerFromSerpAndOpenInNewTabThenSetOriginToSerpAuto() = runTest { val urlType = SpecialUrlDetector.UrlType.ShouldLaunchDuckPlayerLink("duck://player/1234".toUri()) whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any())).thenReturn(urlType) whenever(webResourceRequest.isForMainFrame).thenReturn(true) @@ -550,8 +551,8 @@ class BrowserWebViewClientTest { whenever(mockWebView.url).thenReturn("www.duckduckgo.com") openInNewTabFlow.emit(Off) - assertTrue(testee.shouldOverrideUrlLoading(mockWebView, webResourceRequest)) - verify(mockWebView).loadUrl("www.youtube.com/watch?v=1234&origin=serp_auto") + assertFalse(testee.shouldOverrideUrlLoading(mockWebView, webResourceRequest)) + verify(mockDuckPlayer).setDuckPlayerOrigin(SERP_AUTO) } @UiThreadTest @@ -926,7 +927,7 @@ class BrowserWebViewClientTest { whenever(mockWebView.progress).thenReturn(100) whenever(mockWebView.safeCopyBackForwardList()).thenReturn(TestBackForwardList()) whenever(mockWebView.settings).thenReturn(mock()) - whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) whenever(mockDuckPlayer.isYoutubeWatchUrl(any())).thenReturn(false) testee.onPageStarted(mockWebView, EXAMPLE_URL, null) whenever(currentTimeProvider.elapsedRealtime()).thenReturn(10) @@ -1050,68 +1051,9 @@ class BrowserWebViewClientTest { @UiThreadTest @Test - fun whenLoadingBarExperimentEnabledThenPixelFiredWithExperimentData() { - val mockWebView = getImmediatelyInvokedMockWebView() - - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(loadingBarExperimentManager.shouldSendUriLoadedPixel).thenReturn(true) - whenever(loadingBarExperimentManager.variant).thenReturn(true) - whenever(mockWebView.settings).thenReturn(mock()) - whenever(mockWebView.safeCopyBackForwardList()).thenReturn(TestBackForwardList()) - whenever(mockWebView.progress).thenReturn(100) - - testee.onPageStarted(mockWebView, EXAMPLE_URL, null) - testee.onPageFinished(mockWebView, EXAMPLE_URL) - - verify(pixel).fire( - AppPixelName.URI_LOADED.pixelName, - mapOf(LOADING_BAR_EXPERIMENT to "1"), - ) - } - - @UiThreadTest - @Test - fun whenLoadingBarExperimentDisabledThenPixelFiredWithoutExperimentData() { - val mockWebView = getImmediatelyInvokedMockWebView() - - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) - whenever(loadingBarExperimentManager.shouldSendUriLoadedPixel).thenReturn(true) - whenever(mockWebView.settings).thenReturn(mock()) - whenever(mockWebView.safeCopyBackForwardList()).thenReturn(TestBackForwardList()) - whenever(mockWebView.progress).thenReturn(100) - - testee.onPageStarted(mockWebView, EXAMPLE_URL, null) - testee.onPageFinished(mockWebView, EXAMPLE_URL) - - verify(pixel).fire(AppPixelName.URI_LOADED) - } - - @UiThreadTest - @Test - fun whenLoadingBarExperimentEnabledButProgressIsNot100ThenNoPixelFired() { - val mockWebView = getImmediatelyInvokedMockWebView() - - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(loadingBarExperimentManager.shouldSendUriLoadedPixel).thenReturn(true) - whenever(loadingBarExperimentManager.variant).thenReturn(true) - whenever(mockWebView.settings).thenReturn(mock()) - whenever(mockWebView.safeCopyBackForwardList()).thenReturn(TestBackForwardList()) - whenever(mockWebView.progress).thenReturn(50) - - testee.onPageStarted(mockWebView, EXAMPLE_URL, null) - testee.onPageFinished(mockWebView, EXAMPLE_URL) - - verify(pixel, never()).fire(anyString(), any(), any(), any()) - } - - @UiThreadTest - @Test - fun whenShouldNotSendUriLoadedPixelThenNoPixelFired() { + fun whenPageLoadsThenFireUriLoadedPixel() { val mockWebView = getImmediatelyInvokedMockWebView() - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(loadingBarExperimentManager.shouldSendUriLoadedPixel).thenReturn(false) - whenever(loadingBarExperimentManager.variant).thenReturn(true) whenever(mockWebView.settings).thenReturn(mock()) whenever(mockWebView.safeCopyBackForwardList()).thenReturn(TestBackForwardList()) whenever(mockWebView.progress).thenReturn(100) @@ -1119,7 +1061,7 @@ class BrowserWebViewClientTest { testee.onPageStarted(mockWebView, EXAMPLE_URL, null) testee.onPageFinished(mockWebView, EXAMPLE_URL) - verify(pixel, never()).fire(anyString(), any(), any(), any()) + mockUriLoadedManager.sendUriLoadedPixel() } private class TestWebView(context: Context) : WebView(context) { diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index 978e1c33b2b8..05f60543c4fc 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -70,7 +70,6 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.ArgumentMatchers.anyString import org.mockito.kotlin.* @FlowPreview @@ -148,7 +147,7 @@ class CtaViewModelTest { whenever(mockDuckPlayer.isDuckPlayerUri(any())).thenReturn(false) whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(false, AlwaysAsk)) whenever(mockDuckPlayer.isYouTubeUrl(any())).thenReturn(false) - whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) testee = CtaViewModel( appInstallStore = mockAppInstallStore, @@ -762,13 +761,42 @@ class CtaViewModelTest { whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(false, AlwaysAsk)) whenever(mockDuckPlayer.isYouTubeUrl(any())).thenReturn(false) - whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) - verify(mockPixel).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(Unique())) assertNull(value) } + @Test + fun givenDuckPlayerSiteWhenRefreshCtaWhileBrowsingThenFireSkipMajorNetworkPixel() = runTest { + givenDaxOnboardingActive() + val site = site(url = "duck://player/12345", entity = TestEntity("Google", "Google", 9.0)) + + whenever(mockDuckPlayer.isDuckPlayerUri(any())).thenReturn(true) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(false, AlwaysAsk)) + whenever(mockDuckPlayer.isYouTubeUrl(any())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) + + testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) + verify(mockPixel).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(Unique())) + } + + @Test + fun givenDuckPlayerSiteWhenRefreshCtaWhileBrowsingAndTrackersDialogAlreadyShownThenDontSentSkipMajorNetworkPixel() = runTest { + givenDaxOnboardingActive() + val site = site(url = "duck://player/12345", entity = TestEntity("Google", "Google", 9.0)) + + whenever(mockDuckPlayer.isDuckPlayerUri(any())).thenReturn(true) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(false, AlwaysAsk)) + whenever(mockDuckPlayer.isYouTubeUrl(any())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) + + testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) + verify(mockPixel).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(Unique())) + } + @Test fun givenHighlightsExperimentWhenRefreshCtaOnHomeTabAndIntroCtaWasNotPreviouslyShownThenExperimentIntroCtaShown() = runTest { givenDaxOnboardingActive() diff --git a/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt b/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt index 422c12dcbb60..e9c6d1d1e72e 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt @@ -22,6 +22,7 @@ import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabSelectionEntity +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.* import org.junit.Before @@ -337,4 +338,33 @@ class TabsDaoTest { assertEquals(tab.copy(deletable = false), testee.tab(tab.tabId)) } + + @Test + fun whenSelectTabByUrlAndTabExistsThenTabIdReturned() = runTest { + val tab = TabEntity( + tabId = "TAB_ID", + url = "https://www.duckduckgo.com/", + position = 0, + deletable = true, + ) + + testee.insertTab(tab) + val tabId = testee.selectTabByUrl("https://www.duckduckgo.com/") + + assertEquals(tabId, tab.tabId) + } + + @Test + fun whenSelectTabByUrlAndTabDoesNotExistThenNullReturned() = runTest { + val tab = TabEntity( + tabId = "TAB_ID", + url = "https://www.duckduckgo.com/", + position = 0, + ) + + testee.insertTab(tab) + val tabId = testee.selectTabByUrl("https://www.quackquackno.com/") + + assertNull(tabId) + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e6474231c8e2..534d53d49c85 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -418,6 +418,11 @@ android:exported="false" android:label="@string/generalSettingsActivityTitle" android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" /> + { + if (androidBrowserConfigFeature.self().isEnabled() && + androidBrowserConfigFeature.featuresRequestHeader().isEnabled() && + duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url) + ) { + return mapOf( + X_DUCKDUCKGO_ANDROID_HEADER to TEST_VALUE, + ) + } + return emptyMap() + } + + companion object { + internal const val X_DUCKDUCKGO_ANDROID_HEADER = "x-duckduckgo-android" + internal const val TEST_VALUE = "test" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index c674bf41b3b4..f9a1c5199cbf 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -391,6 +391,10 @@ open class BrowserActivity : DuckDuckGoActivity() { } else { Timber.i("shared text empty, opening last tab") } + + if (!intent.getBooleanExtra(LAUNCH_FROM_CLEAR_DATA_ACTION, false)) { + viewModel.handleShowOnAppLaunchOption() + } } private fun configureObservers() { @@ -583,6 +587,7 @@ open class BrowserActivity : DuckDuckGoActivity() { isExternal: Boolean = false, interstitialScreen: Boolean = false, openExistingTabId: String? = null, + isLaunchFromClearDataAction: Boolean = false, ): Intent { val intent = Intent(context, BrowserActivity::class.java) intent.putExtra(EXTRA_TEXT, queryExtra) @@ -593,6 +598,7 @@ open class BrowserActivity : DuckDuckGoActivity() { intent.putExtra(LAUNCH_FROM_EXTERNAL_EXTRA, isExternal) intent.putExtra(LAUNCH_FROM_INTERSTITIAL_EXTRA, interstitialScreen) intent.putExtra(OPEN_EXISTING_TAB_ID_EXTRA, openExistingTabId) + intent.putExtra(LAUNCH_FROM_CLEAR_DATA_ACTION, isLaunchFromClearDataAction) return intent } @@ -608,6 +614,7 @@ open class BrowserActivity : DuckDuckGoActivity() { const val OPEN_EXISTING_TAB_ID_EXTRA = "OPEN_EXISTING_TAB_ID_EXTRA" private const val LAUNCH_FROM_EXTERNAL_EXTRA = "LAUNCH_FROM_EXTERNAL_EXTRA" + private const val LAUNCH_FROM_CLEAR_DATA_ACTION = "LAUNCH_FROM_CLEAR_DATA_ACTION" private const val MAX_ACTIVE_TABS = 40 } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt index 366645e15323..f6755db8698f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt @@ -35,6 +35,7 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.site.permissions.api.SitePermissionsManager +import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -144,16 +145,26 @@ class BrowserChromeClient @Inject constructor( } override fun onPermissionRequest(request: PermissionRequest) { + Timber.d("Permissions: permission requested ${request.resources.asList()}") webViewClientListener?.getCurrentTabId()?.let { tabId -> appCoroutineScope.launch(coroutineDispatcher.io()) { val permissionsAllowedToAsk = sitePermissionsManager.getSitePermissions(tabId, request) if (permissionsAllowedToAsk.userHandled.isNotEmpty()) { + Timber.d("Permissions: permission requested not user handled") webViewClientListener?.onSitePermissionRequested(request, permissionsAllowedToAsk) } } } } + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback, + ) { + Timber.d("Permissions: location permission requested $origin") + onPermissionRequest(LocationPermissionRequest(origin, callback)) + } + override fun onCloseWindow(window: WebView?) { webViewClientListener?.closeCurrentTab() } @@ -208,13 +219,6 @@ class BrowserChromeClient @Inject constructor( return true } - override fun onGeolocationPermissionsShowPrompt( - origin: String, - callback: GeolocationPermissions.Callback, - ) { - webViewClientListener?.onSiteLocationPermissionRequested(origin, callback) - } - override fun getDefaultVideoPoster(): Bitmap { return Bitmap.createBitmap(intArrayOf(Color.TRANSPARENT), 1, 1, ARGB_8888) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 8aa39b14d8ea..73ba576b7c78 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -70,7 +70,6 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.annotation.AnyThread import androidx.annotation.StringRes -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.core.text.HtmlCompat @@ -100,7 +99,6 @@ import com.duckduckgo.app.accessibility.data.AccessibilitySettingsDataStore import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion import com.duckduckgo.app.brokensite.BrokenSiteActivity import com.duckduckgo.app.browser.BrowserTabViewModel.FileChooserRequestedParams -import com.duckduckgo.app.browser.BrowserTabViewModel.LocationPermission import com.duckduckgo.app.browser.R.string import com.duckduckgo.app.browser.SSLErrorType.NONE import com.duckduckgo.app.browser.WebViewErrorResponse.LOADING @@ -118,8 +116,6 @@ import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.browser.customtabs.CustomTabActivity import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames import com.duckduckgo.app.browser.customtabs.CustomTabViewModel.Companion.CUSTOM_TAB_NAME_PREFIX -import com.duckduckgo.app.browser.databinding.ContentSiteLocationPermissionDialogBinding -import com.duckduckgo.app.browser.databinding.ContentSystemLocationPermissionDialogBinding import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding import com.duckduckgo.app.browser.databinding.HttpAuthenticationBinding import com.duckduckgo.app.browser.downloader.BlobConverterInjector @@ -186,7 +182,6 @@ import com.duckduckgo.app.global.view.isImmersiveModeEnabled import com.duckduckgo.app.global.view.launchDefaultAppActivity import com.duckduckgo.app.global.view.renderIfChanged import com.duckduckgo.app.global.view.toggleFullScreen -import com.duckduckgo.app.location.data.LocationPermissionType import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privatesearch.PrivateSearchScreenNoParams import com.duckduckgo.app.settings.db.SettingsDataStore @@ -267,7 +262,6 @@ import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging @@ -298,7 +292,6 @@ import com.duckduckgo.voice.api.VoiceSearchLauncher import com.duckduckgo.voice.api.VoiceSearchLauncher.Source.BROWSER import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import java.io.File @@ -524,9 +517,6 @@ class BrowserTabFragment : @Inject lateinit var duckPlayer: DuckPlayer - @Inject - lateinit var loadingBarExperimentManager: LoadingBarExperimentManager - @Inject lateinit var webViewCapabilityChecker: WebViewCapabilityChecker @@ -1665,9 +1655,6 @@ class BrowserTabFragment : is Command.ShowErrorWithAction -> showErrorSnackbar(it) is Command.HideWebContent -> webView?.hide() is Command.ShowWebContent -> webView?.show() - is Command.CheckSystemLocationPermission -> checkSystemLocationPermission(it.domain, it.deniedForever) - is Command.RequestSystemLocationPermission -> requestLocationPermissions() - is Command.AskDomainPermission -> askSiteLocationPermission(it.locationPermission) is Command.RefreshUserAgent -> refreshUserAgent(it.url, it.isDesktop) is Command.AskToFireproofWebsite -> askToFireproofWebsite(requireContext(), it.fireproofWebsite) is Command.AskToAutomateFireproofWebsite -> askToAutomateFireproofWebsite(requireContext(), it.fireproofWebsite) @@ -1913,116 +1900,6 @@ class BrowserTabFragment : } } - private fun locationPermissionsHaveNotBeenGranted(): Boolean { - return ContextCompat.checkSelfPermission( - requireActivity(), - Manifest.permission.ACCESS_COARSE_LOCATION, - ) != PackageManager.PERMISSION_GRANTED || - ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED - } - - private fun checkSystemLocationPermission( - domain: String, - deniedForever: Boolean, - ) { - if (locationPermissionsHaveNotBeenGranted()) { - if (deniedForever) { - viewModel.onSystemLocationPermissionDeniedForever() - } else { - showSystemLocationPermissionDialog(domain) - } - } else { - viewModel.onSystemLocationPermissionGranted() - } - } - - private fun showSystemLocationPermissionDialog(domain: String) { - val binding = ContentSystemLocationPermissionDialogBinding.inflate(layoutInflater) - - val originUrl = domain.websiteFromGeoLocationsApiOrigin() - val subtitle = getString(R.string.preciseLocationSystemDialogSubtitle, originUrl, originUrl) - binding.systemPermissionDialogSubtitle.text = subtitle - - val dialog = CustomAlertDialogBuilder(requireActivity()) - .setView(binding) - .build() - - binding.allowLocationPermission.setOnClickListener { - viewModel.onSystemLocationPermissionAllowed() - dialog.dismiss() - } - - binding.denyLocationPermission.setOnClickListener { - viewModel.onSystemLocationPermissionNotAllowed() - dialog.dismiss() - } - - binding.neverAllowLocationPermission.setOnClickListener { - viewModel.onSystemLocationPermissionNeverAllowed() - dialog.dismiss() - } - - dialog.show() - } - - private fun requestLocationPermissions() { - requestPermissions( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, - ), - PERMISSION_REQUEST_GEO_LOCATION, - ) - } - - private fun askSiteLocationPermission(locationPermission: LocationPermission) { - if (!isActiveCustomTab() && !isActiveTab) { - Timber.v("Will not launch a dialog for an inactive tab") - return - } - - val binding = ContentSiteLocationPermissionDialogBinding.inflate(layoutInflater) - - val domain = locationPermission.origin - val title = domain.websiteFromGeoLocationsApiOrigin() - binding.sitePermissionDialogTitle.text = getString(R.string.preciseLocationSiteDialogTitle, title) - binding.sitePermissionDialogSubtitle.text = if (title == DDG_DOMAIN) { - getString(R.string.preciseLocationDDGDialogSubtitle) - } else { - getString(R.string.preciseLocationSiteDialogSubtitle) - } - - val dialog = MaterialAlertDialogBuilder(requireActivity()) - .setView(binding.root) - .setOnCancelListener { - // Called when user clicks outside the dialog - deny to be safe - locationPermission.callback.invoke(locationPermission.origin, false, false) - } - .create() - - binding.siteAllowAlwaysLocationPermission.setOnClickListener { - viewModel.onSiteLocationPermissionSelected(domain, LocationPermissionType.ALLOW_ALWAYS) - dialog.dismiss() - } - - binding.siteAllowOnceLocationPermission.setOnClickListener { - viewModel.onSiteLocationPermissionSelected(domain, LocationPermissionType.ALLOW_ONCE) - dialog.dismiss() - } - - binding.siteDenyOnceLocationPermission.setOnClickListener { - viewModel.onSiteLocationPermissionSelected(domain, LocationPermissionType.DENY_ONCE) - dialog.dismiss() - } - - binding.siteDenyAlwaysLocationPermission.setOnClickListener { - viewModel.onSiteLocationPermissionSelected(domain, LocationPermissionType.DENY_ALWAYS) - dialog.dismiss() - } - - dialog.show() - } - private fun launchBrokenSiteFeedback(data: BrokenSiteData) { val context = context ?: return @@ -3526,18 +3403,6 @@ class BrowserTabFragment : omnibar.toolbar.makeSnackbarWithNoBottomInset(R.string.permissionRequiredToDownload, Snackbar.LENGTH_LONG).show() } } - - PERMISSION_REQUEST_GEO_LOCATION -> { - if ((grantResults.isNotEmpty()) && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - viewModel.onSystemLocationPermissionGranted() - } else { - if (ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), Manifest.permission.ACCESS_FINE_LOCATION)) { - viewModel.onSystemLocationPermissionDeniedOneTime() - } else { - viewModel.onSystemLocationPermissionDeniedTwice() - } - } - } } } @@ -3636,7 +3501,6 @@ class BrowserTabFragment : private const val TAB_ID_ARG = "TAB_ID_ARG" private const val URL_EXTRA_ARG = "URL_EXTRA_ARG" private const val SKIP_HOME_ARG = "SKIP_HOME_ARG" - private const val DDG_DOMAIN = "duckduckgo.com" private const val LAUNCH_FROM_EXTERNAL_EXTRA = "LAUNCH_FROM_EXTERNAL_EXTRA" const val ADD_SAVED_SITE_FRAGMENT_TAG = "ADD_SAVED_SITE" @@ -3646,7 +3510,6 @@ class BrowserTabFragment : private const val REQUEST_CODE_CHOOSE_FILE = 100 private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200 - private const val PERMISSION_REQUEST_GEO_LOCATION = 300 private const val URL_BUNDLE_KEY = "url" diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 859201baa204..bf77ed639b95 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -28,7 +28,6 @@ import android.view.ContextMenu import android.view.MenuItem import android.view.MotionEvent.ACTION_UP import android.view.View -import android.webkit.GeolocationPermissions import android.webkit.MimeTypeMap import android.webkit.PermissionRequest import android.webkit.SslErrorHandler @@ -77,14 +76,12 @@ import com.duckduckgo.app.browser.certificates.BypassedSSLCertificatesRepository import com.duckduckgo.app.browser.certificates.remoteconfig.SSLCertificatesFeature import com.duckduckgo.app.browser.commands.Command import com.duckduckgo.app.browser.commands.Command.AddHomeShortcut -import com.duckduckgo.app.browser.commands.Command.AskDomainPermission import com.duckduckgo.app.browser.commands.Command.AskToAutomateFireproofWebsite import com.duckduckgo.app.browser.commands.Command.AskToDisableLoginDetection import com.duckduckgo.app.browser.commands.Command.AskToFireproofWebsite import com.duckduckgo.app.browser.commands.Command.AutocompleteItemRemoved import com.duckduckgo.app.browser.commands.Command.BrokenSiteFeedback import com.duckduckgo.app.browser.commands.Command.CancelIncomingAutofillRequest -import com.duckduckgo.app.browser.commands.Command.CheckSystemLocationPermission import com.duckduckgo.app.browser.commands.Command.ChildTabClosed import com.duckduckgo.app.browser.commands.Command.ConvertBlobToDataUri import com.duckduckgo.app.browser.commands.Command.CopyAliasToClipboard @@ -120,7 +117,6 @@ import com.duckduckgo.app.browser.commands.Command.OpenMessageInNewTab import com.duckduckgo.app.browser.commands.Command.PrintLink import com.duckduckgo.app.browser.commands.Command.RefreshUserAgent import com.duckduckgo.app.browser.commands.Command.RequestFileDownload -import com.duckduckgo.app.browser.commands.Command.RequestSystemLocationPermission import com.duckduckgo.app.browser.commands.Command.RequiresAuthentication import com.duckduckgo.app.browser.commands.Command.ResetHistory import com.duckduckgo.app.browser.commands.Command.SaveCredentials @@ -212,6 +208,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ALWAYS import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ASK_EVERY_TIME +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler import com.duckduckgo.app.global.events.db.UserEventKey import com.duckduckgo.app.global.events.db.UserEventsStore import com.duckduckgo.app.global.model.PrivacyShield @@ -219,9 +216,7 @@ import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.global.model.domainMatchesUrl -import com.duckduckgo.app.location.GeoLocationPermissions import com.duckduckgo.app.location.data.LocationPermissionType -import com.duckduckgo.app.location.data.LocationPermissionsRepository import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.HighlightsOnboardingExperimentManager import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_BANNER_DISMISSED @@ -268,6 +263,7 @@ import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.extensions.asLocationPermissionOrigin import com.duckduckgo.common.utils.isMobileSite import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider import com.duckduckgo.common.utils.toDesktopUri import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.downloads.api.DownloadCommand @@ -276,14 +272,12 @@ import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels import com.duckduckgo.privacy.config.api.AmpLinkInfo import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking -import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.TrackingParameters import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin @@ -301,6 +295,7 @@ import com.duckduckgo.savedsites.impl.SavedSitesPixelName import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment.DeleteBookmarkListener import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment.EditSavedSiteListener import com.duckduckgo.site.permissions.api.SitePermissionsManager +import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions import com.duckduckgo.subscriptions.api.Subscriptions @@ -378,8 +373,6 @@ class BrowserTabViewModel @Inject constructor( private val networkLeaderboardDao: NetworkLeaderboardDao, private val savedSitesRepository: SavedSitesRepository, private val fireproofWebsiteRepository: FireproofWebsiteRepository, - private val locationPermissionsRepository: LocationPermissionsRepository, - private val geoLocationPermissions: GeoLocationPermissions, private val navigationAwareLoginDetector: NavigationAwareLoginDetector, private val autoComplete: AutoComplete, private val appSettingsPreferencesStore: SettingsDataStore, @@ -394,7 +387,6 @@ class BrowserTabViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val userEventsStore: UserEventsStore, private val fileDownloader: FileDownloader, - private val gpc: Gpc, private val fireproofDialogsEventHandler: FireproofDialogsEventHandler, private val emailManager: EmailManager, private val accessibilitySettingsDataStore: AccessibilitySettingsDataStore, @@ -427,11 +419,12 @@ class BrowserTabViewModel @Inject constructor( private val httpErrorPixels: Lazy, private val duckPlayer: DuckPlayer, private val duckPlayerJSHelper: DuckPlayerJSHelper, - private val loadingBarExperimentManager: LoadingBarExperimentManager, private val refreshPixelSender: RefreshPixelSender, private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature, private val highlightsOnboardingExperimentManager: HighlightsOnboardingExperimentManager, private val privacyProtectionTogglePlugin: PluginPoint, + private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, + private val customHeadersProvider: CustomHeadersProvider, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -446,11 +439,6 @@ class BrowserTabViewModel @Inject constructor( // Map>() = Map>() private val fixedReplyProxyMap = mutableMapOf>() - data class LocationPermission( - val origin: String, - val callback: GeolocationPermissions.Callback, - ) - data class FileChooserRequestedParams( val filePickingMode: Int, val acceptMimeTypes: List, @@ -496,7 +484,7 @@ class BrowserTabViewModel @Inject constructor( val title: String? get() = site?.title - private var locationPermission: LocationPermission? = null + private var locationPermissionRequest: LocationPermissionRequest? = null private val locationPermissionMessages: MutableMap = mutableMapOf() private val locationPermissionSession: MutableMap = mutableMapOf() @@ -1073,10 +1061,7 @@ class BrowserTabViewModel @Inject constructor( } private fun getUrlHeaders(url: String?): Map { - url?.let { - return gpc.getHeaders(url) - } - return emptyMap() + return url?.let { customHeadersProvider.getCustomHeaders(it) } ?: emptyMap() } private fun extractVerticalParameter(currentUrl: String?): String? { @@ -1329,11 +1314,20 @@ class BrowserTabViewModel @Inject constructor( override fun navigationStateChanged(newWebNavigationState: WebNavigationState) { val stateChange = newWebNavigationState.compare(webNavigationState) + + viewModelScope.launch { + showOnAppLaunchOptionHandler.handleResolvedUrlStorage( + currentUrl = newWebNavigationState.currentUrl, + isRootOfTab = !newWebNavigationState.canGoBack, + tabId = tabId, + ) + } + webNavigationState = newWebNavigationState if (!currentBrowserViewState().browserShowing) return - if (loadingBarExperimentManager.isExperimentEnabled() || settingsDataStore.omnibarPosition == BOTTOM) { + if (settingsDataStore.omnibarPosition == BOTTOM) { showOmniBar() } @@ -1596,39 +1590,10 @@ class BrowserTabViewModel @Inject constructor( } private suspend fun notifyPermanentLocationPermission(domain: String) { - if (!geoLocationPermissions.isDeviceLocationEnabled()) { - viewModelScope.launch(dispatchers.io()) { - onDeviceLocationDisabled() - } - return - } - - if (!appSettingsPreferencesStore.appLocationPermission) { - return + if (sitePermissionsManager.hasSitePermanentPermission(domain, LocationPermissionRequest.RESOURCE_LOCATION_PERMISSION)) { + Timber.d("Location Permission: domain $domain site url ${site?.url} has location permission") + command.postValue(ShowDomainHasPermissionMessage(domain)) } - - val permissionEntity = locationPermissionsRepository.getDomainPermission(domain) - permissionEntity?.let { - if (it.permission == LocationPermissionType.ALLOW_ALWAYS) { - Timber.d("Location Permission: domain $domain site url ${site?.url}") - if (!locationPermissionMessages.containsKey(domain)) { - setDomainHasLocationPermissionShown(domain) - if (shouldShowLocationPermissionMessage()) { - Timber.d("Show location permission for $domain") - command.postValue(ShowDomainHasPermissionMessage(domain)) - } - } - } - } - } - - private fun shouldShowLocationPermissionMessage(): Boolean { - val url = site?.url ?: return true - return !duckDuckGoUrlDetector.isDuckDuckGoChatUrl(url) - } - - private fun setDomainHasLocationPermissionShown(domain: String) { - locationPermissionMessages[domain] = true } private fun urlUpdated(url: String) { @@ -1735,51 +1700,16 @@ class BrowserTabViewModel @Inject constructor( request: PermissionRequest, sitePermissionsAllowedToAsk: SitePermissions, ) { - viewModelScope.launch(dispatchers.io()) { - command.postValue(ShowSitePermissionsDialog(sitePermissionsAllowedToAsk, request)) - } - } - - override fun onSiteLocationPermissionRequested( - origin: String, - callback: GeolocationPermissions.Callback, - ) { - locationPermission = LocationPermission(origin, callback) - - if (!geoLocationPermissions.isDeviceLocationEnabled()) { - viewModelScope.launch(dispatchers.io()) { - onDeviceLocationDisabled() + if (request is LocationPermissionRequest) { + if (!sameEffectiveTldPlusOne(site, request.origin)) { + Timber.d("Permissions: sameEffectiveTldPlusOne false") + request.deny() + return } - onSiteLocationPermissionAlwaysDenied() - return } - if (!sameEffectiveTldPlusOne(site, origin)) { - onSiteLocationPermissionAlwaysDenied() - return - } - - if (!appSettingsPreferencesStore.appLocationPermission) { - onSiteLocationPermissionAlwaysDenied() - return - } - - viewModelScope.launch { - val previouslyDeniedForever = appSettingsPreferencesStore.appLocationPermissionDeniedForever - val permissionEntity = locationPermissionsRepository.getDomainPermission(origin) - if (permissionEntity == null) { - if (locationPermissionSession.containsKey(origin)) { - reactToSiteSessionPermission(locationPermissionSession[origin]!!) - } else { - command.postValue(CheckSystemLocationPermission(origin, previouslyDeniedForever)) - } - } else { - if (permissionEntity.permission == LocationPermissionType.DENY_ALWAYS) { - onSiteLocationPermissionAlwaysDenied() - } else { - command.postValue(CheckSystemLocationPermission(origin, previouslyDeniedForever)) - } - } + viewModelScope.launch(dispatchers.io()) { + command.postValue(ShowSitePermissionsDialog(sitePermissionsAllowedToAsk, request)) } } @@ -1796,144 +1726,6 @@ class BrowserTabViewModel @Inject constructor( return siteETldPlusOne == originETldPlusOne } - fun onSiteLocationPermissionSelected( - domain: String, - permission: LocationPermissionType, - ) { - locationPermission?.let { locationPermission -> - when (permission) { - LocationPermissionType.ALLOW_ALWAYS -> { - onSiteLocationPermissionAlwaysAllowed() - setDomainHasLocationPermissionShown(domain) - pixel.fire(AppPixelName.PRECISE_LOCATION_SITE_DIALOG_ALLOW_ALWAYS) - viewModelScope.launch { - locationPermissionsRepository.savePermission(domain, permission) - faviconManager.persistCachedFavicon(tabId, domain) - } - } - - LocationPermissionType.ALLOW_ONCE -> { - pixel.fire(AppPixelName.PRECISE_LOCATION_SITE_DIALOG_ALLOW_ONCE) - locationPermissionSession[domain] = permission - locationPermission.callback.invoke(locationPermission.origin, true, false) - } - - LocationPermissionType.DENY_ALWAYS -> { - pixel.fire(AppPixelName.PRECISE_LOCATION_SITE_DIALOG_DENY_ALWAYS) - onSiteLocationPermissionAlwaysDenied() - viewModelScope.launch { - locationPermissionsRepository.savePermission(domain, permission) - faviconManager.persistCachedFavicon(tabId, domain) - } - } - - LocationPermissionType.DENY_ONCE -> { - pixel.fire(AppPixelName.PRECISE_LOCATION_SITE_DIALOG_DENY_ONCE) - locationPermissionSession[domain] = permission - locationPermission.callback.invoke(locationPermission.origin, false, false) - } - } - } - } - - private fun onSiteLocationPermissionAlwaysAllowed() { - locationPermission?.let { locationPermission -> - geoLocationPermissions.allow(locationPermission.origin) - locationPermission.callback.invoke(locationPermission.origin, true, false) - } - } - - fun onSiteLocationPermissionAlwaysDenied() { - locationPermission?.let { locationPermission -> - geoLocationPermissions.clear(locationPermission.origin) - locationPermission.callback.invoke(locationPermission.origin, false, false) - } - } - - private suspend fun onDeviceLocationDisabled() { - geoLocationPermissions.clearAll() - } - - private fun reactToSitePermission(permission: LocationPermissionType) { - locationPermission?.let { locationPermission -> - when (permission) { - LocationPermissionType.ALLOW_ALWAYS -> { - onSiteLocationPermissionAlwaysAllowed() - } - - LocationPermissionType.ALLOW_ONCE -> { - command.postValue(AskDomainPermission(locationPermission)) - } - - LocationPermissionType.DENY_ALWAYS -> { - onSiteLocationPermissionAlwaysDenied() - } - - LocationPermissionType.DENY_ONCE -> { - command.postValue(AskDomainPermission(locationPermission)) - } - } - } - } - - private fun reactToSiteSessionPermission(permission: LocationPermissionType) { - locationPermission?.let { locationPermission -> - if (permission == LocationPermissionType.ALLOW_ONCE) { - locationPermission.callback.invoke(locationPermission.origin, true, false) - } else { - locationPermission.callback.invoke(locationPermission.origin, false, false) - } - } - } - - fun onSystemLocationPermissionAllowed() { - pixel.fire(AppPixelName.PRECISE_LOCATION_SYSTEM_DIALOG_ENABLE) - command.postValue(RequestSystemLocationPermission) - } - - fun onSystemLocationPermissionNotAllowed() { - pixel.fire(AppPixelName.PRECISE_LOCATION_SYSTEM_DIALOG_LATER) - onSiteLocationPermissionAlwaysDenied() - } - - fun onSystemLocationPermissionNeverAllowed() { - locationPermission?.let { locationPermission -> - onSiteLocationPermissionSelected(locationPermission.origin, LocationPermissionType.DENY_ALWAYS) - pixel.fire(AppPixelName.PRECISE_LOCATION_SYSTEM_DIALOG_NEVER) - } - } - - fun onSystemLocationPermissionGranted() { - locationPermission?.let { locationPermission -> - appSettingsPreferencesStore.appLocationPermissionDeniedForever = false - appSettingsPreferencesStore.appLocationPermission = true - pixel.fire(AppPixelName.PRECISE_LOCATION_SETTINGS_LOCATION_PERMISSION_ENABLE) - viewModelScope.launch { - val permissionEntity = locationPermissionsRepository.getDomainPermission(locationPermission.origin) - if (permissionEntity == null) { - command.postValue(AskDomainPermission(locationPermission)) - } else { - reactToSitePermission(permissionEntity.permission) - } - } - } - } - - fun onSystemLocationPermissionDeniedOneTime() { - pixel.fire(AppPixelName.PRECISE_LOCATION_SETTINGS_LOCATION_PERMISSION_DISABLE) - onSiteLocationPermissionAlwaysDenied() - } - - fun onSystemLocationPermissionDeniedTwice() { - pixel.fire(AppPixelName.PRECISE_LOCATION_SETTINGS_LOCATION_PERMISSION_DISABLE) - onSystemLocationPermissionDeniedForever() - } - - fun onSystemLocationPermissionDeniedForever() { - appSettingsPreferencesStore.appLocationPermissionDeniedForever = true - onSiteLocationPermissionAlwaysDenied() - } - private fun registerSiteVisit() { Schedulers.io().scheduleDirect { networkLeaderboardDao.incrementSitesVisited() diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index 73686393e4c7..fcf92bc0d38f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -20,11 +20,14 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.fire.DataClearer +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler import com.duckduckgo.app.global.ApplicationClearDataState import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter import com.duckduckgo.app.global.rating.AppEnjoymentPromptOptions @@ -69,6 +72,8 @@ class BrowserViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val pixel: Pixel, private val skipUrlConversionOnNewTabFeature: SkipUrlConversionOnNewTabFeature, + private val showOnAppLaunchFeature: ShowOnAppLaunchFeature, + private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, ) : ViewModel(), CoroutineScope { @@ -290,6 +295,14 @@ class BrowserViewModel @Inject constructor( tabRepository.select(tabId) } } + + fun handleShowOnAppLaunchOption() { + if (showOnAppLaunchFeature.self().isEnabled()) { + viewModelScope.launch { + showOnAppLaunchOptionHandler.handleAppLaunchOption() + } + } + } } /** diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index eb6410eb86bd..717ee910bb86 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -58,26 +58,22 @@ import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList import com.duckduckgo.app.browser.pageloadpixel.PageLoadedHandler import com.duckduckgo.app.browser.pageloadpixel.firstpaint.PagePaintedHandler import com.duckduckgo.app.browser.print.PrintInjector +import com.duckduckgo.app.browser.uriloaded.UriLoadedManager import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.autoconsent.api.Autoconsent import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.InternalTestUserChecker import com.duckduckgo.browser.api.JsInjectorPlugin import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.common.utils.extensions.toBinaryString import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.cookies.api.CookieManagerProvider import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.SERP_AUTO import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.On -import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM -import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM_SERP_AUTO import com.duckduckgo.duckplayer.impl.DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.subscriptions.api.Subscriptions @@ -117,8 +113,9 @@ class BrowserWebViewClient @Inject constructor( private val mediaPlayback: MediaPlayback, private val subscriptions: Subscriptions, private val duckPlayer: DuckPlayer, - private val loadingBarExperimentManager: LoadingBarExperimentManager, private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, + private val uriLoadedManager: UriLoadedManager, + private val androidFeaturesHeaderPlugin: AndroidFeaturesHeaderPlugin, ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -338,17 +335,22 @@ class BrowserWebViewClient @Inject constructor( } return true } else if (willOpenDuckPlayer && webView.url?.let { duckDuckGoUrlDetector.isDuckDuckGoUrl(it) } == true) { - val newUrl = url.buildUpon().appendQueryParameter(ORIGIN_QUERY_PARAM, ORIGIN_QUERY_PARAM_SERP_AUTO).build() + duckPlayer.setDuckPlayerOrigin(SERP_AUTO) if (openInNewTab) { - listener.openLinkInNewTab(newUrl) + listener.openLinkInNewTab(url) + return true } else { - loadUrl(listener, webView, newUrl.toString()) + return false } - return true } else if (openInNewTab) { webViewClientListener?.openLinkInNewTab(url) return true } else { + val headers = androidFeaturesHeaderPlugin.getHeaders(url.toString()) + if (headers.isNotEmpty()) { + loadUrl(webView, url.toString(), headers) + return true + } return false } } @@ -386,6 +388,14 @@ class BrowserWebViewClient @Inject constructor( } } + private fun loadUrl( + webView: WebView, + url: String, + headers: Map, + ) { + webView.loadUrl(url, headers) + } + @UiThread override fun onPageStarted( webView: WebView, @@ -452,13 +462,14 @@ class BrowserWebViewClient @Inject constructor( printInjector.injectPrint(webView) url?.let { + val uri = url.toUri() if (url != ABOUT_BLANK) { start?.let { safeStart -> // TODO (cbarreiro - 22/05/2024): Extract to plugins pageLoadedHandler.onPageLoaded(it, navigationList.currentItem?.title, safeStart, currentTimeProvider.elapsedRealtime()) shouldSendPagePaintedPixel(webView = webView, url = it) appCoroutineScope.launch(dispatcherProvider.io()) { - if (duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isSimulatedYoutubeNoCookie(url)) { + if (duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isSimulatedYoutubeNoCookie(uri)) { duckPlayer.createDuckPlayerUriFromYoutubeNoCookie(url.toUri())?.let { navigationHistory.saveToHistory( it, @@ -466,22 +477,13 @@ class BrowserWebViewClient @Inject constructor( ) } } else { - if (duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isYoutubeWatchUrl(url.toUri())) { + if (duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isYoutubeWatchUrl(uri)) { duckPlayer.duckPlayerNavigatedToYoutube() } navigationHistory.saveToHistory(url, navigationList.currentItem?.title) } } - if (loadingBarExperimentManager.shouldSendUriLoadedPixel) { - if (loadingBarExperimentManager.isExperimentEnabled()) { - pixel.fire( - AppPixelName.URI_LOADED.pixelName, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - ) - } else { - pixel.fire(AppPixelName.URI_LOADED) - } - } + uriLoadedManager.sendUriLoadedPixel() start = null } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt index 3eb00f793a94..4995b74b6f32 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt @@ -34,7 +34,6 @@ import com.duckduckgo.privacy.config.api.TrackingParameters import com.duckduckgo.subscriptions.api.Subscriptions import java.net.URISyntaxException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.runBlocking import timber.log.Timber class SpecialUrlDetectorImpl( @@ -91,9 +90,7 @@ class SpecialUrlDetectorImpl( val uri = uriString.toUri() - val willNavigateToDuckPlayer = runBlocking(dispatcherProvider.io()) { duckPlayer.willNavigateToDuckPlayer(uri) } - - if (willNavigateToDuckPlayer) { + if (duckPlayer.willNavigateToDuckPlayer(uri)) { return UrlType.ShouldLaunchDuckPlayerLink(url = uri) } else { try { diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt index 90df126cfc3c..f8fb1313a391 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -21,7 +21,6 @@ import android.net.Uri import android.net.http.SslCertificate import android.os.Message import android.view.View -import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest import android.webkit.SslErrorHandler import android.webkit.ValueCallback @@ -46,11 +45,6 @@ interface WebViewClientListener { sitePermissionsAllowedToAsk: SitePermissions, ) - fun onSiteLocationPermissionRequested( - origin: String, - callback: GeolocationPermissions.Callback, - ) - fun titleReceived(newTitle: String) fun trackerDetected(event: TrackingEvent) fun pageHasHttpResources(page: String) diff --git a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt index 76f183008781..6453bec1e34a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt @@ -27,7 +27,6 @@ import android.webkit.ValueCallback import androidx.annotation.DrawableRes import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion import com.duckduckgo.app.browser.BrowserTabViewModel.FileChooserRequestedParams -import com.duckduckgo.app.browser.BrowserTabViewModel.LocationPermission import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.AppLink import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.NonHttpAppLink import com.duckduckgo.app.browser.SslErrorResponse @@ -160,13 +159,6 @@ sealed class Command { val url: String?, val showDuckPlayerIcon: Boolean = false, ) : Command() - class CheckSystemLocationPermission( - val domain: String, - val deniedForever: Boolean, - ) : Command() - - class AskDomainPermission(val locationPermission: LocationPermission) : Command() - object RequestSystemLocationPermission : Command() class RefreshUserAgent( val url: String?, val isDesktop: Boolean, diff --git a/app/src/main/java/com/duckduckgo/app/browser/duckplayer/DuckPlayerJSHelper.kt b/app/src/main/java/com/duckduckgo/app/browser/duckplayer/DuckPlayerJSHelper.kt index e90c48b2fc4d..23a6eb5645d8 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/duckplayer/DuckPlayerJSHelper.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/duckplayer/DuckPlayerJSHelper.kt @@ -16,7 +16,6 @@ package com.duckduckgo.app.browser.duckplayer -import androidx.core.net.toUri import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.browser.commands.Command import com.duckduckgo.app.browser.commands.Command.OpenDuckPlayerOverlayInfo @@ -36,12 +35,11 @@ import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_NEVER_SERP import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.AUTO +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.OVERLAY import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.On import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences -import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM -import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM_AUTO -import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM_OVERLAY import com.duckduckgo.duckplayer.api.PrivatePlayerMode import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.duckduckgo.js.messaging.api.JsCallbackData @@ -212,15 +210,15 @@ class DuckPlayerJSHelper @Inject constructor( "openDuckPlayer" -> { val openInNewTab = duckPlayer.shouldOpenDuckPlayerInNewTab() is On return data?.getString("href")?.let { - val newUrl = if (duckPlayer.getUserPreferences().privatePlayerMode == Enabled) { - it.toUri().buildUpon().appendQueryParameter(ORIGIN_QUERY_PARAM, ORIGIN_QUERY_PARAM_AUTO).build() + if (duckPlayer.getUserPreferences().privatePlayerMode == Enabled) { + duckPlayer.setDuckPlayerOrigin(AUTO) } else { - it.toUri().buildUpon().appendQueryParameter(ORIGIN_QUERY_PARAM, ORIGIN_QUERY_PARAM_OVERLAY).build() - }.toString() + duckPlayer.setDuckPlayerOrigin(OVERLAY) + } if (openInNewTab && !isActiveCustomTab) { - OpenInNewTab(newUrl, tabId) + OpenInNewTab(it, tabId) } else { - Navigate(newUrl, mapOf()) + Navigate(it, mapOf()) } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt index dfb9ff430131..974bb0abd95d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt @@ -27,7 +27,6 @@ import android.widget.RelativeLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat.NestedScrollType -import androidx.core.view.updateLayoutParams import com.duckduckgo.app.browser.R import com.google.android.material.snackbar.Snackbar import kotlin.math.max @@ -96,18 +95,20 @@ class BottomAppBarBehavior( // only hide the app bar in the browser layout if (target.id == R.id.browserWebView) { toolbar.translationY = max(0f, min(toolbar.height.toFloat(), toolbar.translationY + dy)) + offsetBottomByToolbar(browserLayout) } - - offsetBottomByToolbar(target) } } private fun offsetBottomByToolbar(view: View?) { - if (view?.layoutParams is CoordinatorLayout.LayoutParams) { - view.updateLayoutParams { - this.bottomMargin = omnibar.measuredHeight() - omnibar.getTranslation().roundToInt() + (view?.layoutParams as? CoordinatorLayout.LayoutParams)?.let { layoutParams -> + val newBottomMargin = omnibar.measuredHeight() - omnibar.getTranslation().roundToInt() + if (layoutParams.bottomMargin != newBottomMargin) { + layoutParams.bottomMargin = newBottomMargin + view.postOnAnimation { + view.requestLayout() + } } - view.requestLayout() } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt index dd6f36d1429a..4e11156b9eed 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt @@ -21,7 +21,6 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn @@ -38,14 +37,12 @@ class OmnibarFeatureFlagObserver @Inject constructor( private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature, private val settingsDataStore: SettingsDataStore, private val dispatchers: DispatcherProvider, - private val loadingBarExperimentManager: LoadingBarExperimentManager, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : PrivacyConfigCallbackPlugin { override fun onPrivacyConfigDownloaded() { appCoroutineScope.launch(dispatchers.io()) { // If the feature is not enabled, set the omnibar position to top in case it was set to bottom. - // The feature will only available if the loading experiment is disabled to avoid conflicts. - if (!changeOmnibarPositionFeature.self().isEnabled() || loadingBarExperimentManager.isExperimentEnabled()) { + if (!changeOmnibarPositionFeature.self().isEnabled()) { settingsDataStore.omnibarPosition = OmnibarPosition.TOP } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt index 01ea335fcef2..d508b7ef2193 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt @@ -22,16 +22,13 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS import com.duckduckgo.app.pixels.AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.trackerdetection.blocklist.BlockListPixelsPlugin import com.duckduckgo.app.trackerdetection.blocklist.get2XRefresh import com.duckduckgo.app.trackerdetection.blocklist.get3XRefresh import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.common.utils.extensions.toBinaryString import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import javax.inject.Inject @@ -49,7 +46,6 @@ interface RefreshPixelSender { class DuckDuckGoRefreshPixelSender @Inject constructor( private val pixel: Pixel, private val dao: RefreshDao, - private val loadingBarExperimentManager: LoadingBarExperimentManager, private val currentTimeProvider: CurrentTimeProvider, private val blockListPixelsPlugin: BlockListPixelsPlugin, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, @@ -58,47 +54,18 @@ class DuckDuckGoRefreshPixelSender @Inject constructor( override fun sendMenuRefreshPixels() { sendTimeBasedPixels() - - // Loading Bar Experiment - if (loadingBarExperimentManager.isExperimentEnabled()) { - pixel.fire( - AppPixelName.MENU_ACTION_REFRESH_PRESSED, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - ) - pixel.fire( - AppPixelName.REFRESH_ACTION_DAILY_PIXEL, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - type = Daily(), - ) - } else { - pixel.fire(AppPixelName.MENU_ACTION_REFRESH_PRESSED) - pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL, type = Daily()) - } + pixel.fire(AppPixelName.MENU_ACTION_REFRESH_PRESSED) + pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL, type = Daily()) } override fun sendPullToRefreshPixels() { sendTimeBasedPixels() - - // Loading Bar Experiment - if (loadingBarExperimentManager.isExperimentEnabled()) { - pixel.fire( - AppPixelName.BROWSER_PULL_TO_REFRESH, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - ) - pixel.fire( - AppPixelName.REFRESH_ACTION_DAILY_PIXEL, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - type = Daily(), - ) - } else { - pixel.fire(AppPixelName.BROWSER_PULL_TO_REFRESH) - pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL, type = Daily()) - } + pixel.fire(AppPixelName.BROWSER_PULL_TO_REFRESH) + pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL, type = Daily()) } override fun sendCustomTabRefreshPixel() { sendTimeBasedPixels() - pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_REFRESH) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSender.kt new file mode 100644 index 000000000000..30c8af2f3085 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSender.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.browser.trafficquality + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autoconsent.api.Autoconsent +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection +import com.duckduckgo.networkprotection.api.NetworkProtectionState +import com.duckduckgo.privacy.config.api.Gpc +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesMultibinding(AppScope::class) +class AndroidFeaturesPixelSender @Inject constructor( + private val autoconsent: Autoconsent, + private val gpc: Gpc, + private val appTrackingProtection: AppTrackingProtection, + private val networkProtectionState: NetworkProtectionState, + private val pixel: Pixel, + @AppCoroutineScope private val coroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : AtbLifecyclePlugin { + + override fun onSearchRetentionAtbRefreshed(oldAtb: String, newAtb: String) { + coroutineScope.launch(dispatcherProvider.io()) { + val params = mutableMapOf() + params[PARAM_COOKIE_POP_UP_MANAGEMENT_ENABLED] = autoconsent.isAutoconsentEnabled().toString() + params[PARAM_GLOBAL_PRIVACY_CONTROL_ENABLED] = gpc.isEnabled().toString() + params[PARAM_APP_TRACKING_PROTECTION_ENABLED] = appTrackingProtection.isEnabled().toString() + params[PARAM_PRIVACY_PRO_VPN_ENABLED] = networkProtectionState.isEnabled().toString() + pixel.fire(AppPixelName.FEATURES_ENABLED_AT_SEARCH_TIME, params) + } + } + + companion object { + internal const val PARAM_COOKIE_POP_UP_MANAGEMENT_ENABLED = "cookie_pop_up_management_enabled" + internal const val PARAM_GLOBAL_PRIVACY_CONTROL_ENABLED = "global_privacy_control_enabled" + internal const val PARAM_APP_TRACKING_PROTECTION_ENABLED = "app_tracking_protection_enabled" + internal const val PARAM_PRIVACY_PRO_VPN_ENABLED = "privacy_pro_vpn_enabled" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/uriloaded/DuckDuckGoUriLoadedManager.kt b/app/src/main/java/com/duckduckgo/app/browser/uriloaded/DuckDuckGoUriLoadedManager.kt new file mode 100644 index 000000000000..efeb7aa074ff --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/uriloaded/DuckDuckGoUriLoadedManager.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.browser.uriloaded + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.di.IsMainProcess +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +interface UriLoadedManager { + fun sendUriLoadedPixel() +} + +@ContributesBinding( + scope = AppScope::class, + boundType = UriLoadedManager::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = PrivacyConfigCallbackPlugin::class, +) +@SingleInstanceIn(AppScope::class) +class DuckDuckGoUriLoadedManager @Inject constructor( + private val pixel: Pixel, + private val uriLoadedPixelFeature: UriLoadedPixelFeature, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + @IsMainProcess isMainProcess: Boolean, +) : UriLoadedManager, PrivacyConfigCallbackPlugin { + + private var shouldSendUriLoadedPixel: Boolean = false + + init { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (isMainProcess) { + loadToMemory() + } + } + } + + override fun sendUriLoadedPixel() { + if (shouldSendUriLoadedPixel) { + pixel.fire(AppPixelName.URI_LOADED) + } + } + + override fun onPrivacyConfigDownloaded() { + appCoroutineScope.launch(dispatcherProvider.io()) { + loadToMemory() + } + } + + private fun loadToMemory() { + shouldSendUriLoadedPixel = uriLoadedPixelFeature.self().isEnabled() + } +} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/UriLoadedPixelFeature.kt b/app/src/main/java/com/duckduckgo/app/browser/uriloaded/UriLoadedPixelFeature.kt similarity index 93% rename from experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/UriLoadedPixelFeature.kt rename to app/src/main/java/com/duckduckgo/app/browser/uriloaded/UriLoadedPixelFeature.kt index cb04015406f8..196f82025b4f 100644 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/UriLoadedPixelFeature.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/uriloaded/UriLoadedPixelFeature.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.experiments.impl.loadingbarexperiment +package com.duckduckgo.app.browser.uriloaded import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.di.scopes.AppScope diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index 78fc5d28f7f9..36febc9b8554 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -291,7 +291,7 @@ class CtaViewModel @Inject constructor( val nonNullSite = site ?: return null val host = nonNullSite.domain - if (host == null || userAllowListRepository.isDomainInUserAllowList(host) || isSiteNotAllowedForOnboarding(nonNullSite.url)) { + if (host == null || userAllowListRepository.isDomainInUserAllowList(host) || isSiteNotAllowedForOnboarding(nonNullSite)) { return null } @@ -368,8 +368,8 @@ class CtaViewModel @Inject constructor( } } - private suspend fun isSiteNotAllowedForOnboarding(url: String?): Boolean { - val uri = url?.toUri() ?: return true + private suspend fun isSiteNotAllowedForOnboarding(site: Site): Boolean { + val uri = site.url.toUri() if (subscriptions.isPrivacyProUrl(uri)) return true @@ -377,11 +377,16 @@ class CtaViewModel @Inject constructor( duckPlayer.getDuckPlayerState() == DuckPlayerState.ENABLED && ( (duckPlayer.getUserPreferences().privatePlayerMode == AlwaysAsk && duckPlayer.isYouTubeUrl(uri)) || - duckPlayer.isDuckPlayerUri(url) || duckPlayer.isSimulatedYoutubeNoCookie(url) + duckPlayer.isDuckPlayerUri(site.url) || duckPlayer.isSimulatedYoutubeNoCookie(uri) ) - if (isDuckPlayerUrl) { - pixel.fire(pixel = ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE, type = Unique()) + if (isDuckPlayerUrl) { // temporary pixel + val isMayorNetwork = !daxDialogNetworkShown() && !daxDialogTrackersFoundShown() && OnboardingDaxDialogCta.mainTrackerNetworks.any { + site.entity?.displayName?.contains(it) ?: false + } + if (isMayorNetwork) { + pixel.fire(pixel = ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE, type = Unique()) + } } return isDuckPlayerUrl diff --git a/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt b/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt index bdfbf27b8296..1f723d53c0d6 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt @@ -31,7 +31,6 @@ import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.common.utils.plugins.pixel.PixelInterceptorPlugin import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.experiments.api.VariantManager -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.duckduckgo.user.agent.api.UserAgentProvider import com.squareup.moshi.Moshi import dagger.Lazy @@ -172,7 +171,6 @@ class NetworkModule { @AppCoroutineScope appCoroutineScope: CoroutineScope, appBuildConfig: AppBuildConfig, dispatcherProvider: DispatcherProvider, - loadingBarExperimentManager: LoadingBarExperimentManager, ): FeedbackSubmitter = FireAndForgetFeedbackSubmitter( feedbackService, @@ -183,7 +181,6 @@ class NetworkModule { appCoroutineScope, appBuildConfig, dispatcherProvider, - loadingBarExperimentManager, ) companion object { diff --git a/app/src/main/java/com/duckduckgo/app/di/SystemComponentsModule.kt b/app/src/main/java/com/duckduckgo/app/di/SystemComponentsModule.kt index 2535a8ab80db..539a59ef122a 100644 --- a/app/src/main/java/com/duckduckgo/app/di/SystemComponentsModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/SystemComponentsModule.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.di import android.content.Context import android.content.pm.PackageManager +import android.location.LocationManager import com.duckduckgo.app.fire.FireAnimationLoader import com.duckduckgo.app.fire.LottieFireAnimationLoader import com.duckduckgo.app.global.shortcut.AppShortcutCreator @@ -47,6 +48,10 @@ object SystemComponentsModule { @Provides fun packageManager(context: Context): PackageManager = context.packageManager + @SingleInstanceIn(AppScope::class) + @Provides + fun locationManager(context: Context): LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + @SingleInstanceIn(AppScope::class) @Provides fun deviceAppsListProvider(packageManager: PackageManager): DeviceAppListProvider = InstalledDeviceAppListProvider(packageManager) diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackService.kt b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackService.kt index 0ed71a942c4b..7c46f50f086d 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackService.kt +++ b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackService.kt @@ -40,7 +40,6 @@ interface FeedbackService { @Field("manufacturer") manufacturer: String, @Field("model") model: String, @Field("atb") atb: String, - @Field("loading_bar_exp") loadingBarExperiment: String?, ) companion object { diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt index 2de27816c4f8..63930f509d6a 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt +++ b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt @@ -28,13 +28,10 @@ import com.duckduckgo.app.feedback.ui.negative.FeedbackType.SubReason import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.FEEDBACK_NEGATIVE_SUBMISSION import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.common.utils.extensions.toBinaryString import com.duckduckgo.experiments.api.VariantManager -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -66,7 +63,6 @@ class FireAndForgetFeedbackSubmitter( private val appCoroutineScope: CoroutineScope, private val appBuildConfig: AppBuildConfig, private val dispatcherProvider: DispatcherProvider, - private val loadingBarExperimentManager: LoadingBarExperimentManager, ) : FeedbackSubmitter { override suspend fun sendNegativeFeedback( mainReason: MainReason, @@ -139,16 +135,7 @@ class FireAndForgetFeedbackSubmitter( private fun sendPixel(pixelName: String) { Timber.d("Firing feedback pixel: $pixelName") - - // Loading Bar Experiment - if (loadingBarExperimentManager.isExperimentEnabled()) { - pixel.fire( - pixelName, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - ) - } else { - pixel.fire(pixelName) - } + pixel.fire(pixelName) } private suspend fun submitFeedback( @@ -171,11 +158,6 @@ class FireAndForgetFeedbackSubmitter( model = Build.MODEL, api = appBuildConfig.sdkInt, atb = atbWithVariant(), - loadingBarExperiment = if (loadingBarExperimentManager.isExperimentEnabled()) { - loadingBarExperimentManager.variant.toBinaryString() - } else { - null - }, ) } diff --git a/app/src/main/java/com/duckduckgo/app/fire/FireActivity.kt b/app/src/main/java/com/duckduckgo/app/fire/FireActivity.kt index 5143c0d42a15..97dc42580371 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/FireActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/FireActivity.kt @@ -80,7 +80,7 @@ class FireActivity : AppCompatActivity() { context: Context, notifyDataCleared: Boolean = false, ): Intent { - val intent = BrowserActivity.intent(context, notifyDataCleared = notifyDataCleared) + val intent = BrowserActivity.intent(context, notifyDataCleared = notifyDataCleared, isLaunchFromClearDataAction = true) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) return intent } diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsActivity.kt index b3df78f9eba7..4de78cf371cc 100644 --- a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsActivity.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.generalsettings import android.os.Bundle +import android.view.View.OnClickListener import android.widget.CompoundButton import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle @@ -24,7 +25,16 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.ActivityGeneralSettingsBinding +import com.duckduckgo.app.generalsettings.GeneralSettingsViewModel.Command +import com.duckduckgo.app.generalsettings.GeneralSettingsViewModel.Command.LaunchShowOnAppLaunchScreen +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchScreenNoParams +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.global.view.fadeTransitionConfig import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope @@ -55,6 +65,10 @@ class GeneralSettingsActivity : DuckDuckGoActivity() { viewModel.onVoiceSearchChanged(isChecked) } + private val showOnAppLaunchClickListener = OnClickListener { + viewModel.onShowOnAppLaunchButtonClick() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -69,6 +83,7 @@ class GeneralSettingsActivity : DuckDuckGoActivity() { binding.autocompleteToggle.setOnCheckedChangeListener(autocompleteToggleListener) binding.autocompleteRecentlyVisitedSitesToggle.setOnCheckedChangeListener(autocompleteRecentlyVisitedSitesToggleListener) binding.voiceSearchToggle.setOnCheckedChangeListener(voiceSearchChangeListener) + binding.showOnAppLaunchButton.setOnClickListener(showOnAppLaunchClickListener) } private fun observeViewModel() { @@ -94,7 +109,32 @@ class GeneralSettingsActivity : DuckDuckGoActivity() { binding.voiceSearchToggle.isVisible = true binding.voiceSearchToggle.quietlySetIsChecked(viewState.voiceSearchEnabled, voiceSearchChangeListener) } + + binding.showOnAppLaunchButton.isVisible = it.isShowOnAppLaunchOptionVisible + setShowOnAppLaunchOptionSecondaryText(viewState.showOnAppLaunchSelectedOption) } }.launchIn(lifecycleScope) + + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun setShowOnAppLaunchOptionSecondaryText(showOnAppLaunchOption: ShowOnAppLaunchOption) { + val optionString = when (showOnAppLaunchOption) { + is LastOpenedTab -> getString(R.string.showOnAppLaunchOptionLastOpenedTab) + is NewTabPage -> getString(R.string.showOnAppLaunchOptionNewTabPage) + is SpecificPage -> showOnAppLaunchOption.url + } + binding.showOnAppLaunchButton.setSecondaryText(optionString) + } + + private fun processCommand(command: Command) { + when (command) { + LaunchShowOnAppLaunchScreen -> { + globalActivityStarter.start(this, ShowOnAppLaunchScreenNoParams, fadeTransitionConfig()) + } + } } } diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt index c859b2532060..3e7a786074fc 100644 --- a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt @@ -19,6 +19,10 @@ package com.duckduckgo.app.generalsettings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_GENERAL_SETTINGS_TOGGLED_OFF import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_GENERAL_SETTINGS_TOGGLED_ON import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_RECENT_SITES_GENERAL_SETTINGS_TOGGLED_OFF @@ -33,8 +37,15 @@ import com.duckduckgo.voice.impl.VoiceSearchPixelNames.VOICE_SEARCH_GENERAL_SETT import com.duckduckgo.voice.impl.VoiceSearchPixelNames.VOICE_SEARCH_GENERAL_SETTINGS_ON import com.duckduckgo.voice.store.VoiceSearchRepository import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -46,6 +57,8 @@ class GeneralSettingsViewModel @Inject constructor( private val voiceSearchAvailability: VoiceSearchAvailability, private val voiceSearchRepository: VoiceSearchRepository, private val dispatcherProvider: DispatcherProvider, + private val showOnAppLaunchFeature: ShowOnAppLaunchFeature, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, ) : ViewModel() { data class ViewState( @@ -54,11 +67,20 @@ class GeneralSettingsViewModel @Inject constructor( val storeHistoryEnabled: Boolean, val showVoiceSearch: Boolean, val voiceSearchEnabled: Boolean, + val isShowOnAppLaunchOptionVisible: Boolean, + val showOnAppLaunchSelectedOption: ShowOnAppLaunchOption, ) + sealed class Command { + data object LaunchShowOnAppLaunchScreen : Command() + } + private val _viewState = MutableStateFlow(null) val viewState = _viewState.asStateFlow() + private val _commands = Channel(1, BufferOverflow.DROP_OLDEST) + val commands = _commands.receiveAsFlow() + init { viewModelScope.launch(dispatcherProvider.io()) { val autoCompleteEnabled = settingsDataStore.autoCompleteSuggestionsEnabled @@ -71,8 +93,12 @@ class GeneralSettingsViewModel @Inject constructor( storeHistoryEnabled = history.isHistoryFeatureAvailable(), showVoiceSearch = voiceSearchAvailability.isVoiceSearchSupported, voiceSearchEnabled = voiceSearchAvailability.isVoiceSearchAvailable, + isShowOnAppLaunchOptionVisible = showOnAppLaunchFeature.self().isEnabled(), + showOnAppLaunchSelectedOption = showOnAppLaunchOptionDataStore.optionFlow.first(), ) } + + observeShowOnAppLaunchOption() } fun onAutocompleteSettingChanged(enabled: Boolean) { @@ -119,4 +145,22 @@ class GeneralSettingsViewModel @Inject constructor( _viewState.value = _viewState.value?.copy(voiceSearchEnabled = voiceSearchAvailability.isVoiceSearchAvailable) } } + + fun onShowOnAppLaunchButtonClick() { + sendCommand(Command.LaunchShowOnAppLaunchScreen) + pixel.fire(AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_PRESSED) + } + + private fun observeShowOnAppLaunchOption() { + showOnAppLaunchOptionDataStore.optionFlow + .onEach { showOnAppLaunchOption -> + _viewState.update { it!!.copy(showOnAppLaunchSelectedOption = showOnAppLaunchOption) } + }.launchIn(viewModelScope) + } + + private fun sendCommand(newCommand: Command) { + viewModelScope.launch { + _commands.send(newCommand) + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchActivity.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchActivity.kt new file mode 100644 index 000000000000..dcece1cf5032 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchActivity.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import android.os.Bundle +import android.view.MenuItem +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.databinding.ActivityShowOnAppLaunchSettingBinding +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(ShowOnAppLaunchScreenNoParams::class) +class ShowOnAppLaunchActivity : DuckDuckGoActivity() { + + private val viewModel: ShowOnAppLaunchViewModel by bindViewModel() + private val binding: ActivityShowOnAppLaunchSettingBinding by viewBinding() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setupToolbar(binding.includeToolbar.toolbar) + + binding.specificPageUrlInput.setSelectAllOnFocus(true) + + configureUiEventHandlers() + observeViewModel() + } + + override fun onPause() { + super.onPause() + viewModel.setSpecificPageUrl(binding.specificPageUrlInput.text) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun configureUiEventHandlers() { + binding.lastOpenedTabCheckListItem.setClickListener { + viewModel.onShowOnAppLaunchOptionChanged(LastOpenedTab) + } + + binding.newTabCheckListItem.setClickListener { + viewModel.onShowOnAppLaunchOptionChanged(NewTabPage) + } + + binding.specificPageCheckListItem.setClickListener { + viewModel.onShowOnAppLaunchOptionChanged(SpecificPage(binding.specificPageUrlInput.text)) + } + + binding.specificPageUrlInput.addFocusChangedListener { _, hasFocus -> + if (hasFocus) { + viewModel.onShowOnAppLaunchOptionChanged( + SpecificPage(binding.specificPageUrlInput.text), + ) + } + } + } + + private fun observeViewModel() { + viewModel.viewState + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .onEach { viewState -> + when (viewState.selectedOption) { + LastOpenedTab -> { + uncheckNewTabCheckListItem() + uncheckSpecificPageCheckListItem() + binding.lastOpenedTabCheckListItem.setChecked(true) + } + NewTabPage -> { + uncheckLastOpenedTabCheckListItem() + uncheckSpecificPageCheckListItem() + binding.newTabCheckListItem.setChecked(true) + } + is SpecificPage -> { + uncheckLastOpenedTabCheckListItem() + uncheckNewTabCheckListItem() + with(binding) { + specificPageCheckListItem.setChecked(true) + specificPageUrlInput.isEnabled = true + } + } + } + + if (binding.specificPageUrlInput.text.isBlank()) { + binding.specificPageUrlInput.text = viewState.specificPageUrl + } + } + .launchIn(lifecycleScope) + } + + private fun uncheckLastOpenedTabCheckListItem() { + binding.lastOpenedTabCheckListItem.setChecked(false) + } + + private fun uncheckNewTabCheckListItem() { + binding.newTabCheckListItem.setChecked(false) + } + + private fun uncheckSpecificPageCheckListItem() { + binding.specificPageCheckListItem.setChecked(false) + binding.specificPageUrlInput.isEnabled = false + } +} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentFeature.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchFeature.kt similarity index 81% rename from experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentFeature.kt rename to app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchFeature.kt index 1708e0e51039..a89d1a91ea32 100644 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentFeature.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchFeature.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.experiments.impl.loadingbarexperiment +package com.duckduckgo.app.generalsettings.showonapplaunch import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.di.scopes.AppScope @@ -22,12 +22,10 @@ import com.duckduckgo.feature.toggles.api.Toggle @ContributesRemoteFeature( scope = AppScope::class, - featureName = "loadingBarExp", + featureName = "showOnAppLaunch", ) -interface LoadingBarExperimentFeature { - @Toggle.DefaultValue(false) - fun self(): Toggle +interface ShowOnAppLaunchFeature { @Toggle.DefaultValue(false) - fun allocateVariants(): Toggle + fun self(): Toggle } diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt new file mode 100644 index 000000000000..a0a2a0332018 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import android.net.Uri +import androidx.core.net.toUri +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.isHttpOrHttps +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +interface ShowOnAppLaunchOptionHandler { + suspend fun handleAppLaunchOption() + suspend fun handleResolvedUrlStorage( + currentUrl: String?, + isRootOfTab: Boolean, + tabId: String, + ) +} + +@ContributesBinding(AppScope::class) +class ShowOnAppLaunchOptionHandlerImpl @Inject constructor( + private val dispatchers: DispatcherProvider, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, + private val tabRepository: TabRepository, +) : ShowOnAppLaunchOptionHandler { + + override suspend fun handleAppLaunchOption() { + when (val option = showOnAppLaunchOptionDataStore.optionFlow.first()) { + LastOpenedTab -> Unit + NewTabPage -> tabRepository.add() + is SpecificPage -> handleSpecificPageOption(option) + } + } + + override suspend fun handleResolvedUrlStorage( + currentUrl: String?, + isRootOfTab: Boolean, + tabId: String, + ) { + withContext(dispatchers.io()) { + val shouldSaveCurrentUrlForShowOnAppLaunch = currentUrl != null && + isRootOfTab && + tabId == showOnAppLaunchOptionDataStore.showOnAppLaunchTabId + + if (shouldSaveCurrentUrlForShowOnAppLaunch) { + showOnAppLaunchOptionDataStore.setResolvedPageUrl(currentUrl!!) + } + } + } + + private suspend fun handleSpecificPageOption(option: SpecificPage) { + val userUri = option.url.toUri() + val resolvedUri = option.resolvedUrl?.toUri() + + val urls = listOfNotNull(userUri, resolvedUri).map { uri -> + stripIfHttpOrHttps(uri) + } + + val tabIdUrlMap = getTabIdUrlMap(tabRepository.flowTabs.first()) + + val existingTabId = tabIdUrlMap.entries.findLast { it.value in urls }?.key + + if (existingTabId != null) { + showOnAppLaunchOptionDataStore.setShowOnAppLaunchTabId(existingTabId) + tabRepository.select(existingTabId) + } else { + val tabId = tabRepository.add(url = option.url) + showOnAppLaunchOptionDataStore.setShowOnAppLaunchTabId(tabId) + } + } + + private fun stripIfHttpOrHttps(uri: Uri): String { + return if (uri.isHttpOrHttps) { + stripUri(uri) + } else { + uri.toString() + } + } + + private fun stripUri(uri: Uri): String = uri.run { + val authority = uri.authority?.removePrefix("www.") + uri.buildUpon() + .scheme(null) + .authority(authority) + .toString() + .replaceFirst("//", "") + } + + private fun getTabIdUrlMap(tabs: List): Map { + return tabs + .filterNot { tab -> tab.url.isNullOrBlank() } + .associate { tab -> + val tabUri = tab.url!!.toUri() + val strippedUrl = stripIfHttpOrHttps(tabUri) + tab.tabId to strippedUrl + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchScreens.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchScreens.kt new file mode 100644 index 000000000000..7bc928f69542 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchScreens.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams + +/** + * Use this model to launch the Show On App Launch screen + */ +object ShowOnAppLaunchScreenNoParams : ActivityParams diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPlugin.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPlugin.kt new file mode 100644 index 000000000000..2bfcbbac34f7 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPlugin.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.statistics.api.BrowserFeatureStateReporterPlugin +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +interface ShowOnAppLaunchReporterPlugin + +@ContributesMultibinding( + scope = AppScope::class, + boundType = BrowserFeatureStateReporterPlugin::class, +) +@ContributesBinding(scope = AppScope::class, boundType = ShowOnAppLaunchReporterPlugin::class) +class ShowOnAppLaunchStateReporterPlugin +@Inject +constructor( + private val dispatcherProvider: DispatcherProvider, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, +) : ShowOnAppLaunchReporterPlugin, BrowserFeatureStateReporterPlugin { + + override fun featureStateParams(): Map { + val option = + runBlocking(dispatcherProvider.io()) { + showOnAppLaunchOptionDataStore.optionFlow.first() + } + val dailyPixelValue = ShowOnAppLaunchOption.getDailyPixelValue(option) + return mapOf(PixelParameter.LAUNCH_SCREEN to dailyPixelValue) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImpl.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImpl.kt new file mode 100644 index 000000000000..bb218d89505a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImpl.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import android.net.Uri +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore + +class ShowOnAppLaunchUrlConverterImpl : UrlConverter { + + override fun convertUrl(url: String?): String { + if (url.isNullOrBlank()) return ShowOnAppLaunchOptionDataStore.DEFAULT_SPECIFIC_PAGE_URL + + val uri = Uri.parse(url.trim()) + + val uriWithScheme = if (uri.scheme == null) { + Uri.Builder() + .scheme("http") + .authority(uri.path?.lowercase()) + } else { + uri.buildUpon() + .scheme(uri.scheme?.lowercase()) + .authority(uri.authority?.lowercase()) + } + .apply { + query(uri.query) + fragment(uri.fragment) + } + + val uriWithPath = if (uri.path.isNullOrBlank()) { + uriWithScheme.path("/") + } else { + uriWithScheme + } + + val processedUrl = uriWithPath.build().toString() + + return Uri.decode(processedUrl) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModel.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModel.kt new file mode 100644 index 000000000000..965d3cdc3dcc --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModel.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch +import timber.log.Timber + +@ContributesViewModel(ActivityScope::class) +class ShowOnAppLaunchViewModel @Inject constructor( + private val dispatcherProvider: DispatcherProvider, + private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, + private val urlConverter: UrlConverter, + private val pixel: Pixel, +) : ViewModel() { + + data class ViewState( + val selectedOption: ShowOnAppLaunchOption, + val specificPageUrl: String, + ) + + private val _viewState = MutableStateFlow(null) + val viewState = _viewState.asStateFlow().filterNotNull() + + init { + observeShowOnAppLaunchOptionChanges() + } + + private fun observeShowOnAppLaunchOptionChanges() { + combine( + showOnAppLaunchOptionDataStore.optionFlow, + showOnAppLaunchOptionDataStore.specificPageUrlFlow, + ) { option, specificPageUrl -> + _viewState.value = ViewState(option, specificPageUrl) + }.flowOn(dispatcherProvider.io()) + .launchIn(viewModelScope) + } + + fun onShowOnAppLaunchOptionChanged(option: ShowOnAppLaunchOption) { + Timber.i("User changed show on app launch option to $option") + viewModelScope.launch(dispatcherProvider.io()) { + firePixel(option) + showOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(option) + } + } + + fun setSpecificPageUrl(url: String) { + Timber.i("Setting specific page url to $url") + viewModelScope.launch(dispatcherProvider.io()) { + val convertedUrl = urlConverter.convertUrl(url) + showOnAppLaunchOptionDataStore.setSpecificPageUrl(convertedUrl) + } + } + + private fun firePixel(option: ShowOnAppLaunchOption) { + val pixelName = ShowOnAppLaunchOption.getPixelName(option) + pixel.fire(pixelName) + } +} diff --git a/experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/loadingbarexperiment/LoadingBarExperimentManager.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/UrlConverter.kt similarity index 72% rename from experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/loadingbarexperiment/LoadingBarExperimentManager.kt rename to app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/UrlConverter.kt index 2c91958920f9..87703ab36a73 100644 --- a/experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/loadingbarexperiment/LoadingBarExperimentManager.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/UrlConverter.kt @@ -14,11 +14,9 @@ * limitations under the License. */ -package com.duckduckgo.experiments.api.loadingbarexperiment +package com.duckduckgo.app.generalsettings.showonapplaunch -interface LoadingBarExperimentManager { - fun isExperimentEnabled(): Boolean - suspend fun update() - val variant: Boolean - val shouldSendUriLoadedPixel: Boolean +interface UrlConverter { + + fun convertUrl(url: String?): String } diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/model/ShowOnAppLaunchOption.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/model/ShowOnAppLaunchOption.kt new file mode 100644 index 000000000000..4552c471cfbf --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/model/ShowOnAppLaunchOption.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.model + +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED + +sealed class ShowOnAppLaunchOption(val id: Int) { + + data object LastOpenedTab : ShowOnAppLaunchOption(1) + data object NewTabPage : ShowOnAppLaunchOption(2) + data class SpecificPage(val url: String, val resolvedUrl: String? = null) : ShowOnAppLaunchOption(3) + + companion object { + + fun mapToOption(id: Int): ShowOnAppLaunchOption = when (id) { + 1 -> LastOpenedTab + 2 -> NewTabPage + 3 -> SpecificPage("") + else -> throw IllegalArgumentException("Unknown id: $id") + } + + fun getPixelName(option: ShowOnAppLaunchOption) = when (option) { + LastOpenedTab -> SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED + NewTabPage -> SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED + is SpecificPage -> SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED + } + + fun getDailyPixelValue(option: ShowOnAppLaunchOption) = when (option) { + LastOpenedTab -> "last_opened_tab" + NewTabPage -> "new_tab_page" + is SpecificPage -> "specific_page" + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchDataStoreModule.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchDataStoreModule.kt new file mode 100644 index 000000000000..291efa1fe04a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchDataStoreModule.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.store + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchUrlConverterImpl +import com.duckduckgo.app.generalsettings.showonapplaunch.UrlConverter +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import javax.inject.Qualifier + +@ContributesTo(AppScope::class) +@Module +object ShowOnAppLaunchDataStoreModule { + + private val Context.showOnAppLaunchDataStore: DataStore by preferencesDataStore( + name = "show_on_app_launch", + ) + + @Provides + @ShowOnAppLaunch + fun showOnAppLaunchDataStore(context: Context): DataStore = context.showOnAppLaunchDataStore + + @Provides + fun showOnAppLaunchUrlConverter(): UrlConverter = ShowOnAppLaunchUrlConverterImpl() +} + +@Qualifier +annotation class ShowOnAppLaunch diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchOptionDataStore.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchOptionDataStore.kt new file mode 100644 index 000000000000..3afb3f017cd7 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchOptionDataStore.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore.Companion.DEFAULT_SPECIFIC_PAGE_URL +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface ShowOnAppLaunchOptionDataStore { + val optionFlow: Flow + val specificPageUrlFlow: Flow + val showOnAppLaunchTabId: String? + + fun setShowOnAppLaunchTabId(tabId: String) + suspend fun setShowOnAppLaunchOption(showOnAppLaunchOption: ShowOnAppLaunchOption) + suspend fun setSpecificPageUrl(url: String) + suspend fun setResolvedPageUrl(url: String) + + companion object { + const val DEFAULT_SPECIFIC_PAGE_URL = "https://duckduckgo.com/" + } +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class ShowOnAppLaunchOptionPrefsDataStore @Inject constructor( + @ShowOnAppLaunch private val store: DataStore, +) : ShowOnAppLaunchOptionDataStore { + + override var showOnAppLaunchTabId: String? = null + private set + + override val optionFlow: Flow = store.data.map { preferences -> + preferences[intPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_OPTION)]?.let { optionId -> + when (val option = ShowOnAppLaunchOption.mapToOption(optionId)) { + LastOpenedTab, + NewTabPage, + -> option + is SpecificPage -> { + val url = preferences[stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL)]!! + val resolvedUrl = preferences[stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL)] + SpecificPage(url, resolvedUrl) + } + } + } ?: LastOpenedTab + } + + override val specificPageUrlFlow: Flow = store.data.map { preferences -> + preferences[stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL)] ?: DEFAULT_SPECIFIC_PAGE_URL + } + + override suspend fun setShowOnAppLaunchOption(showOnAppLaunchOption: ShowOnAppLaunchOption) { + store.edit { preferences -> + preferences[intPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_OPTION)] = showOnAppLaunchOption.id + + if (showOnAppLaunchOption is SpecificPage) { + preferences.setShowOnAppLaunch(showOnAppLaunchOption.url) + preferences.remove(stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL)) + showOnAppLaunchTabId = null + } + } + } + + override fun setShowOnAppLaunchTabId(tabId: String) { + showOnAppLaunchTabId = tabId + } + + override suspend fun setSpecificPageUrl(url: String) { + store.edit { preferences -> + preferences.setShowOnAppLaunch(url) + } + } + + override suspend fun setResolvedPageUrl(url: String) { + store.edit { preferences -> + preferences.setShowOnAppLaunchResolvedUrl(url) + } + } + + private fun MutablePreferences.setShowOnAppLaunch(url: String) { + set(stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL), url) + } + + private fun MutablePreferences.setShowOnAppLaunchResolvedUrl(url: String) { + set(stringPreferencesKey(KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL), url) + } + + companion object { + private const val KEY_SHOW_ON_APP_LAUNCH_OPTION = "SHOW_ON_APP_LAUNCH_OPTION" + private const val KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL = "SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_URL" + private const val KEY_SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL = "SHOW_ON_APP_LAUNCH_SPECIFIC_PAGE_RESOLVED_URL" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/global/api/PixelParamRemovalInterceptor.kt b/app/src/main/java/com/duckduckgo/app/global/api/PixelParamRemovalInterceptor.kt index 6423faf1cf66..132305e45741 100644 --- a/app/src/main/java/com/duckduckgo/app/global/api/PixelParamRemovalInterceptor.kt +++ b/app/src/main/java/com/duckduckgo/app/global/api/PixelParamRemovalInterceptor.kt @@ -29,6 +29,7 @@ import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelPa import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter.ATB import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter.OS_VERSION import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.site.permissions.impl.SitePermissionsPixelName import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject import okhttp3.Interceptor @@ -86,6 +87,9 @@ object PixelInterceptorPixelsRequiringDataCleaning : PixelParamRemovalPlugin { HttpErrorPixelName.WEBVIEW_RECEIVED_HTTP_ERROR_400_DAILY.pixelName to PixelParameter.removeAtb(), HttpErrorPixelName.WEBVIEW_RECEIVED_HTTP_ERROR_4XX_DAILY.pixelName to PixelParameter.removeAtb(), HttpErrorPixelName.WEBVIEW_RECEIVED_HTTP_ERROR_5XX_DAILY.pixelName to PixelParameter.removeAtb(), + AppPixelName.FEATURES_ENABLED_AT_SEARCH_TIME.pixelName to PixelParameter.removeAll(), + SitePermissionsPixelName.PERMISSION_DIALOG_CLICK.pixelName to PixelParameter.removeAtb(), + SitePermissionsPixelName.PERMISSION_DIALOG_IMPRESSION.pixelName to PixelParameter.removeAtb(), ) } } diff --git a/app/src/main/java/com/duckduckgo/app/global/migrations/LocationPermissionMigrationPlugin.kt b/app/src/main/java/com/duckduckgo/app/global/migrations/LocationPermissionMigrationPlugin.kt new file mode 100644 index 000000000000..4654d8360012 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/migrations/LocationPermissionMigrationPlugin.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.global.migrations + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.location.data.LocationPermissionType.ALLOW_ALWAYS +import com.duckduckgo.app.location.data.LocationPermissionType.DENY_ALWAYS +import com.duckduckgo.app.location.data.LocationPermissionsRepository +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.plugins.migrations.MigrationPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest +import com.duckduckgo.site.permissions.impl.SitePermissionsRepository +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@ContributesMultibinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class LocationPermissionMigrationPlugin @Inject constructor( + private val settingsDataStore: SettingsDataStore, + private val locationPermissionsRepository: LocationPermissionsRepository, + private val sitePermissionsRepository: SitePermissionsRepository, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : MigrationPlugin { + + override val version: Int = 2 + + override fun run() { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!settingsDataStore.appLocationPermissionMigrated) { + sitePermissionsRepository.askLocationEnabled = settingsDataStore.appLocationPermission + Timber.d("Location permissions migrated: location permission set to ${sitePermissionsRepository.askLocationEnabled}") + val locationPermissions = locationPermissionsRepository.getLocationPermissionsSync() + val alwaysAllowedPermissions = locationPermissions.filter { it.permission == ALLOW_ALWAYS } + val alwaysDeniedPermissions = locationPermissions.filter { it.permission == DENY_ALWAYS } + alwaysAllowedPermissions.forEach { permission -> + sitePermissionsRepository.sitePermissionPermanentlySaved( + permission.domain, + LocationPermissionRequest.RESOURCE_LOCATION_PERMISSION, + SitePermissionAskSettingType.ALLOW_ALWAYS, + ) + } + alwaysDeniedPermissions.forEach { permission -> + sitePermissionsRepository.sitePermissionPermanentlySaved( + permission.domain, + LocationPermissionRequest.RESOURCE_LOCATION_PERMISSION, + SitePermissionAskSettingType.DENY_ALWAYS, + ) + } + settingsDataStore.appLocationPermissionMigrated = true + Timber.d("Location permissions migrated: ALLOW ALWAYS ${alwaysAllowedPermissions.size} DENY ALWAYS ${alwaysDeniedPermissions.size}.") + } + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/global/migrations/MigrationLifecycleObserver.kt b/app/src/main/java/com/duckduckgo/app/global/migrations/MigrationLifecycleObserver.kt index dd16f53af51a..31f1b27430fc 100644 --- a/app/src/main/java/com/duckduckgo/app/global/migrations/MigrationLifecycleObserver.kt +++ b/app/src/main/java/com/duckduckgo/app/global/migrations/MigrationLifecycleObserver.kt @@ -51,6 +51,6 @@ class MigrationLifecycleObserver @Inject constructor( } companion object { - const val CURRENT_VERSION = 1 + const val CURRENT_VERSION = 2 } } diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index c8adb9505206..f334fde9ef21 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -144,6 +144,10 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { SETTINGS_PRIVATE_SEARCH_MORE_SEARCH_SETTINGS_PRESSED("ms_private_search_more_search_settings_pressed"), SETTINGS_COOKIE_POPUP_PROTECTION_PRESSED("ms_cookie_popup_protection_setting_pressed"), SETTINGS_FIRE_BUTTON_PRESSED("ms_fire_button_setting_pressed"), + SETTINGS_GENERAL_APP_LAUNCH_PRESSED("m_settings_general_app_launch_pressed"), + SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED("m_settings_general_app_launch_last_opened_tab_selected"), + SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED("m_settings_general_app_launch_new_tab_page_selected"), + SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED("m_settings_general_app_launch_specific_page_selected"), SURVEY_CTA_SHOWN(pixelName = "mus_cs"), SURVEY_CTA_DISMISSED(pixelName = "mus_cd"), @@ -274,16 +278,6 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { SHORTCUT_ADDED("m_sho_a"), SHORTCUT_OPENED("m_sho_o"), - PRECISE_LOCATION_SYSTEM_DIALOG_ENABLE("m_pc_syd_e"), - PRECISE_LOCATION_SYSTEM_DIALOG_LATER("m_pc_syd_l"), - PRECISE_LOCATION_SYSTEM_DIALOG_NEVER("m_pc_syd_n"), - PRECISE_LOCATION_SETTINGS_LOCATION_PERMISSION_ENABLE("m_pc_s_l_e"), - PRECISE_LOCATION_SETTINGS_LOCATION_PERMISSION_DISABLE("m_pc_s_l_d"), - PRECISE_LOCATION_SITE_DIALOG_ALLOW_ALWAYS("m_pc_sd_aa"), - PRECISE_LOCATION_SITE_DIALOG_ALLOW_ONCE("m_pc_sd_ao"), - PRECISE_LOCATION_SITE_DIALOG_DENY_ALWAYS("m_pc_sd_da"), - PRECISE_LOCATION_SITE_DIALOG_DENY_ONCE("m_pc_sd_do"), - FIRE_DIALOG_PROMOTED_CLEAR_PRESSED("m_fdp_p"), FIRE_DIALOG_CLEAR_PRESSED("m_fd_p"), FIRE_DIALOG_CANCEL("m_fd_c"), @@ -374,4 +368,6 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { URI_LOADED("m_uri_loaded"), ERROR_PAGE_SHOWN("m_errorpageshown"), + + FEATURES_ENABLED_AT_SEARCH_TIME("features"), } diff --git a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt index 8c9c22a45b62..cc6328142d2d 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt @@ -75,4 +75,12 @@ interface AndroidBrowserConfigFeature { */ @Toggle.DefaultValue(true) fun errorPagePixel(): Toggle + + /** + * @return `true` when the remote config has the global "featuresRequestHeader" androidBrowserConfig + * sub-feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @Toggle.DefaultValue(false) + fun featuresRequestHeader(): Toggle } diff --git a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt index f643381b048b..d72ff1604765 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt @@ -47,8 +47,20 @@ interface SettingsDataStore { @Deprecated(message = "Not used anymore after adding automatic fireproof", replaceWith = ReplaceWith(expression = "automaticFireproofSetting")) var appLoginDetection: Boolean var automaticFireproofSetting: AutomaticFireproofSetting + + @Deprecated( + message = "Not used anymore after migration to SitePermissionsRepository - https://app.asana.com/0/1174433894299346/1206170291275949/f", + replaceWith = ReplaceWith(expression = "SitePermissionsRepository.askLocationEnabled"), + ) var appLocationPermission: Boolean + + @Deprecated( + message = "Not used anymore after migration to SitePermissionsRepository - https://app.asana.com/0/1174433894299346/1206170291275949/f", + replaceWith = ReplaceWith(expression = "SitePermissionsRepository.askLocationEnabled"), + ) var appLocationPermissionDeniedForever: Boolean + var appLocationPermissionMigrated: Boolean + var globalPrivacyControlEnabled: Boolean var appLinksEnabled: Boolean var showAppLinksPrompt: Boolean @@ -121,6 +133,10 @@ class SettingsSharedPreferences @Inject constructor( get() = preferences.getBoolean(KEY_SYSTEM_LOCATION_PERMISSION_DENIED_FOREVER, false) set(enabled) = preferences.edit { putBoolean(KEY_SYSTEM_LOCATION_PERMISSION_DENIED_FOREVER, enabled) } + override var appLocationPermissionMigrated: Boolean + get() = preferences.getBoolean(KEY_SITE_LOCATION_PERMISSION_MIGRATED, false) + set(enabled) = preferences.edit { putBoolean(KEY_SITE_LOCATION_PERMISSION_MIGRATED, enabled) } + override var appIcon: AppIcon get() { val componentName = preferences.getString(KEY_APP_ICON, defaultIcon().componentName) ?: return defaultIcon() @@ -248,6 +264,7 @@ class SettingsSharedPreferences @Inject constructor( const val KEY_APP_ICON_CHANGED = "APP_ICON_CHANGED" const val KEY_SITE_LOCATION_PERMISSION_ENABLED = "KEY_SITE_LOCATION_PERMISSION_ENABLED" const val KEY_SYSTEM_LOCATION_PERMISSION_DENIED_FOREVER = "KEY_SYSTEM_LOCATION_PERMISSION_DENIED_FOREVER" + const val KEY_SITE_LOCATION_PERMISSION_MIGRATED = "KEY_SITE_LOCATION_PERMISSION_MIGRATED" const val KEY_DO_NOT_SELL_ENABLED = "KEY_DO_NOT_SELL_ENABLED" const val APP_LINKS_ENABLED = "APP_LINKS_ENABLED" const val SHOW_APP_LINKS_PROMPT = "SHOW_APP_LINKS_PROMPT" diff --git a/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsActivity.kt b/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsActivity.kt index 796219e312d5..2ad5d5df52de 100644 --- a/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsActivity.kt @@ -27,7 +27,6 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.ActivitySitePermissionsBinding import com.duckduckgo.app.browser.favicon.FaviconManager -import com.duckduckgo.app.location.data.LocationPermissionEntity import com.duckduckgo.app.sitepermissions.SitePermissionsViewModel.Command import com.duckduckgo.app.sitepermissions.SitePermissionsViewModel.Command.LaunchWebsiteAllowed import com.duckduckgo.app.sitepermissions.SitePermissionsViewModel.Command.ShowRemovedAllConfirmationSnackbar @@ -68,7 +67,7 @@ class SitePermissionsActivity : DuckDuckGoActivity() { viewModel.viewState .flowWithLifecycle(lifecycle, STARTED) .collectLatest { state -> - val sitePermissionsWebsites = viewModel.combineAllPermissions(state.locationPermissionsAllowed, state.sitesPermissionsAllowed) + val sitePermissionsWebsites = state.sitesPermissionsAllowed.map { it.domain } updateList(sitePermissionsWebsites, state.askLocationEnabled, state.askCameraEnabled, state.askMicEnabled, state.askDrmEnabled) } } @@ -81,14 +80,13 @@ class SitePermissionsActivity : DuckDuckGoActivity() { private fun processCommand(command: Command) { when (command) { - is ShowRemovedAllConfirmationSnackbar -> showRemovedAllSnackbar(command.removedSitePermissions, command.removedLocationPermissions) + is ShowRemovedAllConfirmationSnackbar -> showRemovedAllSnackbar(command.removedSitePermissions) is LaunchWebsiteAllowed -> launchWebsiteAllowed(command.domain) } } private fun showRemovedAllSnackbar( removedSitePermissions: List, - removedLocationPermissions: List, ) { val message = HtmlCompat.fromHtml(getString(R.string.sitePermissionsRemoveAllWebsitesSnackbarText), HtmlCompat.FROM_HTML_MODE_LEGACY) Snackbar.make( @@ -96,7 +94,7 @@ class SitePermissionsActivity : DuckDuckGoActivity() { message, Snackbar.LENGTH_LONG, ).setAction(R.string.fireproofWebsiteSnackbarAction) { - viewModel.onSnackBarUndoRemoveAllWebsites(removedSitePermissions, removedLocationPermissions) + viewModel.onSnackBarUndoRemoveAllWebsites(removedSitePermissions) }.show() } diff --git a/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsAdapter.kt b/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsAdapter.kt index 84b408f965b9..0b1b08949e07 100644 --- a/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsAdapter.kt @@ -30,6 +30,7 @@ import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.R.layout import com.duckduckgo.app.browser.databinding.ViewSitePermissionsDescriptionBinding import com.duckduckgo.app.browser.databinding.ViewSitePermissionsEmptyListBinding +import com.duckduckgo.app.browser.databinding.ViewSitePermissionsSiteBinding import com.duckduckgo.app.browser.databinding.ViewSitePermissionsTitleBinding import com.duckduckgo.app.browser.databinding.ViewSitePermissionsToggleBinding import com.duckduckgo.app.browser.favicon.FaviconManager @@ -49,7 +50,6 @@ import com.duckduckgo.common.ui.menu.PopupMenu import com.duckduckgo.common.ui.view.PopupMenuItemView import com.duckduckgo.common.ui.view.divider.HorizontalDivider import com.duckduckgo.common.ui.view.setEnabledOpacity -import com.duckduckgo.mobile.android.databinding.RowOneLineListItemBinding import kotlinx.coroutines.launch class SitePermissionsAdapter( @@ -96,24 +96,29 @@ class SitePermissionsAdapter( val binding = ViewSitePermissionsDescriptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) SitePermissionsSimpleViewHolder(binding) } + HEADER -> { val binding = ViewSitePermissionsTitleBinding.inflate(LayoutInflater.from(parent.context), parent, false) SitePermissionsHeaderViewHolder(binding, LayoutInflater.from(parent.context), viewModel) } + TOGGLE -> { val binding = ViewSitePermissionsToggleBinding.inflate(LayoutInflater.from(parent.context), parent, false) SitePermissionToggleViewHolder(binding) } + DIVIDER -> { val view = HorizontalDivider(parent.context) SitePermissionsDividerViewHolder(view) } + SITES_EMPTY -> { val binding = ViewSitePermissionsEmptyListBinding.inflate(LayoutInflater.from(parent.context), parent, false) SitePermissionsSimpleViewHolder(binding) } + SITE_ALLOWED_ITEM -> { - val binding = RowOneLineListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = ViewSitePermissionsSiteBinding.inflate(LayoutInflater.from(parent.context), parent, false) SiteViewHolder(binding, viewModel, lifecycleOwner, faviconManager) } } @@ -127,6 +132,7 @@ class SitePermissionsAdapter( is SitePermissionToggle -> (holder as SitePermissionToggleViewHolder).bind(item) { _, isChecked -> viewModel.permissionToggleSelected(isChecked, item.text) } + is SiteAllowedItem -> (holder as SiteViewHolder).bind(item) else -> {} } @@ -159,6 +165,7 @@ class SitePermissionsAdapter( setOnClickListener { showOverflowMenu(isListEmpty) } } } + else -> binding.sitePermissionsSectionHeader.showOverflowMenuIcon(false) } binding.sitePermissionsSectionHeader.setText(title) @@ -194,6 +201,7 @@ class SitePermissionsAdapter( R.drawable.ic_location_blocked_24 } } + R.string.sitePermissionsSettingsCamera -> { if (item.enable) { R.drawable.ic_video_24 @@ -201,6 +209,7 @@ class SitePermissionsAdapter( R.drawable.ic_video_blocked_24 } } + R.string.sitePermissionsSettingsMicrophone -> { if (item.enable) { R.drawable.ic_microphone_24 @@ -208,6 +217,7 @@ class SitePermissionsAdapter( R.drawable.ic_microphone_blocked_24 } } + R.string.sitePermissionsSettingsDRM -> { if (item.enable) { R.drawable.ic_video_player_24 @@ -215,6 +225,7 @@ class SitePermissionsAdapter( R.drawable.ic_video_player_blocked_24 } } + else -> null } iconRes?.let { @@ -226,7 +237,7 @@ class SitePermissionsAdapter( } class SiteViewHolder( - private val binding: RowOneLineListItemBinding, + private val binding: ViewSitePermissionsSiteBinding, private val viewModel: SitePermissionsViewModel, private val lifecycleOwner: LifecycleOwner, private val faviconManager: FaviconManager, diff --git a/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsViewModel.kt b/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsViewModel.kt index 7193b41690b9..9289746bf2bd 100644 --- a/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/sitepermissions/SitePermissionsViewModel.kt @@ -20,10 +20,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.browser.R -import com.duckduckgo.app.location.GeoLocationPermissions -import com.duckduckgo.app.location.data.LocationPermissionEntity -import com.duckduckgo.app.location.data.LocationPermissionsRepository -import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.sitepermissions.SitePermissionsViewModel.Command.LaunchWebsiteAllowed import com.duckduckgo.app.sitepermissions.SitePermissionsViewModel.Command.ShowRemovedAllConfirmationSnackbar import com.duckduckgo.common.utils.DispatcherProvider @@ -36,16 +32,12 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @ContributesViewModel(ActivityScope::class) class SitePermissionsViewModel @Inject constructor( private val sitePermissionsRepository: SitePermissionsRepository, - private val locationPermissionsRepository: LocationPermissionsRepository, - private val geolocationPermissions: GeoLocationPermissions, - private val settingsDataStore: SettingsDataStore, private val dispatcherProvider: DispatcherProvider, ) : ViewModel() { @@ -63,20 +55,17 @@ class SitePermissionsViewModel @Inject constructor( val askMicEnabled: Boolean = true, val askDrmEnabled: Boolean = true, val sitesPermissionsAllowed: List = listOf(), - val locationPermissionsAllowed: List = listOf(), ) sealed class Command { - class ShowRemovedAllConfirmationSnackbar( - val removedSitePermissions: List, - val removedLocationPermissions: List, - ) : Command() + class ShowRemovedAllConfirmationSnackbar(val removedSitePermissions: List) : Command() class LaunchWebsiteAllowed(val domain: String) : Command() } init { _viewState.value = ViewState( - askLocationEnabled = settingsDataStore.appLocationPermission, + // askLocationEnabled = settingsDataStore.appLocationPermission, + askLocationEnabled = sitePermissionsRepository.askLocationEnabled, askCameraEnabled = sitePermissionsRepository.askCameraEnabled, askMicEnabled = sitePermissionsRepository.askMicEnabled, askDrmEnabled = sitePermissionsRepository.askDrmEnabled, @@ -85,43 +74,36 @@ class SitePermissionsViewModel @Inject constructor( fun allowedSites() { viewModelScope.launch { - val locationsPermissionsFlow = locationPermissionsRepository.getLocationPermissionsFlow() - val sitePermissionsFlow = sitePermissionsRepository.sitePermissionsWebsitesFlow() - - sitePermissionsFlow.combine(locationsPermissionsFlow) { sitePermissionsList, locationPermissionsList -> - Pair(sitePermissionsList, locationPermissionsList) - }.collect { + sitePermissionsRepository.sitePermissionsWebsitesFlow().collect { _viewState.emit( _viewState.value.copy( - sitesPermissionsAllowed = it.first, - locationPermissionsAllowed = it.second, + sitesPermissionsAllowed = it, ), ) } } } - fun combineAllPermissions(locationPermissions: List, sitePermissions: List): List = - locationPermissions.map { it.domain }.union(sitePermissions.map { it.domain }).toList() - fun permissionToggleSelected( isChecked: Boolean, textRes: Int, ) { when (textRes) { R.string.sitePermissionsSettingsLocation -> { - settingsDataStore.appLocationPermission = isChecked + sitePermissionsRepository.askLocationEnabled = isChecked _viewState.value = _viewState.value.copy(askLocationEnabled = isChecked) - removeLocationSites() } + R.string.sitePermissionsSettingsCamera -> { sitePermissionsRepository.askCameraEnabled = isChecked _viewState.value = _viewState.value.copy(askCameraEnabled = isChecked) } + R.string.sitePermissionsSettingsMicrophone -> { sitePermissionsRepository.askMicEnabled = isChecked _viewState.value = _viewState.value.copy(askMicEnabled = isChecked) } + R.string.sitePermissionsSettingsDRM -> { sitePermissionsRepository.askDrmEnabled = isChecked _viewState.value = _viewState.value.copy(askDrmEnabled = isChecked) @@ -129,12 +111,6 @@ class SitePermissionsViewModel @Inject constructor( } } - private fun removeLocationSites() { - viewModelScope.launch { - geolocationPermissions.clearAll() - } - } - fun allowedSiteSelected(domain: String) { viewModelScope.launch { _commands.send(LaunchWebsiteAllowed(domain)) @@ -143,24 +119,18 @@ class SitePermissionsViewModel @Inject constructor( fun removeAllSitesSelected() { val sitePermissions = _viewState.value.sitesPermissionsAllowed.toMutableList() - val locationPermissions = _viewState.value.locationPermissionsAllowed.toMutableList() viewModelScope.launch(dispatcherProvider.io()) { sitePermissionsRepository.sitePermissionsAllowedFlow().collect { sitePermissionsAllowed -> - geolocationPermissions.clearAll() sitePermissionsRepository.deleteAll() - _commands.send(ShowRemovedAllConfirmationSnackbar(sitePermissions, locationPermissions)) + _commands.send(ShowRemovedAllConfirmationSnackbar(sitePermissions)) cachedAllowedSites = sitePermissionsAllowed } } } - fun onSnackBarUndoRemoveAllWebsites( - removedSitePermissions: List, - removedLocationPermissions: List, - ) { + fun onSnackBarUndoRemoveAllWebsites(removedSitePermissions: List) { viewModelScope.launch(dispatcherProvider.io()) { sitePermissionsRepository.undoDeleteAll(removedSitePermissions, cachedAllowedSites) - geolocationPermissions.undoClearAll(removedLocationPermissions) } } } diff --git a/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/PermissionsPerWebsiteActivity.kt b/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/PermissionsPerWebsiteActivity.kt index b86dcd6230d0..8d2c6512f021 100644 --- a/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/PermissionsPerWebsiteActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/PermissionsPerWebsiteActivity.kt @@ -41,6 +41,7 @@ import com.duckduckgo.common.utils.extensions.websiteFromGeoLocationsApiOrigin import com.duckduckgo.di.scopes.ActivityScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import timber.log.Timber @InjectWith(ActivityScope::class) class PermissionsPerWebsiteActivity : DuckDuckGoActivity() { @@ -135,6 +136,8 @@ class PermissionsPerWebsiteActivity : DuckDuckGoActivity() { override fun onPositiveButtonClicked(selectedItem: Int) { val permissionSettingSelected = selectedItem.getPermissionSettingOptionFromPosition() val newPermissionSetting = WebsitePermissionSetting(currentOption.icon, currentOption.title, permissionSettingSelected) + Timber.d("Permissions: permissionSettingSelected $permissionSettingSelected") + Timber.d("Permissions: newPermissionSetting $newPermissionSetting") viewModel.onPermissionSettingSelected(newPermissionSetting, url) } }, diff --git a/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/PermissionsPerWebsiteViewModel.kt b/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/PermissionsPerWebsiteViewModel.kt index 234ebade639d..8795c02116d2 100644 --- a/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/PermissionsPerWebsiteViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/PermissionsPerWebsiteViewModel.kt @@ -20,16 +20,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.browser.R -import com.duckduckgo.app.location.data.LocationPermissionEntity -import com.duckduckgo.app.location.data.LocationPermissionType -import com.duckduckgo.app.location.data.LocationPermissionsRepository -import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.sitepermissions.permissionsperwebsite.PermissionsPerWebsiteViewModel.Command.GoBackToSitePermissions import com.duckduckgo.app.sitepermissions.permissionsperwebsite.PermissionsPerWebsiteViewModel.Command.ShowPermissionSettingSelectionDialog -import com.duckduckgo.app.sitepermissions.permissionsperwebsite.WebsitePermissionSettingOption.ALLOW import com.duckduckgo.app.sitepermissions.permissionsperwebsite.WebsitePermissionSettingOption.ASK import com.duckduckgo.app.sitepermissions.permissionsperwebsite.WebsitePermissionSettingOption.ASK_DISABLED -import com.duckduckgo.app.sitepermissions.permissionsperwebsite.WebsitePermissionSettingOption.DENY import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.site.permissions.impl.SitePermissionsRepository import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsEntity @@ -40,12 +34,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import timber.log.Timber @ContributesViewModel(ActivityScope::class) class PermissionsPerWebsiteViewModel @Inject constructor( private val sitePermissionsRepository: SitePermissionsRepository, - private val locationPermissionsRepository: LocationPermissionsRepository, - private val settingsDataStore: SettingsDataStore, ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState()) @@ -66,8 +59,9 @@ class PermissionsPerWebsiteViewModel @Inject constructor( fun websitePermissionSettings(url: String) { viewModelScope.launch { val websitePermissionsSettings = sitePermissionsRepository.getSitePermissionsForWebsite(url) - val locationSetting = locationPermissionsRepository.getDomainPermission(url) - val websitePermissions = convertToWebsitePermissionSettings(websitePermissionsSettings, locationSetting) + val websitePermissions = convertToWebsitePermissionSettings(websitePermissionsSettings) + Timber.d("Permissions: websitePermissionsSettings for $url $websitePermissionsSettings") + Timber.d("Permissions: websitePermissions for $url $websitePermissions") _viewState.value = _viewState.value.copy(websitePermissions = websitePermissions) } @@ -75,10 +69,9 @@ class PermissionsPerWebsiteViewModel @Inject constructor( private fun convertToWebsitePermissionSettings( sitePermissionsEntity: SitePermissionsEntity?, - locationPermissionEntity: LocationPermissionEntity?, ): List { - var locationSetting = WebsitePermissionSettingOption.mapToWebsitePermissionSetting(locationPermissionEntity?.permission?.name) - if (locationSetting == ASK && !settingsDataStore.appLocationPermission) { + var locationSetting = WebsitePermissionSettingOption.mapToWebsitePermissionSetting(sitePermissionsEntity?.askLocationSetting) + if (locationSetting == ASK && !sitePermissionsRepository.askLocationEnabled) { locationSetting = ASK_DISABLED } @@ -143,7 +136,10 @@ class PermissionsPerWebsiteViewModel @Inject constructor( } } - fun onPermissionSettingSelected(editedPermissionSetting: WebsitePermissionSetting, url: String) { + fun onPermissionSettingSelected( + editedPermissionSetting: WebsitePermissionSetting, + url: String, + ) { var askLocationSetting = viewState.value.websitePermissions[0].setting var askCameraSetting = viewState.value.websitePermissions[1].setting var askMicSetting = viewState.value.websitePermissions[2].setting @@ -151,55 +147,46 @@ class PermissionsPerWebsiteViewModel @Inject constructor( when (editedPermissionSetting.title) { R.string.sitePermissionsSettingsLocation -> { - askLocationSetting = when (editedPermissionSetting.setting == ASK && !settingsDataStore.appLocationPermission) { + askLocationSetting = when (editedPermissionSetting.setting == ASK && !sitePermissionsRepository.askLocationEnabled) { true -> ASK_DISABLED false -> editedPermissionSetting.setting } - updateLocationSetting(editedPermissionSetting.setting, url) } + R.string.sitePermissionsSettingsCamera -> { askCameraSetting = when (editedPermissionSetting.setting == ASK && !sitePermissionsRepository.askCameraEnabled) { true -> ASK_DISABLED false -> editedPermissionSetting.setting } - updateSitePermissionsSetting(askCameraSetting, askMicSetting, askDrmSetting, url) } + R.string.sitePermissionsSettingsMicrophone -> { askMicSetting = when (editedPermissionSetting.setting == ASK && !sitePermissionsRepository.askMicEnabled) { true -> ASK_DISABLED false -> editedPermissionSetting.setting } - updateSitePermissionsSetting(askCameraSetting, askMicSetting, askDrmSetting, url) } + R.string.sitePermissionsSettingsDRM -> { askDrmSetting = when (editedPermissionSetting.setting == ASK && !sitePermissionsRepository.askDrmEnabled) { true -> ASK_DISABLED false -> editedPermissionSetting.setting } - updateSitePermissionsSetting(askCameraSetting, askMicSetting, askDrmSetting, url) } } + updateSitePermissionsSetting(askCameraSetting, askMicSetting, askDrmSetting, askLocationSetting, url) + _viewState.value = _viewState.value.copy( websitePermissions = getSettingsList(askLocationSetting, askCameraSetting, askMicSetting, askDrmSetting), ) } - private fun updateLocationSetting(locationSetting: WebsitePermissionSettingOption, url: String) { - val locationPermissionType = when (locationSetting) { - ASK, ASK_DISABLED -> LocationPermissionType.ALLOW_ONCE - DENY -> LocationPermissionType.DENY_ALWAYS - ALLOW -> LocationPermissionType.ALLOW_ALWAYS - } - viewModelScope.launch { - locationPermissionsRepository.savePermission(url, locationPermissionType) - } - } - private fun updateSitePermissionsSetting( askCameraSetting: WebsitePermissionSettingOption, askMicSetting: WebsitePermissionSettingOption, askDrmSetting: WebsitePermissionSettingOption, + askLocationSetting: WebsitePermissionSettingOption, url: String, ) { val sitePermissionsEntity = SitePermissionsEntity( @@ -207,6 +194,7 @@ class PermissionsPerWebsiteViewModel @Inject constructor( askCameraSetting = askCameraSetting.toSitePermissionSettingEntityType().name, askMicSetting = askMicSetting.toSitePermissionSettingEntityType().name, askDrmSetting = askDrmSetting.toSitePermissionSettingEntityType().name, + askLocationSetting = askLocationSetting.toSitePermissionSettingEntityType().name, ) viewModelScope.launch { sitePermissionsRepository.savePermission(sitePermissionsEntity) diff --git a/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/WebsitePermissionSettingOption.kt b/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/WebsitePermissionSettingOption.kt index 84f26e93cb19..819cf306eca1 100644 --- a/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/WebsitePermissionSettingOption.kt +++ b/app/src/main/java/com/duckduckgo/app/sitepermissions/permissionsperwebsite/WebsitePermissionSettingOption.kt @@ -53,13 +53,7 @@ enum class WebsitePermissionSettingOption( } fun Int.getPermissionSettingOptionFromPosition(): WebsitePermissionSettingOption { - var option = ASK - values().forEach { - if (it.order == this) { - option = it - } - } - return option + return entries.first { it.order == this } } } } diff --git a/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyViewModel.kt b/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyViewModel.kt index 6dc18e38d0f6..0c27cb18fbbf 100644 --- a/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyViewModel.kt @@ -31,7 +31,6 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.SingleLiveEvent import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import javax.inject.Inject import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -45,7 +44,6 @@ class SurveyViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val appDaysUsedRepository: AppDaysUsedRepository, private val surveyRepository: SurveyRepository, - private val loadingBarExperimentManager: LoadingBarExperimentManager, ) : ViewModel() { sealed class Command { @@ -87,11 +85,6 @@ class SurveyViewModel @Inject constructor( .appendQueryParameter(SurveyParams.SOURCE, source.name.lowercase()) .appendQueryParameter(SurveyParams.LAST_ACTIVE_DATE, lastActiveDay) - // Loading Bar Experiment - if (loadingBarExperimentManager.isExperimentEnabled()) { - urlBuilder.appendQueryParameter(SurveyParams.COHORT, loadingBarExperimentManager.variant.toString()) - } - return urlBuilder.build().toString() } @@ -133,6 +126,5 @@ class SurveyViewModel @Inject constructor( const val MODEL = "mo" const val LAST_ACTIVE_DATE = "da" const val SOURCE = "src" - const val COHORT = "loading_bar_exp" } } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt index 70f08a2dff60..0338a282c400 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt @@ -179,6 +179,8 @@ class TabDataRepository @Inject constructor( } } + override suspend fun getTabId(url: String): String? = tabsDao.selectTabByUrl(url) + override suspend fun setIsUserNew(isUserNew: Boolean) { if (tabSwitcherDataStore.data.first().userState == UserState.UNKNOWN) { val userState = if (isUserNew) UserState.NEW else UserState.EXISTING @@ -298,6 +300,9 @@ class TabDataRepository @Inject constructor( siteData.clear() } + override suspend fun getSelectedTab(): TabEntity? = + withContext(dispatchers.io()) { tabsDao.selectedTab() } + override suspend fun select(tabId: String) { databaseExecutor().scheduleDirect { val selection = TabSelectionEntity(tabId = tabId) diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockList.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockList.kt index e98dc1d630f5..62d5aa18e97f 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockList.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockList.kt @@ -70,10 +70,7 @@ interface BlockList { } companion object { - const val EXPERIMENT_PREFIX = "tds" - const val TREATMENT_URL = "treatmentUrl" - const val CONTROL_URL = "controlUrl" - const val NEXT_URL = "nextUrl" + internal const val EXPERIMENT_PREFIX = "tds" } } diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPlugin.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPlugin.kt index b527a9844ede..5e6ebfda7dad 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPlugin.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPlugin.kt @@ -20,12 +20,12 @@ import com.duckduckgo.app.global.api.ApiInterceptorPlugin import com.duckduckgo.app.trackerdetection.api.TDS_BASE_URL import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.CONTROL import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.NEXT_URL -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory import com.squareup.anvil.annotations.ContributesMultibinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types import javax.inject.Inject import kotlinx.coroutines.runBlocking import okhttp3.Interceptor @@ -38,8 +38,12 @@ import okhttp3.Response ) class BlockListInterceptorApiPlugin @Inject constructor( private val inventory: FeatureTogglesInventory, + private val moshi: Moshi, ) : Interceptor, ApiInterceptorPlugin { + private val jsonAdapter: JsonAdapter> by lazy { + moshi.adapter(Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)) + } override fun intercept(chain: Chain): Response { val request = chain.request().newBuilder() val url = chain.request().url @@ -51,11 +55,16 @@ class BlockListInterceptorApiPlugin @Inject constructor( } return activeExperiment?.let { + val config = activeExperiment.getSettings()?.let { + runCatching { + jsonAdapter.fromJson(it) + }.getOrDefault(emptyMap()) + } ?: emptyMap() val path = when { - activeExperiment.isEnabled(TREATMENT) -> activeExperiment.getConfig()[TREATMENT_URL] - activeExperiment.isEnabled(CONTROL) -> activeExperiment.getConfig()[CONTROL_URL] - else -> activeExperiment.getConfig()[NEXT_URL] - } ?: chain.proceed(request.build()) + activeExperiment.isEnabled(TREATMENT) -> config["treatmentUrl"] + activeExperiment.isEnabled(CONTROL) -> config["controlUrl"] + else -> config["nextUrl"] + } ?: return chain.proceed(request.build()) chain.proceed(request.url("$TDS_BASE_URL$path").build()) } ?: chain.proceed(request.build()) } diff --git a/app/src/main/res/layout/activity_general_settings.xml b/app/src/main/res/layout/activity_general_settings.xml index 1677248f89e5..698f152bc700 100644 --- a/app/src/main/res/layout/activity_general_settings.xml +++ b/app/src/main/res/layout/activity_general_settings.xml @@ -69,6 +69,17 @@ app:secondaryText="@string/accessibilityVoiceSearchSubtitle" app:showSwitch="true" /> + + + + diff --git a/app/src/main/res/layout/activity_show_on_app_launch_setting.xml b/app/src/main/res/layout/activity_show_on_app_launch_setting.xml new file mode 100644 index 000000000000..bc42e01d64c0 --- /dev/null +++ b/app/src/main/res/layout/activity_show_on_app_launch_setting.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_site_permissions_site.xml b/app/src/main/res/layout/view_site_permissions_site.xml new file mode 100644 index 000000000000..f84397a003cd --- /dev/null +++ b/app/src/main/res/layout/view_site_permissions_site.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index b0e9757a2bdd..82b1ef4beb1e 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -811,4 +811,10 @@ Най-горе Отдолу + + Показване при стартиране на приложението + Последно отворен раздел + Страница с нов раздел + Конкретна страница + \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ed937aa9ced7..2881a8d89b96 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -815,4 +815,10 @@ Nahoru Dole + + Zobrazit při spuštění aplikace + Naposledy otevřená karta + Stránka Nová karta + Konkrétní stránka + \ No newline at end of file diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 607be923a46b..4b691ab38862 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -811,4 +811,10 @@ Top Nederst + + Vis ved app-start + Sidst åbnede fane + Ny faneside + Specifik side + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 674e1d021ea7..7df22371ecbc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -811,4 +811,10 @@ Nach oben Unten + + Beim App-Start anzeigen + Zuletzt geöffneter Tab + Neue Tab-Seite + Bestimmte Seite + \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index e1fee4e88f50..5240e9eef78d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -811,4 +811,10 @@ Κορυφή Κάτω μέρος + + Εμφάνιση στην Εκκίνηση εφαρμογής + Τελευταία καρτέλα που άνοιξε + Σελίδα νέας καρτέλας + Συγκεκριμένη σελίδα + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b120de8f9b38..3306e8ef0199 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -811,4 +811,10 @@ Arriba Inferior + + Mostrar al abrir la aplicación + Última pestaña abierta + Página de nueva pestaña + Página específica + \ No newline at end of file diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 61e2d0ca966f..9c46215d5951 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -811,4 +811,10 @@ Tipp All + + Kuva rakenduse käivitamisel + Viimati avatud vahekaart + Uue vahekaardi leht + Konkreetne lehekülg + \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index f5299b26f811..a7fe8b6158ed 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -811,4 +811,10 @@ Ylös Alareuna + + Näytä sovelluksen käynnistyksen yhteydessä + Viimeksi avattu -välilehti + Uusi välilehti -sivu + Tietty sivu + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 876ae50c22c1..a7785e38d138 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -811,4 +811,10 @@ Haut de page En bas + + Afficher au lancement de l\'application + Dernier onglet ouvert + Nouvelle page d\'onglet + Page spécifique + \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 4f20c914dccf..fd77cc12603d 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -815,4 +815,10 @@ Vrh Dno + + Prikaži pri pokretanju aplikacije + Posljednja otvorena kartica + Nova stranica kartice + Specifična stranica + \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 2d62d7e6158c..a3b8d66c2ade 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -811,4 +811,10 @@ Fel Alul + + Megjelenítés az alkalmazás indításakor + Utoljára megnyitott lap + „Új lap” oldal + Speciális oldal + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 57e6ec6f113c..c9bc92394bbc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -811,4 +811,10 @@ Inizio Parte inferiore + + Mostra all\'avvio dell\'app + Ultima scheda aperta + Pagina Nuova scheda + Pagina specifica + \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 46ca48752278..c486898d3899 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -815,4 +815,10 @@ Viršus Apačia + + Rodyti paleidus programą + Paskutinį kartą atidarytas skirtukas + Naujas skirtuko puslapis + Konkretus puslapis + \ No newline at end of file diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index b47ce2ae99fc..c34ba319d874 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -813,4 +813,10 @@ Populārākie Apakšā + + Rādīt lietotnes palaišanas laikā + Pēdējā atvērtā cilne + Jaunas cilnes lapa + Konkrēta lapa + \ No newline at end of file diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 2a1460b7a9bc..f2bf970c846a 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -811,4 +811,10 @@ Topp Nederst + + Vis ved lansering av appen + Sist åpnet fane + Ny faneside + Spesifikk side + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d0e1aa623cde..ad23f880340b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -811,4 +811,10 @@ Boven Onderkant + + Weergeven bij het starten van de app + Laatst geopende tabblad + Nieuwe tabbladpagina + Specifieke pagina + \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0bf30bba0ab3..9285f34da18a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -815,4 +815,10 @@ Do góry Dół + + Pokaż przy uruchomieniu aplikacji + Ostatnio otwarta karta + Strona nowej karty + Określona strona + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 5ac7df500cf3..715ae9406401 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -811,4 +811,10 @@ Topo Parte inferior + + Mostrar ao abrir a aplicação + Último separador aberto + Nova página de separador + Página específica + \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ac29ac240723..5dc535f896fd 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -813,4 +813,10 @@ Sus Partea de jos + + Afișează la lansarea aplicației + Ultima filă deschisă + Filă nouă + Pagină specifică + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 87c4f4fb0606..b30b9e5785a8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -815,4 +815,10 @@ Вверх Внизу + + Показывать при запуске приложения + Последняя открытая вкладка + Страница новой вкладки + Конкретная страница + \ No newline at end of file diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 1607c3f51d5c..662b70f26b20 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -815,4 +815,10 @@ Hore Spodná časť + + Zobraziť pri spustení aplikácie + Naposledy otvorená karta + Stránka na novej karte + Špecifická stránka + \ No newline at end of file diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index b2134e664954..ce3f63ae6e86 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -815,4 +815,10 @@ Vrh Spodaj + + Pokaži ob zagonu aplikacije + Zadnji odprt zavihek + Stran z novim zavihkom + Določena stran + \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 86953f848626..14a8c18d6119 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -811,4 +811,10 @@ Topp Botten + + Visa vid app-start + Senast öppnade flik + Ny fliksida + Specifik sida + \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 169394b55c35..3cb4607d2996 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -811,4 +811,10 @@ Başa dön Alt + + Uygulama Başlatıldığında Göster + Son Açılan Sekme + Yeni Sekme Sayfası + Belirli Bir Sayfa + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f40e7fd16de..db9aef527b48 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -546,8 +546,8 @@ Maybe Later Don\'t Ask Again for This Site Grant %1$s permission to access location? - We only use your anonymous location to deliver better results, closer to you. You can always change your mind later. You can manage the location access permissions you’ve granted to individual sites in Settings. + We only use your anonymous location to deliver better results, closer to you. You can manage the location access permissions you’ve granted to individual sites in Settings. Always Only for This Session Deny Always @@ -672,10 +672,10 @@ No sites yet Permissions Removed for All Sites Permissions for \"%1$s\" - Ask + Ask every time Deny Allow - \"Ask\" disabled for all sites + Disabled for all sites %1$s permission for %2$s @@ -810,4 +810,10 @@ Top Bottom + + Show on App Launch + Last Opened Tab + New Tab Page + Specific Page + \ No newline at end of file diff --git a/app/src/test/java/com/duckduckgo/app/Fakes.kt b/app/src/test/java/com/duckduckgo/app/Fakes.kt index b8720c50c6a1..a882a653e03f 100644 --- a/app/src/test/java/com/duckduckgo/app/Fakes.kt +++ b/app/src/test/java/com/duckduckgo/app/Fakes.kt @@ -58,6 +58,10 @@ class FakeSettingsDataStore : SettingsDataStore { get() = store["appLocationPermissionDeniedForever"] as Boolean? ?: false set(value) { store["appLocationPermissionDeniedForever"] = value } + override var appLocationPermissionMigrated: Boolean + get() = store["appLocationPermissionMigrated"] as Boolean? ?: false + set(value) { store["appLocationPermissionMigrated"] = value } + override var appIcon: AppIcon get() = store["appIcon"] as AppIcon? ?: defaultIcon() set(value) { store["appIcon"] = value } diff --git a/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt index cecbe415cd75..7ad3a645cc86 100644 --- a/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt @@ -31,13 +31,10 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.ui.DuckDuckGoTheme import com.duckduckgo.common.ui.store.AppTheme import com.duckduckgo.common.ui.store.ThemingDataStore -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -69,9 +66,6 @@ internal class AppearanceViewModelTest { @Mock private lateinit var mockAppTheme: AppTheme - @Mock - private lateinit var loadingBarExperimentManager: LoadingBarExperimentManager - private val featureFlag = FakeFeatureToggleFactory.create(ChangeOmnibarPositionFeature::class.java) @Before @@ -82,7 +76,6 @@ internal class AppearanceViewModelTest { whenever(mockThemeSettingsDataStore.theme).thenReturn(DuckDuckGoTheme.SYSTEM_DEFAULT) whenever(mockAppSettingsDataStore.selectedFireAnimation).thenReturn(FireAnimation.HeroFire) whenever(mockAppSettingsDataStore.omnibarPosition).thenReturn(TOP) - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) featureFlag.self().setRawStoredState(Toggle.State(enable = true)) @@ -92,7 +85,6 @@ internal class AppearanceViewModelTest { mockPixel, coroutineTestRule.testDispatcherProvider, featureFlag, - loadingBarExperimentManager, ) } @@ -224,37 +216,6 @@ internal class AppearanceViewModelTest { verify(mockPixel).fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_TOP) } - @Test - fun whenLoadingBarExperimentDisabledAndFeatureFlagEnabledTheOmnibarFeatureIsEnabled() = runTest { - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) - - testee.viewState().test { - val value = awaitItem() - assertTrue(value.isOmnibarPositionFeatureEnabled) - } - } - - @Test - fun whenLoadingBarExperimentDisabledAndFeatureFlagDisabledTheOmnibarFeatureIsDisabled() = runTest { - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) - featureFlag.self().setRawStoredState(Toggle.State(enable = false)) - - testee.viewState().test { - val value = awaitItem() - assertFalse(value.isOmnibarPositionFeatureEnabled) - } - } - - @Test - fun whenLoadingBarExperimentEnabledTheBottomOmnibarIsDisabled() = runTest { - whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - - testee.viewState().test { - val value = awaitItem() - assertFalse(value.isOmnibarPositionFeatureEnabled) - } - } - @Test fun whenInitialisedAndLightThemeThenViewStateEmittedWithProperValues() = runTest { whenever(mockThemeSettingsDataStore.theme).thenReturn(DuckDuckGoTheme.LIGHT) diff --git a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt index 8159ac10d9ba..e2fb9eb372e8 100644 --- a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt +++ b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt @@ -11,8 +11,6 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL import com.duckduckgo.app.trackerdetection.blocklist.FakeFeatureTogglesInventory import com.duckduckgo.app.trackerdetection.blocklist.TestBlockListFeature import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao @@ -43,6 +41,7 @@ import com.duckduckgo.privacy.config.api.PrivacyConfigData import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.UnprotectedTemporary import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.squareup.moshi.Moshi import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.ChronoUnit @@ -68,6 +67,15 @@ class BrokenSiteSubmitterTest { @get:Rule var coroutineRule = CoroutineTestRule() + private val moshi = Moshi.Builder().build() + + private data class Config( + val treatmentUrl: String? = null, + val controlUrl: String? = null, + val nextUrl: String? = null, + ) + private val configAdapter = moshi.adapter(Config::class.java) + private val mockPixel: Pixel = mock() private val mockVariantManager: VariantManager = mock() @@ -605,10 +613,7 @@ class BrokenSiteSubmitterTest { State( remoteEnableState = true, enable = true, - config = mapOf( - TREATMENT_URL to "treatmentUrl", - CONTROL_URL to "controlUrl", - ), + settings = configAdapter.toJson(Config(treatmentUrl = "treatmentUrl", controlUrl = "controlUrl")), assignedCohort = State.Cohort(name = TREATMENT.cohortName, weight = 1, enrollmentDateET = enrollmentDateET), ), ) diff --git a/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt b/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt new file mode 100644 index 000000000000..098a0b6bb611 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt @@ -0,0 +1,77 @@ +package com.duckduckgo.app.browser + +import com.duckduckgo.app.browser.AndroidFeaturesHeaderPlugin.Companion.TEST_VALUE +import com.duckduckgo.app.browser.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature +import com.duckduckgo.feature.toggles.api.Toggle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class AndroidFeaturesHeaderPluginTest { + + private lateinit var testee: AndroidFeaturesHeaderPlugin + + private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() + private val mockAndroidBrowserConfigFeature: AndroidBrowserConfigFeature = mock() + private val mockEnabledToggle: Toggle = mock { on { it.isEnabled() } doReturn true } + private val mockDisabledToggle: Toggle = mock { on { it.isEnabled() } doReturn false } + + @Before + fun setup() { + testee = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mockAndroidBrowserConfigFeature) + } + + @Test + fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureEnabledThenReturnCorrectHeader() { + val url = "duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockEnabledToggle) + + val headers = testee.getHeaders(url) + + assertEquals(TEST_VALUE, headers[X_DUCKDUCKGO_ANDROID_HEADER]) + } + + @Test + fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureDisabledThenReturnEmptyMap() { + val url = "duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockDisabledToggle) + + val headers = testee.getHeaders(url) + + assertTrue(headers.isEmpty()) + } + + @Test + fun whenGetHeadersCalledWithNonDuckDuckGoUrlAndFeatureEnabledThenReturnEmptyMap() { + val url = "non_duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(false) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockEnabledToggle) + + val headers = testee.getHeaders(url) + + assertTrue(headers.isEmpty()) + } + + @Test + fun whenGetHeadersCalledWithNonDuckDuckGoUrlAndFeatureDisabledThenReturnEmptyMap() { + val url = "non_duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(false) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockDisabledToggle) + + val headers = testee.getHeaders(url) + + assertTrue(headers.isEmpty()) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt index acaab6d828d8..5eda04d9d419 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt @@ -23,6 +23,8 @@ import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.fire.DataClearer +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter import com.duckduckgo.app.global.rating.AppEnjoymentPromptOptions import com.duckduckgo.app.global.rating.AppEnjoymentUserEventRecorder @@ -44,7 +46,12 @@ import org.junit.Rule import org.junit.Test import org.mockito.Mock import org.mockito.MockitoAnnotations -import org.mockito.kotlin.* +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class BrowserViewModelTest { @@ -52,34 +59,29 @@ class BrowserViewModelTest { @Suppress("unused") var instantTaskExecutorRule = InstantTaskExecutorRule() - @get:Rule - var coroutinesTestRule = CoroutineTestRule() + @get:Rule var coroutinesTestRule = CoroutineTestRule() - @Mock - private lateinit var mockCommandObserver: Observer + @Mock private lateinit var mockCommandObserver: Observer private val commandCaptor = argumentCaptor() - @Mock - private lateinit var mockTabRepository: TabRepository + @Mock private lateinit var mockTabRepository: TabRepository + + @Mock private lateinit var mockOmnibarEntryConverter: OmnibarEntryConverter + + @Mock private lateinit var mockAutomaticDataClearer: DataClearer - @Mock - private lateinit var mockOmnibarEntryConverter: OmnibarEntryConverter + @Mock private lateinit var mockAppEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder - @Mock - private lateinit var mockAutomaticDataClearer: DataClearer + @Mock private lateinit var mockAppEnjoymentPromptEmitter: AppEnjoymentPromptEmitter - @Mock - private lateinit var mockAppEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder + @Mock private lateinit var mockPixel: Pixel - @Mock - private lateinit var mockAppEnjoymentPromptEmitter: AppEnjoymentPromptEmitter + @Mock private lateinit var mockDefaultBrowserDetector: DefaultBrowserDetector - @Mock - private lateinit var mockPixel: Pixel + @Mock private lateinit var showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler - @Mock - private lateinit var mockDefaultBrowserDetector: DefaultBrowserDetector + private val fakeShowOnAppLaunchFeatureToggle = FakeFeatureToggleFactory.create(ShowOnAppLaunchFeature::class.java) private lateinit var testee: BrowserViewModel @@ -93,17 +95,7 @@ class BrowserViewModelTest { configureSkipUrlConversionInNewTabState(enabled = true) - testee = BrowserViewModel( - tabRepository = mockTabRepository, - queryUrlConverter = mockOmnibarEntryConverter, - dataClearer = mockAutomaticDataClearer, - appEnjoymentPromptEmitter = mockAppEnjoymentPromptEmitter, - appEnjoymentUserEventRecorder = mockAppEnjoymentUserEventRecorder, - defaultBrowserDetector = mockDefaultBrowserDetector, - dispatchers = coroutinesTestRule.testDispatcherProvider, - pixel = mockPixel, - skipUrlConversionOnNewTabFeature = skipUrlConversionOnNewTabFeature, - ) + initTestee() testee.command.observeForever(mockCommandObserver) @@ -276,6 +268,40 @@ class BrowserViewModelTest { verify(mockTabRepository).select(tabId) } + @Test + fun whenHandleShowOnAppLaunchCalledThenNoTabIsAddedByDefault() = runTest { + testee.handleShowOnAppLaunchOption() + + verify(mockTabRepository, never()).add() + verify(mockTabRepository, never()).addFromSourceTab(url = any(), skipHome = any(), sourceTabId = any()) + verify(mockTabRepository, never()).addDefaultTab() + } + + @Test + fun whenShowOnAppLaunchFeatureToggleIsOnThenShowOnAppLaunchHandled() = runTest { + fakeShowOnAppLaunchFeatureToggle.self().setRawStoredState(State(enable = true)) + + testee.handleShowOnAppLaunchOption() + + verify(showOnAppLaunchOptionHandler).handleAppLaunchOption() + } + + private fun initTestee() { + testee = BrowserViewModel( + tabRepository = mockTabRepository, + queryUrlConverter = mockOmnibarEntryConverter, + dataClearer = mockAutomaticDataClearer, + appEnjoymentPromptEmitter = mockAppEnjoymentPromptEmitter, + appEnjoymentUserEventRecorder = mockAppEnjoymentUserEventRecorder, + defaultBrowserDetector = mockDefaultBrowserDetector, + dispatchers = coroutinesTestRule.testDispatcherProvider, + pixel = mockPixel, + skipUrlConversionOnNewTabFeature = skipUrlConversionOnNewTabFeature, + showOnAppLaunchFeature = fakeShowOnAppLaunchFeatureToggle, + showOnAppLaunchOptionHandler = showOnAppLaunchOptionHandler, + ) + } + private fun configureSkipUrlConversionInNewTabState(enabled: Boolean) { skipUrlConversionOnNewTabFeature.self().setRawStoredState(State(enable = enabled)) } diff --git a/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt b/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt index 9f6f3d428571..0d248c24fb00 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt @@ -8,11 +8,8 @@ import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL import com.duckduckgo.app.trackerdetection.blocklist.BlockListPixelsPlugin import com.duckduckgo.app.trackerdetection.blocklist.FakeFeatureTogglesInventory import com.duckduckgo.app.trackerdetection.blocklist.TestBlockListFeature @@ -20,12 +17,12 @@ import com.duckduckgo.app.trackerdetection.blocklist.get2XRefresh import com.duckduckgo.app.trackerdetection.blocklist.get3XRefresh import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.CurrentTimeProvider -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import com.duckduckgo.feature.toggles.api.FakeToggleStore import com.duckduckgo.feature.toggles.api.FeatureToggles import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory +import com.squareup.moshi.Moshi import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.ChronoUnit @@ -50,10 +47,18 @@ class RefreshPixelSenderTest { @get:Rule var coroutineTestRule = CoroutineTestRule() + private val moshi = Moshi.Builder().build() + + private data class Config( + val treatmentUrl: String? = null, + val controlUrl: String? = null, + val nextUrl: String? = null, + ) + private val configAdapter = moshi.adapter(Config::class.java) + private lateinit var db: AppDatabase private lateinit var refreshDao: RefreshDao private val mockPixel: Pixel = mock() - private val mockLoadingBarExperimentManager: LoadingBarExperimentManager = mock() private val mockCurrentTimeProvider: CurrentTimeProvider = mock() private lateinit var testBlockListFeature: TestBlockListFeature private lateinit var inventory: FeatureTogglesInventory @@ -93,7 +98,6 @@ class RefreshPixelSenderTest { testee = DuckDuckGoRefreshPixelSender( pixel = mockPixel, dao = refreshDao, - loadingBarExperimentManager = mockLoadingBarExperimentManager, currentTimeProvider = mockCurrentTimeProvider, appCoroutineScope = coroutineTestRule.testScope, dispatcherProvider = coroutineTestRule.testDispatcherProvider, @@ -107,45 +111,7 @@ class RefreshPixelSenderTest { } @Test - fun whenSendMenuRefreshPixelsAndExperimentEnabledAndIsTestVariantThenTestVariantPixelsFired() { - whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(mockLoadingBarExperimentManager.variant).thenReturn(true) - - testee.sendMenuRefreshPixels() - - verify(mockPixel).fire( - pixel = AppPixelName.MENU_ACTION_REFRESH_PRESSED, - parameters = mapOf(LOADING_BAR_EXPERIMENT to "1"), - ) - verify(mockPixel).fire( - pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, - parameters = mapOf(LOADING_BAR_EXPERIMENT to "1"), - type = Daily(), - ) - } - - @Test - fun whenSendMenuRefreshPixelsAndExperimentEnabledAndIsControlVariantThenControlVariantPixelsFired() { - whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(mockLoadingBarExperimentManager.variant).thenReturn(false) - - testee.sendMenuRefreshPixels() - - verify(mockPixel).fire( - pixel = AppPixelName.MENU_ACTION_REFRESH_PRESSED, - parameters = mapOf(LOADING_BAR_EXPERIMENT to "0"), - ) - verify(mockPixel).fire( - pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, - parameters = mapOf(LOADING_BAR_EXPERIMENT to "0"), - type = Daily(), - ) - } - - @Test - fun whenSendMenuRefreshPixelsAndExperimentDisabledThenDefaultPixelsFired() { - whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) - + fun whenSendMenuRefreshPixelsThenPixelsFired() { testee.sendMenuRefreshPixels() verify(mockPixel).fire( @@ -158,45 +124,7 @@ class RefreshPixelSenderTest { } @Test - fun whenSendPullToRefreshPixelsAndExperimentEnabledAndIsTestVariantThenTestVariantPixelsFired() { - whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(mockLoadingBarExperimentManager.variant).thenReturn(true) - - testee.sendPullToRefreshPixels() - - verify(mockPixel).fire( - pixel = AppPixelName.BROWSER_PULL_TO_REFRESH, - parameters = mapOf(LOADING_BAR_EXPERIMENT to "1"), - ) - verify(mockPixel).fire( - pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, - parameters = mapOf(LOADING_BAR_EXPERIMENT to "1"), - type = Daily(), - ) - } - - @Test - fun whenSendPullToRefreshPixelsAndExperimentEnabledAndIsControlVariantThenControlVariantPixelsFired() { - whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(mockLoadingBarExperimentManager.variant).thenReturn(false) - - testee.sendPullToRefreshPixels() - - verify(mockPixel).fire( - pixel = AppPixelName.BROWSER_PULL_TO_REFRESH, - parameters = mapOf(LOADING_BAR_EXPERIMENT to "0"), - ) - verify(mockPixel).fire( - pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, - parameters = mapOf(LOADING_BAR_EXPERIMENT to "0"), - type = Daily(), - ) - } - - @Test - fun whenSendPullToRefreshPixelsAndExperimentDisabledThenDefaultPixelsFired() { - whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) - + fun whenSendPullToRefreshPixelsThenPixelsFired() { testee.sendPullToRefreshPixels() verify(mockPixel).fire( @@ -222,7 +150,6 @@ class RefreshPixelSenderTest { testee = DuckDuckGoRefreshPixelSender( pixel = mockPixel, dao = mockDao, - loadingBarExperimentManager = mockLoadingBarExperimentManager, currentTimeProvider = mockCurrentTimeProvider, appCoroutineScope = coroutineTestRule.testScope, blockListPixelsPlugin = blockListPixelsPlugin, @@ -241,7 +168,6 @@ class RefreshPixelSenderTest { testee = DuckDuckGoRefreshPixelSender( pixel = mockPixel, dao = mockDao, - loadingBarExperimentManager = mockLoadingBarExperimentManager, currentTimeProvider = mockCurrentTimeProvider, appCoroutineScope = coroutineTestRule.testScope, blockListPixelsPlugin = blockListPixelsPlugin, @@ -260,7 +186,6 @@ class RefreshPixelSenderTest { testee = DuckDuckGoRefreshPixelSender( pixel = mockPixel, dao = mockDao, - loadingBarExperimentManager = mockLoadingBarExperimentManager, currentTimeProvider = mockCurrentTimeProvider, appCoroutineScope = coroutineTestRule.testScope, blockListPixelsPlugin = blockListPixelsPlugin, @@ -387,10 +312,7 @@ class RefreshPixelSenderTest { State( remoteEnableState = true, enable = true, - config = mapOf( - TREATMENT_URL to "treatmentUrl", - CONTROL_URL to "controlUrl", - ), + settings = configAdapter.toJson(Config(treatmentUrl = "treatmentUrl", controlUrl = "controlUrl")), assignedCohort = State.Cohort(name = TREATMENT.cohortName, weight = 1, enrollmentDateET = enrollmentDateET), ), ) diff --git a/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSenderTest.kt b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSenderTest.kt new file mode 100644 index 000000000000..b959c98f3315 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSenderTest.kt @@ -0,0 +1,64 @@ +package com.duckduckgo.app.browser.trafficquality + +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autoconsent.api.Autoconsent +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection +import com.duckduckgo.networkprotection.api.NetworkProtectionState +import com.duckduckgo.privacy.config.api.Gpc +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +class AndroidFeaturesPixelSenderTest { + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val mockAutoconsent = mock() + private val mockGpc = mock() + private val mockAppTrackingProtection = mock() + private val mockNetworkProtectionState = mock() + private val mockPixel = mock() + + private lateinit var pixelSender: AndroidFeaturesPixelSender + + @Before + fun setup() { + pixelSender = AndroidFeaturesPixelSender( + mockAutoconsent, + mockGpc, + mockAppTrackingProtection, + mockNetworkProtectionState, + mockPixel, + coroutineRule.testScope, + coroutineRule.testDispatcherProvider, + ) + } + + @Test + fun reportFeaturesEnabledOrDisabledWhenEnabledOrDisabled() = runTest { + whenever(mockAutoconsent.isAutoconsentEnabled()).thenReturn(false) + whenever(mockGpc.isEnabled()).thenReturn(true) + whenever(mockAppTrackingProtection.isEnabled()).thenReturn(false) + whenever(mockNetworkProtectionState.isEnabled()).thenReturn(true) + + pixelSender.onSearchRetentionAtbRefreshed("v123-1", "v123-2") + + verify(mockPixel).fire( + AppPixelName.FEATURES_ENABLED_AT_SEARCH_TIME, + mapOf( + AndroidFeaturesPixelSender.PARAM_COOKIE_POP_UP_MANAGEMENT_ENABLED to "false", + AndroidFeaturesPixelSender.PARAM_GLOBAL_PRIVACY_CONTROL_ENABLED to "true", + AndroidFeaturesPixelSender.PARAM_APP_TRACKING_PROTECTION_ENABLED to "false", + AndroidFeaturesPixelSender.PARAM_PRIVACY_PRO_VPN_ENABLED to "true", + ), + ) + verifyNoMoreInteractions(mockPixel) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/uriloaded/DuckDuckGoUriLoadedManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/uriloaded/DuckDuckGoUriLoadedManagerTest.kt new file mode 100644 index 000000000000..d5dadcf4bf6e --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/uriloaded/DuckDuckGoUriLoadedManagerTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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 com.duckduckgo.app.browser.uriloaded.DuckDuckGoUriLoadedManager +import com.duckduckgo.app.browser.uriloaded.UriLoadedPixelFeature +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.Toggle +import kotlinx.coroutines.test.TestScope +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.* + +class DuckDuckGoUriLoadedManagerTest { + + private lateinit var testee: DuckDuckGoUriLoadedManager + + private val mockUriLoadedPixelFeature: UriLoadedPixelFeature = mock() + private val mockUriLoadedKillSwitch: Toggle = mock() + private val mockPixel: Pixel = mock() + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + @Before + fun setup() { + whenever(mockUriLoadedPixelFeature.self()).thenReturn(mockUriLoadedKillSwitch) + } + + @Test + fun whenShouldSendUriLoadedPixelEnabledThenSendPixel() { + whenever(mockUriLoadedKillSwitch.isEnabled()).thenReturn(true) + + initialize() + testee.sendUriLoadedPixel() + + verify(mockPixel).fire(AppPixelName.URI_LOADED) + } + + @Test + fun whenShouldSendUriLoadedPixelDisabledThenDoNotSendPixel() { + whenever(mockUriLoadedKillSwitch.isEnabled()).thenReturn(false) + + initialize() + testee.sendUriLoadedPixel() + + verify(mockPixel, never()).fire(AppPixelName.URI_LOADED) + } + + @Test + fun whenPrivacyConfigDownloadedThenUpdateState() { + initialize() + + whenever(mockUriLoadedKillSwitch.isEnabled()).thenReturn(true) + testee.onPrivacyConfigDownloaded() + testee.sendUriLoadedPixel() + + verify(mockPixel).fire(AppPixelName.URI_LOADED) + + reset(mockPixel) + + whenever(mockUriLoadedKillSwitch.isEnabled()).thenReturn(false) + testee.onPrivacyConfigDownloaded() + testee.sendUriLoadedPixel() + + verify(mockPixel, never()).fire(AppPixelName.URI_LOADED) + } + + private fun initialize() { + testee = DuckDuckGoUriLoadedManager( + mockPixel, + mockUriLoadedPixelFeature, + TestScope(), + coroutineTestRule.testDispatcherProvider, + isMainProcess = true, + ) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModelTest.kt index a4aaa1dc9cc2..0f28c3fefddc 100644 --- a/app/src/test/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModelTest.kt @@ -19,14 +19,24 @@ package com.duckduckgo.app.generalsettings import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test import com.duckduckgo.app.FakeSettingsDataStore +import com.duckduckgo.app.generalsettings.GeneralSettingsViewModel.Command.LaunchShowOnAppLaunchScreen +import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_PRESSED import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.voice.api.VoiceSearchAvailability import com.duckduckgo.voice.impl.VoiceSearchPixelNames import com.duckduckgo.voice.store.VoiceSearchRepository import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -49,6 +59,10 @@ internal class GeneralSettingsViewModelTest { private lateinit var fakeAppSettingsDataStore: FakeSettingsDataStore + private lateinit var fakeShowOnAppLaunchOptionDataStore: FakeShowOnAppLaunchOptionDataStore + + private val fakeShowOnAppLaunchFeatureToggle = FakeFeatureToggleFactory.create(ShowOnAppLaunchFeature::class.java) + @Mock private lateinit var mockPixel: Pixel @@ -64,25 +78,20 @@ internal class GeneralSettingsViewModelTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - val dispatcherProvider = coroutineTestRule.testDispatcherProvider + private val dispatcherProvider = coroutineTestRule.testDispatcherProvider @Before fun before() { MockitoAnnotations.openMocks(this) - runTest { + runBlocking { whenever(mockHistory.isHistoryUserEnabled()).thenReturn(true) + whenever(mockHistory.isHistoryFeatureAvailable()).thenReturn(false) fakeAppSettingsDataStore = FakeSettingsDataStore() - testee = GeneralSettingsViewModel( - fakeAppSettingsDataStore, - mockPixel, - mockHistory, - mockVoiceSearchAvailability, - mockVoiceSearchRepository, - dispatcherProvider, - ) + fakeShowOnAppLaunchOptionDataStore = FakeShowOnAppLaunchOptionDataStore() + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(LastOpenedTab) } } @@ -95,6 +104,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteSwitchedOnThenDataStoreIsUpdated() { + initTestee() + testee.onAutocompleteSettingChanged(true) assertTrue(fakeAppSettingsDataStore.autoCompleteSuggestionsEnabled) @@ -102,6 +113,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteSwitchedOffThenDataStoreIsUpdated() { + initTestee() + testee.onAutocompleteSettingChanged(false) assertFalse(fakeAppSettingsDataStore.autoCompleteSuggestionsEnabled) @@ -109,6 +122,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteSwitchedOffThenRecentlyVisitedSitesIsUpdated() = runTest { + initTestee() + testee.onAutocompleteSettingChanged(false) verify(mockHistory).setHistoryUserEnabled(false) @@ -116,6 +131,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteRecentlyVisitedSitesSwitchedOnThenHistoryUpdated() = runTest { + initTestee() + testee.onAutocompleteRecentlyVisitedSitesSettingChanged(true) verify(mockHistory).setHistoryUserEnabled(true) @@ -123,6 +140,8 @@ internal class GeneralSettingsViewModelTest { @Test fun whenAutocompleteRecentlyVisitedSitesSwitchedOffThenHistoryUpdated() = runTest { + initTestee() + whenever(mockHistory.isHistoryUserEnabled()).thenReturn(false) testee.onAutocompleteRecentlyVisitedSitesSettingChanged(false) @@ -132,10 +151,13 @@ internal class GeneralSettingsViewModelTest { @Test fun whenVoiceSearchEnabledThenViewStateEmitted() = runTest { fakeAppSettingsDataStore.autoCompleteSuggestionsEnabled = true + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(LastOpenedTab) whenever(mockVoiceSearchAvailability.isVoiceSearchAvailable).thenReturn(true) val viewState = defaultViewState() + initTestee() + testee.onVoiceSearchChanged(true) testee.viewState.test { @@ -146,33 +168,151 @@ internal class GeneralSettingsViewModelTest { @Test fun whenVoiceSearchEnabledThenSettingsUpdated() = runTest { + initTestee() + testee.onVoiceSearchChanged(true) + verify(mockVoiceSearchRepository).setVoiceSearchUserEnabled(true) } @Test fun whenVoiceSearchDisabledThenSettingsUpdated() = runTest { + initTestee() + testee.onVoiceSearchChanged(false) verify(mockVoiceSearchRepository).setVoiceSearchUserEnabled(false) } @Test fun whenVoiceSearchEnabledThenFirePixel() = runTest { + initTestee() + testee.onVoiceSearchChanged(true) verify(mockPixel).fire(VoiceSearchPixelNames.VOICE_SEARCH_GENERAL_SETTINGS_ON) } @Test fun whenVoiceSearchDisabledThenFirePixel() = runTest { + initTestee() + testee.onVoiceSearchChanged(false) verify(mockPixel).fire(VoiceSearchPixelNames.VOICE_SEARCH_GENERAL_SETTINGS_OFF) } + @Test + fun whenShowOnAppLaunchClickedThenLaunchShowOnAppLaunchScreenCommandEmitted() = runTest { + initTestee() + + testee.onShowOnAppLaunchButtonClick() + + testee.commands.test { + assertEquals(LaunchShowOnAppLaunchScreen, awaitItem()) + } + } + + @Test + fun whenShowOnAppLaunchSetToLastOpenedTabThenShowOnAppLaunchOptionIsLastOpenedTab() = runTest { + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(LastOpenedTab) + + initTestee() + + testee.viewState.test { + assertEquals(LastOpenedTab, awaitItem()?.showOnAppLaunchSelectedOption) + } + } + + @Test + fun whenShowOnAppLaunchSetToNewTabPageThenShowOnAppLaunchOptionIsNewTabPage() = runTest { + initTestee() + + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(NewTabPage) + + testee.viewState.test { + assertEquals(NewTabPage, awaitItem()?.showOnAppLaunchSelectedOption) + } + } + + @Test + fun whenShowOnAppLaunchSetToSpecificPageThenShowOnAppLaunchOptionIsSpecificPage() = runTest { + val specificPage = SpecificPage("example.com") + + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(specificPage) + + initTestee() + + testee.viewState.test { + assertEquals(specificPage, awaitItem()?.showOnAppLaunchSelectedOption) + } + } + + @Test + fun whenShowOnAppLaunchUpdatedThenViewStateIsUpdated() = runTest { + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(LastOpenedTab) + + initTestee() + + testee.viewState.test { + awaitItem() + + fakeShowOnAppLaunchOptionDataStore.setShowOnAppLaunchOption(NewTabPage) + + assertEquals(NewTabPage, awaitItem()?.showOnAppLaunchSelectedOption) + } + } + + @Test + fun whenShowOnAppLaunchClickedThenPixelFiredEmitted() = runTest { + initTestee() + + testee.onShowOnAppLaunchButtonClick() + + verify(mockPixel).fire(SETTINGS_GENERAL_APP_LAUNCH_PRESSED) + } + + @Test + fun whenLaunchedThenShowOnAppLaunchIsNotVisibleByDefault() = runTest { + initTestee() + + testee.viewState.test { + val state = awaitItem() + + assertTrue(!state!!.isShowOnAppLaunchOptionVisible) + } + } + + @Test + fun whenShowOnAppLaunchFeatureIsDisabledThenIsShowOnAppLaunchOptionIsVisible() = runTest { + fakeShowOnAppLaunchFeatureToggle.self().setRawStoredState(Toggle.State(enable = true)) + + initTestee() + + testee.viewState.test { + val state = awaitItem() + + assertTrue(state!!.isShowOnAppLaunchOptionVisible) + } + } + private fun defaultViewState() = GeneralSettingsViewModel.ViewState( autoCompleteSuggestionsEnabled = true, autoCompleteRecentlyVisitedSitesSuggestionsUserEnabled = true, storeHistoryEnabled = false, showVoiceSearch = false, voiceSearchEnabled = false, + isShowOnAppLaunchOptionVisible = fakeShowOnAppLaunchFeatureToggle.self().isEnabled(), + showOnAppLaunchSelectedOption = LastOpenedTab, ) + + private fun initTestee() { + testee = GeneralSettingsViewModel( + fakeAppSettingsDataStore, + mockPixel, + mockHistory, + mockVoiceSearchAvailability, + mockVoiceSearchRepository, + dispatcherProvider, + fakeShowOnAppLaunchFeatureToggle, + fakeShowOnAppLaunchOptionDataStore, + ) + } } diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt new file mode 100644 index 000000000000..d663c75048eb --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt @@ -0,0 +1,855 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.model.TabSwitcherData +import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowOnAppLaunchOptionHandlerImplTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + private val dispatcherProvider: DispatcherProvider = coroutineTestRule.testDispatcherProvider + + private lateinit var fakeDataStore: ShowOnAppLaunchOptionDataStore + private lateinit var fakeTabRepository: TabRepository + private lateinit var testee: ShowOnAppLaunchOptionHandler + + @Before + fun setup() { + fakeDataStore = FakeShowOnAppLaunchOptionDataStore() + fakeTabRepository = FakeTabRepository() + testee = + ShowOnAppLaunchOptionHandlerImpl(dispatcherProvider, fakeDataStore, fakeTabRepository) + } + + @Test + fun whenOptionIsLastTabOpenedThenNoTabIsAdded() = runTest { + fakeDataStore.setShowOnAppLaunchOption(LastOpenedTab) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.isEmpty()) + } + } + + @Test + fun whenOptionIsNewTabPageOpenedThenNewTabPageIsAdded() = runTest { + fakeDataStore.setShowOnAppLaunchOption(NewTabPage) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == "") + } + } + + @Test + fun whenOptionIsSpecificUrlThenTabIsAdded() = runTest { + val url = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndTabDoesNotExistThenTabIdIsStored() = runTest { + val url = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tab = awaitItem() + awaitComplete() + + assertTrue(fakeDataStore.showOnAppLaunchTabId == tab.first().tabId) + } + } + + @Test + fun whenOptionIsSpecificUrlAndTabExistsThenExistingTabIdIsStored() = runTest { + val url = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + val existingTabId = fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + awaitItem() + awaitComplete() + + assertTrue(fakeDataStore.showOnAppLaunchTabId == existingTabId) + } + } + + @Test + fun whenOptionIsSpecificUrlWithSubdomainThenTabIsAdded() = runTest { + val url = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndUrlIsHttpThenTabIsAdded() = runTest { + val url = "http://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndTabAlreadyAddedThenTabIsSelected() = runTest { + val url = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithSubdomainAndTabAlreadyAddedThenTabIsSelected() = runTest { + val url = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndIsHttpAndTabAlreadyAddedThenTabIsSelected() = runTest { + val url = "http://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlAndIsHttpAndHttpsTabAlreadyAddedThenTabIsNotAdded() = runTest { + val url = "http://example.com/" + val httpsUrl = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(httpsUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == httpsUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlAndIsHttpsAndHttpTabAlreadyAddedThenTabIsNotAdded() = runTest { + val url = "https://example.com/" + val httpUrl = "http://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(httpUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == httpUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDomainOnlyAndTabAlreadyAddedWithSchemeAndSubdomainThenTabIsNotAdded() = + runTest { + val url = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithPathThenTabIsAdded() = runTest { + val queryUrl = "https://example.com/article/1234" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithPathAndTabAlreadyAddedThenTabIsNotAdded() = runTest { + val queryUrl = "https://example.com/article/1234" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryUrl)) + fakeTabRepository.add(queryUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNoPathAndTabExistsWithPathThenTabIsAdded() = runTest { + val url = "http://example.com/" + val pathUrl = "https://example.com/article/1234/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(pathUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithPathAndTabExistsWithoutPathThenTabIsAdded() = runTest { + val url = "https://example.com/article/1234/" + val pathUrl = "https://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(pathUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDifferentPathThenTabIsAdded() = runTest { + val url1 = "https://example.com/path1" + val url2 = "https://example.com/path2" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url1) + } + } + + @Test + fun whenOptionIsSpecificUrlWithWWWSubdomainAndDifferentPathThenTabIsAdded() = runTest { + val url1 = "https://www.example.com/path1" + val url2 = "https://www.example.com/path2" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url1) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonWWWSubdomainAndTabExistsWithWWWSubdomainThenTabIsAdded() = runTest { + val url1 = "https://blog.example.com/" + val url2 = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url1) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNoSubdomainAndTabExistsWithWWWSubdomainThenTabIsNotAdded() = runTest { + val url1 = "https://example.com/" + val url2 = "https://www.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url2) + } + } + + @Test + fun whenOptionIsSpecificUrlWithQueryStringThenTabIsAdded() = runTest { + val queryUrl = "https://example.com/?query=1" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDifferentQueryParameterThenTabIsAdded() = runTest { + val url1 = "https://example.com/path?query1=value1" + val url2 = "https://example.com/path?query2=value2" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url1)) + fakeTabRepository.add(url2) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url1) + } + } + + @Test + fun whenOptionIsSpecificUrlWithFragmentThenTabIsAdded() = runTest { + val fragmentUrl = "https://example.com/#fragment" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(fragmentUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == fragmentUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDifferentFragmentThenTabIsAdded() = runTest { + val url1 = "https://example.com/path?query=value#fragment1" + val url2 = "https://example.com/path?query=value#fragment2" + + fakeTabRepository.add(url1) + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url2)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == url2) + } + } + + @Test + fun whenOptionIsSpecificUrlWithFragmentAndIsAddedThenTabIsNotAdded() = runTest { + val fragmentUrl = "https://example.com/#fragment" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(fragmentUrl)) + fakeTabRepository.add(fragmentUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == fragmentUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithQueryStringAndFragmentThenTabIsAdded() = runTest { + val queryFragmentUrl = "https://example.com/?query=1#fragment" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryFragmentUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryFragmentUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithQueryStringAndFragmentAndIsAddedThenTabIsNotAdded() = runTest { + val queryFragmentUrl = "https://example.com/?query=1#fragment" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(queryFragmentUrl)) + fakeTabRepository.add(queryFragmentUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == queryFragmentUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonHttpOrHttpsProtocolAndNotAddedThenTabIsAdded() = runTest { + val ftpUrl = "ftp://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(ftpUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == ftpUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonHttpOrHttpsProtocolAndAddedThenTabIsNotAdded() = runTest { + val ftpUrl = "ftp://example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(ftpUrl)) + fakeTabRepository.add(ftpUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == ftpUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithResolvedUrlThenTabIsAdded() = runTest { + val url = "https://www.example.com/" + val resolvedUrl = "https://www.example.co.uk/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url, resolvedUrl)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithResolvedUrlAndTabMatchesResolvedUrlThenTabIsNotAdded() = + runTest { + val url = "https://example.com/" + val resolvedUrl = "https://www.example.co.uk/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url, resolvedUrl)) + fakeTabRepository.add(resolvedUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == resolvedUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithResolvedUrlAndTabMatchesBothUrlsThenTabIsNotAdded() = runTest { + val url = "https://www.example.co.uk/" + val resolvedUrl = "https://www.example.co.uk/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url, resolvedUrl)) + fakeTabRepository.add(resolvedUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == resolvedUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonWwwSubdomainThenTabIsAdded() = runTest { + val url = "https://blog.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNonWwwSubdomainAndTabExistsThenTabIsNotAdded() = runTest { + val url = "https://blog.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + fakeTabRepository.add(url) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + @Test + fun whenOptionIsSpecificUrlWithNoSubdomainAndTabWithDifferentSubdomainExistsThenTabIsAdded() = + runTest { + val noSubdomainUrl = "https://example.com/" + val subdomainUrl = "https://blog.example.com/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(noSubdomainUrl)) + fakeTabRepository.add(subdomainUrl) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 2) + assertTrue(tabs.last().url == noSubdomainUrl) + } + } + + @Test + fun whenOptionIsSpecificUrlWithDifferentPortThenTabIsAdded() = runTest { + val url = "https://example.com:8080/" + + fakeDataStore.setShowOnAppLaunchOption(SpecificPage(url)) + + testee.handleAppLaunchOption() + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == url) + } + } + + private class FakeTabRepository : TabRepository { + + private val tabs = mutableMapOf() + + override suspend fun select(tabId: String) = Unit + + override suspend fun add( + url: String?, + skipHome: Boolean, + ): String { + tabs[tabs.size + 1] = url ?: "" + return tabs.size.toString() + } + + override suspend fun getTabId(url: String): String? { + return tabs.values.firstOrNull { it.contains(url) } + } + + override val flowTabs: Flow> = flowOf(tabs).map { + it.map { (id, url) -> TabEntity(tabId = id.toString(), url = url, position = id) } + } + + override val liveTabs: LiveData> + get() = TODO("Not yet implemented") + override val childClosedTabs: SharedFlow + get() = TODO("Not yet implemented") + override val flowDeletableTabs: Flow> + get() = TODO("Not yet implemented") + override val liveSelectedTab: LiveData + get() = TODO("Not yet implemented") + override val tabSwitcherData: Flow + get() = TODO("Not yet implemented") + + override suspend fun addDefaultTab(): String { + TODO("Not yet implemented") + } + + override suspend fun addFromSourceTab( + url: String?, + skipHome: Boolean, + sourceTabId: String, + ): String { + TODO("Not yet implemented") + } + + override suspend fun addNewTabAfterExistingTab( + url: String?, + tabId: String, + ) { + TODO("Not yet implemented") + } + + override suspend fun update( + tabId: String, + site: Site?, + ) { + TODO("Not yet implemented") + } + + override suspend fun updateTabPosition( + from: Int, + to: Int, + ) { + TODO("Not yet implemented") + } + + override fun retrieveSiteData(tabId: String): MutableLiveData { + TODO("Not yet implemented") + } + + override suspend fun delete(tab: TabEntity) { + TODO("Not yet implemented") + } + + override suspend fun markDeletable(tab: TabEntity) { + TODO("Not yet implemented") + } + + override suspend fun undoDeletable(tab: TabEntity) { + TODO("Not yet implemented") + } + + override suspend fun purgeDeletableTabs() { + TODO("Not yet implemented") + } + + override suspend fun getDeletableTabIds(): List { + TODO("Not yet implemented") + } + + override suspend fun deleteTabAndSelectSource(tabId: String) { + TODO("Not yet implemented") + } + + override suspend fun deleteAll() { + TODO("Not yet implemented") + } + + override suspend fun getSelectedTab(): TabEntity? { + TODO("Not yet implemented") + } + + override fun updateTabPreviewImage( + tabId: String, + fileName: String?, + ) { + TODO("Not yet implemented") + } + + override fun updateTabFavicon( + tabId: String, + fileName: String?, + ) { + TODO("Not yet implemented") + } + + override suspend fun selectByUrlOrNewTab(url: String) { + TODO("Not yet implemented") + } + + override suspend fun setIsUserNew(isUserNew: Boolean) { + TODO("Not yet implemented") + } + + override suspend fun setTabLayoutType(layoutType: LayoutType) { + TODO("Not yet implemented") + } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPluginTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPluginTest.kt new file mode 100644 index 000000000000..07c10d5e99a3 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchStateReporterPluginTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowOnAppLaunchReporterPluginTest { + + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private val dispatcherProvider: DispatcherProvider = coroutineTestRule.testDispatcherProvider + private lateinit var testee: ShowOnAppLaunchStateReporterPlugin + private lateinit var fakeDataStore: ShowOnAppLaunchOptionDataStore + + @Before + fun setup() { + fakeDataStore = FakeShowOnAppLaunchOptionDataStore(ShowOnAppLaunchOption.LastOpenedTab) + + testee = ShowOnAppLaunchStateReporterPlugin(dispatcherProvider, fakeDataStore) + } + + @Test + fun whenOptionIsSetToLastOpenedPageThenShouldReturnDailyPixelValue() = runTest { + fakeDataStore.setShowOnAppLaunchOption(ShowOnAppLaunchOption.LastOpenedTab) + val result = testee.featureStateParams() + assertEquals("last_opened_tab", result[PixelParameter.LAUNCH_SCREEN]) + } + + @Test + fun whenOptionIsSetToNewTabPageThenShouldReturnDailyPixelValue() = runTest { + fakeDataStore.setShowOnAppLaunchOption(ShowOnAppLaunchOption.NewTabPage) + val result = testee.featureStateParams() + assertEquals("new_tab_page", result[PixelParameter.LAUNCH_SCREEN]) + } + + @Test + fun whenOptionIsSetToSpecificPageThenShouldReturnDailyPixelValue() = runTest { + val specificPage = ShowOnAppLaunchOption.SpecificPage("example.com") + fakeDataStore.setShowOnAppLaunchOption(specificPage) + val result = testee.featureStateParams() + assertEquals("specific_page", result[PixelParameter.LAUNCH_SCREEN]) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImplTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImplTest.kt new file mode 100644 index 000000000000..45a87f1c1ec9 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchUrlConverterImplTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore.Companion.DEFAULT_SPECIFIC_PAGE_URL +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowOnAppLaunchUrlConverterImplTest { + + private val urlConverter = ShowOnAppLaunchUrlConverterImpl() + + @Test + fun whenUrlIsNullThenShouldReturnDefaultUrl() { + val result = urlConverter.convertUrl(null) + assertEquals(DEFAULT_SPECIFIC_PAGE_URL, result) + } + + @Test + fun whenUrlIsEmptyThenShouldReturnDefaultUrl() { + val result = urlConverter.convertUrl("") + assertEquals(DEFAULT_SPECIFIC_PAGE_URL, result) + } + + @Test + fun whenUrlIsBlankThenShouldReturnDefaultUrl() { + val result = urlConverter.convertUrl(" ") + assertEquals(DEFAULT_SPECIFIC_PAGE_URL, result) + } + + @Test + fun whenUrlHasNoSchemeThenHttpSchemeIsAdded() { + val result = urlConverter.convertUrl("www.example.com") + assertEquals("http://www.example.com", result) + } + + @Test + fun whenUrlHasNoSchemeAndSubdomainThenHttpSchemeIsAdded() { + val result = urlConverter.convertUrl("example.com") + assertEquals("http://example.com", result) + } + + @Test + fun whenUrlDoesNotHaveAPathThenForwardSlashIsAdded() { + val result = urlConverter.convertUrl("https://www.example.com") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasASchemeThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("https://www.example.com/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasDifferentSchemeThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("ftp://www.example.com/") + assertEquals("ftp://www.example.com/", result) + } + + @Test + fun whenUrlHasSpecialCharactersThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("https://www.example.com/path?query=param&another=param") + assertEquals("https://www.example.com/path?query=param&another=param", result) + } + + @Test + fun whenUrlHasPortThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("https://www.example.com:8080/") + assertEquals("https://www.example.com:8080/", result) + } + + @Test + fun whenUrlHasPathAndQueryParametersThenShouldReturnTheSameUrl() { + val result = urlConverter.convertUrl("https://www.example.com/path/to/resource?query=param") + assertEquals("https://www.example.com/path/to/resource?query=param", result) + } + + @Test + fun whenUrlHasUppercaseProtocolThenShouldLowercaseProtocol() { + val result = urlConverter.convertUrl("HTTPS://www.example.com/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasUppercaseSubdomainThenShouldLowercaseSubdomain() { + val result = urlConverter.convertUrl("https://WWW.example.com/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasUppercaseDomainThenShouldLowercaseDomain() { + val result = urlConverter.convertUrl("https://www.EXAMPLE.com/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasUppercaseTopLevelDomainThenShouldLowercaseTopLevelDomain() { + val result = urlConverter.convertUrl("https://www.example.COM/") + assertEquals("https://www.example.com/", result) + } + + @Test + fun whenUrlHasMixedCaseThenOnlyProtocolSubdomainDomainAndTldAreLowercased() { + val result = urlConverter.convertUrl("HTTPS://WWW.EXAMPLE.COM/Path?Query=Param#Fragment") + assertEquals("https://www.example.com/Path?Query=Param#Fragment", result) + } + + @Test + fun whenUrlIsNotAValidUrlReturnsInvalidUrlWithHttpScheme() { + val result = urlConverter.convertUrl("example") + assertEquals("http://example", result) + } + + @Test + fun whenUrlHasADifferentSchemeThenSameUrlReturned() { + val result = urlConverter.convertUrl("ftp://example.com/") + assertEquals("ftp://example.com/", result) + } + + @Test + fun whenUrlHasADifferentSchemeAndNoTrailingSlashThenTrailingSlashAdded() { + val result = urlConverter.convertUrl("ftp://example.com") + assertEquals("ftp://example.com/", result) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModelTest.kt new file mode 100644 index 000000000000..dc8a0af55ac0 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchViewModelTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch + +import app.cash.turbine.test +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.fakes.FakePixel +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ShowOnAppLaunchViewModelTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var testee: ShowOnAppLaunchViewModel + private lateinit var fakeDataStore: FakeShowOnAppLaunchOptionDataStore + private lateinit var fakePixel: FakePixel + private val dispatcherProvider: DispatcherProvider = coroutineTestRule.testDispatcherProvider + + @Before + fun setup() { + fakeDataStore = FakeShowOnAppLaunchOptionDataStore(LastOpenedTab) + fakePixel = FakePixel() + testee = ShowOnAppLaunchViewModel(dispatcherProvider, fakeDataStore, FakeUrlConverter(), fakePixel) + } + + @Test + fun whenViewModelInitializedThenInitialStateIsCorrect() = runTest { + testee.viewState.test { + val initialState = awaitItem() + assertEquals(LastOpenedTab, initialState.selectedOption) + assertEquals("https://duckduckgo.com", initialState.specificPageUrl) + } + } + + @Test + fun whenShowOnAppLaunchOptionChangedThenStateIsUpdated() = runTest { + testee.onShowOnAppLaunchOptionChanged(NewTabPage) + testee.viewState.test { + val updatedState = awaitItem() + assertEquals(NewTabPage, updatedState.selectedOption) + } + } + + @Test + fun whenSpecificPageUrlSetThenStateIsUpdated() = runTest { + val newUrl = "https://example.com" + + testee.setSpecificPageUrl(newUrl) + testee.viewState.test { + val updatedState = awaitItem() + assertEquals(newUrl, updatedState.specificPageUrl) + } + } + + @Test + fun whenMultipleOptionsChangedThenStateIsUpdatedCorrectly() = runTest { + testee.onShowOnAppLaunchOptionChanged(NewTabPage) + testee.onShowOnAppLaunchOptionChanged(LastOpenedTab) + testee.viewState.test { + val updatedState = awaitItem() + assertEquals(LastOpenedTab, updatedState.selectedOption) + } + } + + @Test + fun whenOptionChangedToLastOpenedPageThenLastOpenedPageIsFired() = runTest { + testee.onShowOnAppLaunchOptionChanged(NewTabPage) + testee.onShowOnAppLaunchOptionChanged(LastOpenedTab) + assertEquals(2, fakePixel.firedPixels.size) + assertEquals(SETTINGS_GENERAL_APP_LAUNCH_LAST_OPENED_TAB_SELECTED.pixelName, fakePixel.firedPixels.last()) + } + + @Test + fun whenOptionChangedToNewTabPageThenNewTabPagePixelIsFired() = runTest { + testee.onShowOnAppLaunchOptionChanged(NewTabPage) + assertEquals(1, fakePixel.firedPixels.size) + assertEquals(SETTINGS_GENERAL_APP_LAUNCH_NEW_TAB_PAGE_SELECTED.pixelName, fakePixel.firedPixels[0]) + } + + @Test + fun whenOptionChangedToSpecificPageThenSpecificPixelIsFired() = runTest { + testee.onShowOnAppLaunchOptionChanged(SpecificPage("https://example.com")) + assertEquals(1, fakePixel.firedPixels.size) + assertEquals(SETTINGS_GENERAL_APP_LAUNCH_SPECIFIC_PAGE_SELECTED.pixelName, fakePixel.firedPixels[0]) + } + + private class FakeUrlConverter : UrlConverter { + + override fun convertUrl(url: String?): String { + return url ?: "https://duckduckgo.com" + } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/FakeShowOnAppLaunchOptionDataStore.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/FakeShowOnAppLaunchOptionDataStore.kt new file mode 100644 index 000000000000..e24ae2050472 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/FakeShowOnAppLaunchOptionDataStore.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.store + +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull + +class FakeShowOnAppLaunchOptionDataStore(defaultOption: ShowOnAppLaunchOption? = null) : ShowOnAppLaunchOptionDataStore { + + override var showOnAppLaunchTabId: String? = null + private set + + private var currentOptionStateFlow = MutableStateFlow(defaultOption) + + private var currentSpecificPageUrl = MutableStateFlow("https://duckduckgo.com") + + override val optionFlow: Flow = currentOptionStateFlow.asStateFlow().filterNotNull() + + override val specificPageUrlFlow: Flow = currentSpecificPageUrl.asStateFlow() + + override suspend fun setShowOnAppLaunchOption(showOnAppLaunchOption: ShowOnAppLaunchOption) { + currentOptionStateFlow.value = showOnAppLaunchOption + } + + override suspend fun setSpecificPageUrl(url: String) { + currentSpecificPageUrl.value = url + } + + override suspend fun setResolvedPageUrl(url: String) { + TODO("Not yet implemented") + } + + override fun setShowOnAppLaunchTabId(tabId: String) { + showOnAppLaunchTabId = tabId + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchPrefsDataStoreTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchPrefsDataStoreTest.kt new file mode 100644 index 000000000000..dcb1c8dec27a --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/store/ShowOnAppLaunchPrefsDataStoreTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.generalsettings.showonapplaunch.store + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage +import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowOnAppLaunchPrefsDataStoreTest { + + @get:Rule val coroutineRule = CoroutineTestRule() + + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + private val dataStoreFile = context.preferencesDataStoreFile("show_on_app_launch") + + private val testDataStore: DataStore = + PreferenceDataStoreFactory.create( + scope = coroutineRule.testScope, + produceFile = { dataStoreFile }, + ) + + private val testee: ShowOnAppLaunchOptionDataStore = + ShowOnAppLaunchOptionPrefsDataStore(testDataStore) + + @After + fun after() { + dataStoreFile.delete() + } + + @Test + fun whenOptionIsNullThenShouldReturnLastOpenedPage() = runTest { + assertEquals(LastOpenedTab, testee.optionFlow.first()) + } + + @Test + fun whenOptionIsSetToLastOpenedPageThenShouldReturnLastOpenedPage() = runTest { + testee.setShowOnAppLaunchOption(LastOpenedTab) + assertEquals(LastOpenedTab, testee.optionFlow.first()) + } + + @Test + fun whenOptionIsSetToNewTabPageThenShouldReturnNewTabPage() = runTest { + testee.setShowOnAppLaunchOption(NewTabPage) + assertEquals(NewTabPage, testee.optionFlow.first()) + } + + @Test + fun whenOptionIsSetToSpecificPageThenShouldReturnSpecificPage() = runTest { + val specificPage = SpecificPage("example.com") + + testee.setShowOnAppLaunchOption(specificPage) + assertEquals(specificPage, testee.optionFlow.first()) + } + + @Test + fun whenSpecificPageIsNullThenShouldReturnDefaultUrl() = runTest { + assertEquals("https://duckduckgo.com/", testee.specificPageUrlFlow.first()) + } + + @Test + fun whenSpecificPageUrlIsSetThenShouldReturnSpecificPageUrl() = runTest { + testee.setSpecificPageUrl("example.com") + assertEquals("example.com", testee.specificPageUrlFlow.first()) + } + + @Test + fun whenOptionIsChangedThenNewOptionEmitted() = runTest { + testee.optionFlow.test { + val defaultOption = awaitItem() + + assertEquals(LastOpenedTab, defaultOption) + + testee.setShowOnAppLaunchOption(NewTabPage) + + assertEquals(NewTabPage, awaitItem()) + + testee.setShowOnAppLaunchOption(SpecificPage("example.com")) + + assertEquals(SpecificPage("example.com"), awaitItem()) + } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/global/migrations/LocationPermissionsMigrationPluginTest.kt b/app/src/test/java/com/duckduckgo/app/global/migrations/LocationPermissionsMigrationPluginTest.kt new file mode 100644 index 000000000000..1e87049f55cc --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/global/migrations/LocationPermissionsMigrationPluginTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.app.global.migrations + +import com.duckduckgo.app.location.data.LocationPermissionEntity +import com.duckduckgo.app.location.data.LocationPermissionType.ALLOW_ALWAYS +import com.duckduckgo.app.location.data.LocationPermissionType.DENY_ALWAYS +import com.duckduckgo.app.location.data.LocationPermissionsRepository +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest +import com.duckduckgo.site.permissions.impl.SitePermissionsRepository +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType +import kotlinx.coroutines.test.TestScope +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class LocationPermissionsMigrationPluginTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private var settingsDataStore: SettingsDataStore = mock() + private var locationPermissionsRepository: LocationPermissionsRepository = mock() + private var sitePermissionsRepository: SitePermissionsRepository = mock() + + private lateinit var testee: LocationPermissionMigrationPlugin + + @Before + fun before() { + testee = LocationPermissionMigrationPlugin( + settingsDataStore, + locationPermissionsRepository, + sitePermissionsRepository, + TestScope(), + coroutineRule.testDispatcherProvider, + ) + } + + @Test + fun whenMigrationIsNeededAndRanThenMigrationStateStored() { + whenever(settingsDataStore.appLocationPermissionMigrated).thenReturn(false) + + testee.run() + + verify(settingsDataStore).appLocationPermissionMigrated = true + } + + @Test + fun whenMigrationIsNeededAndRanThenLocationPermissionMigrated() { + whenever(settingsDataStore.appLocationPermissionMigrated).thenReturn(false) + whenever(settingsDataStore.appLocationPermission).thenReturn(false) + + testee.run() + + verify(sitePermissionsRepository).askLocationEnabled = false + } + + @Test + fun whenMigrationNotNeededAThenNothingHappens() { + whenever(settingsDataStore.appLocationPermissionMigrated).thenReturn(true) + + testee.run() + + verifyNoInteractions(locationPermissionsRepository) + } + + @Test + fun whenAllowedPermissionsPresentThenCanBeMigrated() { + whenever(settingsDataStore.appLocationPermissionMigrated).thenReturn(false) + whenever(locationPermissionsRepository.getLocationPermissionsSync()).thenReturn( + listOf(LocationPermissionEntity("domain.com", ALLOW_ALWAYS)), + ) + + testee.run() + + verify(sitePermissionsRepository).sitePermissionPermanentlySaved( + "domain.com", + LocationPermissionRequest.RESOURCE_LOCATION_PERMISSION, + SitePermissionAskSettingType.ALLOW_ALWAYS, + ) + } + + @Test + fun whenDeniedPermissionsPresentThenCanBeMigrated() { + whenever(settingsDataStore.appLocationPermissionMigrated).thenReturn(false) + whenever(locationPermissionsRepository.getLocationPermissionsSync()).thenReturn( + listOf(LocationPermissionEntity("domain.com", DENY_ALWAYS)), + ) + + testee.run() + + verify(sitePermissionsRepository).sitePermissionPermanentlySaved( + "domain.com", + LocationPermissionRequest.RESOURCE_LOCATION_PERMISSION, + SitePermissionAskSettingType.DENY_ALWAYS, + ) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/location/data/LocationPermissionEntityTest.kt b/app/src/test/java/com/duckduckgo/app/location/data/LocationPermissionRequestEntityTest.kt similarity index 97% rename from app/src/test/java/com/duckduckgo/app/location/data/LocationPermissionEntityTest.kt rename to app/src/test/java/com/duckduckgo/app/location/data/LocationPermissionRequestEntityTest.kt index 9705b0a62b75..30c712b21590 100644 --- a/app/src/test/java/com/duckduckgo/app/location/data/LocationPermissionEntityTest.kt +++ b/app/src/test/java/com/duckduckgo/app/location/data/LocationPermissionRequestEntityTest.kt @@ -20,7 +20,7 @@ import com.duckduckgo.common.utils.extensions.asLocationPermissionOrigin import org.junit.Assert import org.junit.Test -class LocationPermissionEntityTest { +class LocationPermissionRequestEntityTest { @Test fun whenDomainStartsWithHttpsThenDropPrefix() { diff --git a/app/src/test/java/com/duckduckgo/app/sitepermissions/PermissionsPerWebsiteViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/sitepermissions/PermissionsPerWebsiteViewModelTest.kt index d744f3881010..5895875cf281 100644 --- a/app/src/test/java/com/duckduckgo/app/sitepermissions/PermissionsPerWebsiteViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/sitepermissions/PermissionsPerWebsiteViewModelTest.kt @@ -18,10 +18,6 @@ package com.duckduckgo.app.sitepermissions import app.cash.turbine.test import com.duckduckgo.app.browser.R -import com.duckduckgo.app.location.data.LocationPermissionEntity -import com.duckduckgo.app.location.data.LocationPermissionType -import com.duckduckgo.app.location.data.LocationPermissionsRepository -import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.sitepermissions.permissionsperwebsite.PermissionsPerWebsiteViewModel import com.duckduckgo.app.sitepermissions.permissionsperwebsite.PermissionsPerWebsiteViewModel.Command.GoBackToSitePermissions import com.duckduckgo.app.sitepermissions.permissionsperwebsite.PermissionsPerWebsiteViewModel.Command.ShowPermissionSettingSelectionDialog @@ -47,13 +43,9 @@ class PermissionsPerWebsiteViewModelTest { var coroutineRule = CoroutineTestRule() private val mockSitePermissionsRepository: SitePermissionsRepository = mock() - private val mockLocationPermissionsRepository: LocationPermissionsRepository = mock() - private val mockSettingsDataStore: SettingsDataStore = mock() private val viewModel = PermissionsPerWebsiteViewModel( sitePermissionsRepository = mockSitePermissionsRepository, - locationPermissionsRepository = mockLocationPermissionsRepository, - settingsDataStore = mockSettingsDataStore, ) private val domain = "domain.com" @@ -211,20 +203,21 @@ class PermissionsPerWebsiteViewModelTest { micEnabled: Boolean = true, cameraEnabled: Boolean = true, locationEnabled: Boolean = true, + drmEnabled: Boolean = true, ) { - whenever(mockSettingsDataStore.appLocationPermission).thenReturn(locationEnabled) whenever(mockSitePermissionsRepository.askMicEnabled).thenReturn(micEnabled) whenever(mockSitePermissionsRepository.askCameraEnabled).thenReturn(cameraEnabled) + whenever(mockSitePermissionsRepository.askDrmEnabled).thenReturn(drmEnabled) + whenever(mockSitePermissionsRepository.askLocationEnabled).thenReturn(locationEnabled) } private fun loadWebsitePermissionsSettings( cameraSetting: String = SitePermissionAskSettingType.ASK_EVERY_TIME.name, micSetting: String = SitePermissionAskSettingType.ASK_EVERY_TIME.name, - locationSetting: LocationPermissionType = LocationPermissionType.ALLOW_ALWAYS, + locationSetting: String = SitePermissionAskSettingType.ASK_EVERY_TIME.name, + drmSetting: String = SitePermissionAskSettingType.ASK_EVERY_TIME.name, ) { - val testLocationEntity = LocationPermissionEntity(domain, locationSetting) - val testSitePermissionEntity = SitePermissionsEntity(domain, cameraSetting, micSetting) + val testSitePermissionEntity = SitePermissionsEntity(domain, cameraSetting, micSetting, drmSetting, locationSetting) mockSitePermissionsRepository.stub { onBlocking { getSitePermissionsForWebsite(domain) }.thenReturn(testSitePermissionEntity) } - mockLocationPermissionsRepository.stub { onBlocking { getDomainPermission(domain) }.thenReturn(testLocationEntity) } } } diff --git a/app/src/test/java/com/duckduckgo/app/sitepermissions/SitePermissionsViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/sitepermissions/SitePermissionsViewModelTest.kt index 9287b8a746d1..49d4d44646ea 100644 --- a/app/src/test/java/com/duckduckgo/app/sitepermissions/SitePermissionsViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/sitepermissions/SitePermissionsViewModelTest.kt @@ -18,11 +18,6 @@ package com.duckduckgo.app.sitepermissions import app.cash.turbine.test import com.duckduckgo.app.browser.R -import com.duckduckgo.app.location.GeoLocationPermissions -import com.duckduckgo.app.location.data.LocationPermissionEntity -import com.duckduckgo.app.location.data.LocationPermissionType -import com.duckduckgo.app.location.data.LocationPermissionsRepository -import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.sitepermissions.SitePermissionsViewModel.Command.LaunchWebsiteAllowed import com.duckduckgo.app.sitepermissions.SitePermissionsViewModel.Command.ShowRemovedAllConfirmationSnackbar import com.duckduckgo.common.test.CoroutineTestRule @@ -46,15 +41,9 @@ class SitePermissionsViewModelTest { var coroutineRule = CoroutineTestRule() private val mockSitePermissionsRepository: SitePermissionsRepository = mock() - private val mockLocationPermissionsRepository: LocationPermissionsRepository = mock() - private val mockGeoLocationPermissions: GeoLocationPermissions = mock() - private val mockSettingsDataStore: SettingsDataStore = mock() private val viewModel = SitePermissionsViewModel( sitePermissionsRepository = mockSitePermissionsRepository, - locationPermissionsRepository = mockLocationPermissionsRepository, - geolocationPermissions = mockGeoLocationPermissions, - settingsDataStore = mockSettingsDataStore, dispatcherProvider = coroutineRule.testDispatcherProvider, ) @@ -74,25 +63,15 @@ class SitePermissionsViewModelTest { } @Test - fun whenAllowedSitesLoadedThenViewStateEmittedLocationWebsites() = runTest { + fun whenRemoveAllWebsitesThenDeleteAllSitePermissionsIsCalled() = runTest { viewModel.viewState.test { - val sitePermissions = awaitItem().locationPermissionsAllowed - assertEquals(1, sitePermissions.size) - } - } - - @Test - fun whenRemoveAllWebsitesThenClearAllLocationWebsitesIsCalled() = runTest { - viewModel.removeAllSitesSelected() + viewModel.removeAllSitesSelected() - verify(mockGeoLocationPermissions).clearAll() - } + verify(mockSitePermissionsRepository).deleteAll() - @Test - fun whenRemoveAllWebsitesThenDeleteAllSitePermissionsIsCalled() = runTest { - viewModel.removeAllSitesSelected() - - verify(mockSitePermissionsRepository).deleteAll() + val sitePermissions = expectMostRecentItem().sitesPermissionsAllowed + assertEquals(2, sitePermissions.size) + } } @Test @@ -163,6 +142,16 @@ class SitePermissionsViewModelTest { } } + @Test + fun whenToggleOffAskForDRMThenViewStateEmitted() = runTest { + viewModel.permissionToggleSelected(false, R.string.sitePermissionsSettingsDRM) + + viewModel.viewState.test { + val drmEnabled = awaitItem().askDrmEnabled + assertFalse(drmEnabled) + } + } + @Test fun whenWebsiteIsTappedThenNavigateToPermissionsPerWebsiteScreen() = runTest { val testDomain = "website1.com" @@ -174,16 +163,20 @@ class SitePermissionsViewModelTest { } private fun loadWebsites() { - val locationPermissions = listOf(LocationPermissionEntity("www.website1.com", LocationPermissionType.ALLOW_ONCE)) val sitePermissions = listOf(SitePermissionsEntity("www.website2.com"), SitePermissionsEntity("www.website3.com")) - whenever(mockLocationPermissionsRepository.getLocationPermissionsFlow()).thenReturn(flowOf(locationPermissions)) whenever(mockSitePermissionsRepository.sitePermissionsWebsitesFlow()).thenReturn(flowOf(sitePermissions)) whenever(mockSitePermissionsRepository.sitePermissionsAllowedFlow()).thenReturn(flowOf(emptyList())) } - private fun loadPermissionsSettings(micEnabled: Boolean = true, cameraEnabled: Boolean = true, locationEnabled: Boolean = true) { - whenever(mockSettingsDataStore.appLocationPermission).thenReturn(locationEnabled) + private fun loadPermissionsSettings( + micEnabled: Boolean = true, + cameraEnabled: Boolean = true, + locationEnabled: Boolean = true, + drmEnabled: Boolean = true, + ) { whenever(mockSitePermissionsRepository.askMicEnabled).thenReturn(micEnabled) whenever(mockSitePermissionsRepository.askCameraEnabled).thenReturn(cameraEnabled) + whenever(mockSitePermissionsRepository.askLocationEnabled).thenReturn(locationEnabled) + whenever(mockSitePermissionsRepository.askDrmEnabled).thenReturn(drmEnabled) } } diff --git a/app/src/test/java/com/duckduckgo/app/survey/ui/SurveyViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/survey/ui/SurveyViewModelTest.kt index dcad08a20f7b..ac2f04670731 100644 --- a/app/src/test/java/com/duckduckgo/app/survey/ui/SurveyViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/survey/ui/SurveyViewModelTest.kt @@ -32,7 +32,6 @@ import com.duckduckgo.app.survey.ui.SurveyViewModel.Command import com.duckduckgo.app.usage.app.AppDaysUsedRepository import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import java.util.concurrent.TimeUnit import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -68,8 +67,6 @@ class SurveyViewModelTest { private val mockSurveyRepository: SurveyRepository = mock() - private val mockLoadingBarExperimentManager: LoadingBarExperimentManager = mock() - private lateinit var testee: SurveyViewModel private val testSource = SurveySource.IN_APP @@ -87,7 +84,6 @@ class SurveyViewModelTest { coroutineTestRule.testDispatcherProvider, mockAppDaysUsedRepository, mockSurveyRepository, - mockLoadingBarExperimentManager, ) testee.command.observeForever(mockCommandObserver) } @@ -176,33 +172,6 @@ class SurveyViewModelTest { assertEquals("name", loadedUri.getQueryParameter("ddgv")) assertEquals("pixel", loadedUri.getQueryParameter("man")) assertEquals("XL", loadedUri.getQueryParameter("mo")) - assertEquals(null, loadedUri.getQueryParameter("loading_bar_exp")) - } - - @Test - fun givenLoadingBarExperimentWhenSurveyStartedThenExtraParametersAddedToUrl() { - whenever(mockStatisticsStore.atb).thenReturn(Atb("123")) - whenever(mockStatisticsStore.variant).thenReturn("abc") - whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)) - whenever(mockAppBuildConfig.sdkInt).thenReturn(16) - whenever(mockAppBuildConfig.manufacturer).thenReturn("pixel") - whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) - whenever(mockLoadingBarExperimentManager.variant).thenReturn(true) - - val captor = argumentCaptor() - testee.start(Survey("", "https://survey.com", null, SCHEDULED), testSource) - verify(mockCommandObserver).onChanged(captor.capture()) - val loadedUri = captor.lastValue.url.toUri() - - assertEquals("123", loadedUri.getQueryParameter("atb")) - assertEquals("abc", loadedUri.getQueryParameter("var")) - assertEquals("2", loadedUri.getQueryParameter("delta")) - assertEquals("16", loadedUri.getQueryParameter("av")) - assertEquals("name", loadedUri.getQueryParameter("ddgv")) - assertEquals("pixel", loadedUri.getQueryParameter("man")) - assertEquals("in_app", loadedUri.getQueryParameter("src")) - assertEquals("today", loadedUri.getQueryParameter("da")) - assertEquals("true", loadedUri.getQueryParameter("loading_bar_exp")) } @Test diff --git a/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPluginTest.kt b/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPluginTest.kt index b66eb433fb93..6d05736e91ee 100644 --- a/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPluginTest.kt +++ b/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListInterceptorApiPluginTest.kt @@ -22,9 +22,6 @@ import com.duckduckgo.app.trackerdetection.api.TDS_BASE_URL import com.duckduckgo.app.trackerdetection.api.TDS_PATH import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.CONTROL import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.NEXT_URL -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.api.FakeChain import com.duckduckgo.feature.toggles.api.FakeToggleStore @@ -34,6 +31,7 @@ import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory +import com.squareup.moshi.Moshi import org.junit.Assert.* import org.junit.Before import org.junit.Rule @@ -49,6 +47,14 @@ class BlockListInterceptorApiPluginTest { private lateinit var testBlockListFeature: TestBlockListFeature private lateinit var inventory: FeatureTogglesInventory private lateinit var interceptor: BlockListInterceptorApiPlugin + private val moshi = Moshi.Builder().build() + + private data class Config( + val treatmentUrl: String? = null, + val controlUrl: String? = null, + val nextUrl: String? = null, + ) + private val configAdapter = moshi.adapter(Config::class.java) @Before fun setup() { @@ -69,7 +75,7 @@ class BlockListInterceptorApiPluginTest { coroutineRule.testDispatcherProvider, ) - interceptor = BlockListInterceptorApiPlugin(inventory) + interceptor = BlockListInterceptorApiPlugin(inventory, moshi) } @Test @@ -78,9 +84,8 @@ class BlockListInterceptorApiPluginTest { State( remoteEnableState = true, enable = true, - config = mapOf( - TREATMENT_URL to "treatmentUrl", - CONTROL_URL to "controlUrl", + settings = configAdapter.toJson( + Config(treatmentUrl = "treatmentUrl", controlUrl = "controlUrl"), ), cohorts = listOf( State.Cohort(name = CONTROL.cohortName, weight = 0), @@ -92,9 +97,8 @@ class BlockListInterceptorApiPluginTest { State( remoteEnableState = true, enable = true, - config = mapOf( - TREATMENT_URL to "anotherTreatmentUrl", - CONTROL_URL to "anotherControlUrl", + settings = configAdapter.toJson( + Config(treatmentUrl = "anotherTreatmentUrl", controlUrl = "anotherControlUrl"), ), cohorts = listOf( State.Cohort(name = CONTROL.cohortName, weight = 0), @@ -114,9 +118,8 @@ class BlockListInterceptorApiPluginTest { State( remoteEnableState = true, enable = true, - config = mapOf( - TREATMENT_URL to "anotherTreatmentUrl", - CONTROL_URL to "anotherControlUrl", + settings = configAdapter.toJson( + Config(treatmentUrl = "anotherTreatmentUrl", controlUrl = "anotherControlUrl"), ), cohorts = listOf( State.Cohort(name = CONTROL.cohortName, weight = 0), @@ -136,9 +139,8 @@ class BlockListInterceptorApiPluginTest { State( remoteEnableState = true, enable = true, - config = mapOf( - TREATMENT_URL to "anotherTreatmentUrl", - CONTROL_URL to "anotherControlUrl", + settings = configAdapter.toJson( + Config(treatmentUrl = "anotherTreatmentUrl", controlUrl = "anotherControlUrl"), ), cohorts = listOf( State.Cohort(name = CONTROL.cohortName, weight = 1), @@ -158,8 +160,8 @@ class BlockListInterceptorApiPluginTest { State( remoteEnableState = true, enable = true, - config = mapOf( - NEXT_URL to "nextUrl", + settings = configAdapter.toJson( + Config(nextUrl = "nextUrl"), ), ), ) @@ -182,9 +184,8 @@ class BlockListInterceptorApiPluginTest { State( remoteEnableState = true, enable = true, - config = mapOf( - TREATMENT_URL to "anotherTreatmentUrl", - CONTROL_URL to "anotherControlUrl", + settings = configAdapter.toJson( + Config(treatmentUrl = "anotherTreatmentUrl", controlUrl = "anotherControlUrl"), ), cohorts = listOf( State.Cohort(name = CONTROL.cohortName, weight = 1), diff --git a/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePluginTest.kt b/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePluginTest.kt index 15aa52ba4bc6..19ce4d733874 100644 --- a/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePluginTest.kt +++ b/app/src/test/java/com/duckduckgo/app/trackerdetection/blocklist/BlockListPrivacyTogglePluginTest.kt @@ -3,8 +3,6 @@ package com.duckduckgo.app.trackerdetection.blocklist import android.annotation.SuppressLint import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.TREATMENT -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.CONTROL_URL -import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Companion.TREATMENT_URL import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeToggleStore import com.duckduckgo.feature.toggles.api.FeatureToggles @@ -14,6 +12,7 @@ import com.duckduckgo.feature.toggles.impl.RealFeatureTogglesInventory import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin.BREAKAGE_FORM import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin.DASHBOARD import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin.MENU +import com.squareup.moshi.Moshi import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.ChronoUnit @@ -31,6 +30,15 @@ class BlockListPrivacyTogglePluginTest { @get:Rule var coroutineRule = CoroutineTestRule() + private val moshi = Moshi.Builder().build() + + private data class Config( + val treatmentUrl: String? = null, + val controlUrl: String? = null, + val nextUrl: String? = null, + ) + private val configAdapter = moshi.adapter(Config::class.java) + private val pixel: Pixel = mock() private lateinit var testBlockListFeature: TestBlockListFeature private lateinit var inventory: FeatureTogglesInventory @@ -88,10 +96,7 @@ class BlockListPrivacyTogglePluginTest { State( remoteEnableState = true, enable = true, - config = mapOf( - TREATMENT_URL to "treatmentUrl", - CONTROL_URL to "controlUrl", - ), + settings = configAdapter.toJson(Config(treatmentUrl = "treatmentUrl", controlUrl = "controlUrl")), assignedCohort = State.Cohort(name = TREATMENT.cohortName, weight = 1, enrollmentDateET = enrollmentDateET), ), ) diff --git a/app/src/test/java/com/duckduckgo/fakes/FakePixel.kt b/app/src/test/java/com/duckduckgo/fakes/FakePixel.kt new file mode 100644 index 000000000000..ca4bbb7ec52d --- /dev/null +++ b/app/src/test/java/com/duckduckgo/fakes/FakePixel.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.fakes + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType + +internal class FakePixel : Pixel { + + val firedPixels = mutableListOf() + + override fun fire( + pixel: PixelName, + parameters: Map, + encodedParameters: Map, + type: PixelType, + ) { + firedPixels.add(pixel.pixelName) + } + + override fun fire( + pixelName: String, + parameters: Map, + encodedParameters: Map, + type: PixelType, + ) { + firedPixels.add(pixelName) + } + + override fun enqueueFire( + pixel: PixelName, + parameters: Map, + encodedParameters: Map, + ) { + firedPixels.add(pixel.pixelName) + } + + override fun enqueueFire( + pixelName: String, + parameters: Map, + encodedParameters: Map, + ) { + firedPixels.add(pixelName) + } +} diff --git a/app/version/version.properties b/app/version/version.properties index 52163853b329..ebd5dbf6904e 100644 --- a/app/version/version.properties +++ b/app/version/version.properties @@ -1 +1 @@ -VERSION=5.219.0 \ No newline at end of file +VERSION=5.220.0 \ No newline at end of file diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt index c37e4a264cb0..0244965bc6d2 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt @@ -97,4 +97,13 @@ interface AutofillFeature { */ @Toggle.DefaultValue(false) fun showDisableDialogAutofillPrompt(): Toggle + + /** + * Remote Flag that enables the ability to import passwords directly from Google Password Manager + * @return `true` when the remote config has "canImportFromGooglePasswordManager" autofill sub-feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @InternalAlwaysEnabled + @Toggle.DefaultValue(false) + fun canImportFromGooglePasswordManager(): Toggle } diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle index d455ac29e520..937214be5f02 100644 --- a/autofill/autofill-impl/build.gradle +++ b/autofill/autofill-impl/build.gradle @@ -18,6 +18,7 @@ plugins { id 'com.android.library' id 'kotlin-android' id 'com.squareup.anvil' + id 'kotlin-parcelize' } apply from: "$rootProject.projectDir/gradle/android-library.gradle" @@ -59,6 +60,8 @@ dependencies { implementation "androidx.datastore:datastore-preferences:_" + implementation "de.siegmar:fastcsv:_" + implementation Square.retrofit2.converter.moshi implementation "com.squareup.moshi:moshi-kotlin:_" implementation "com.squareup.moshi:moshi-adapters:_" @@ -71,6 +74,7 @@ dependencies { implementation "net.zetetic:android-database-sqlcipher:_" // Testing dependencies + testImplementation project(':common-test') testImplementation "org.mockito.kotlin:mockito-kotlin:_" testImplementation Testing.junit4 testImplementation AndroidX.core diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt index 76b1036f5834..a5829831ecaa 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt @@ -270,7 +270,7 @@ class SecureStoreBackedAutofillStore @Inject constructor( return matchType } - private fun LoginCredentials.prepareForReinsertion(): WebsiteLoginDetailsWithCredentials { + private fun LoginCredentials.prepareForBulkInsertion(): WebsiteLoginDetailsWithCredentials { val loginDetails = WebsiteLoginDetails( id = id, domain = domain, @@ -287,7 +287,7 @@ class SecureStoreBackedAutofillStore @Inject constructor( override suspend fun reinsertCredentials(credentials: LoginCredentials) { withContext(dispatcherProvider.io()) { - secureStorage.addWebsiteLoginDetailsWithCredentials(credentials.prepareForReinsertion())?.also { + secureStorage.addWebsiteLoginDetailsWithCredentials(credentials.prepareForBulkInsertion())?.also { syncCredentialsListener.onCredentialAdded(it.details.id!!) } } @@ -295,7 +295,7 @@ class SecureStoreBackedAutofillStore @Inject constructor( override suspend fun reinsertCredentials(credentials: List) { withContext(dispatcherProvider.io()) { - val mappedCredentials = credentials.map { it.prepareForReinsertion() } + val mappedCredentials = credentials.map { it.prepareForBulkInsertion() } secureStorage.addWebsiteLoginDetailsWithCredentials(mappedCredentials).also { val ids = mappedCredentials.mapNotNull { it.details.id } syncCredentialsListener.onCredentialsAdded(ids) @@ -303,6 +303,15 @@ class SecureStoreBackedAutofillStore @Inject constructor( } } + override suspend fun bulkInsert(credentials: List): List { + return withContext(dispatcherProvider.io()) { + val mappedCredentials = credentials.map { it.prepareForBulkInsertion() } + return@withContext secureStorage.addWebsiteLoginDetailsWithCredentials(mappedCredentials).also { + syncCredentialsListener.onCredentialsAdded(it) + } + } + } + private fun usernameMatch( credentials: WebsiteLoginDetailsWithCredentials, username: String?, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt new file mode 100644 index 000000000000..c90f2015d75c --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import android.os.Parcelable +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.InProgress +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +interface CredentialImporter { + suspend fun import( + importList: List, + originalImportListSize: Int, + ) + + fun getImportStatus(): Flow + + sealed interface ImportResult : Parcelable { + + @Parcelize + data object InProgress : ImportResult + + @Parcelize + data class Finished( + val savedCredentials: Int, + val numberSkipped: Int, + ) : ImportResult + } +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class CredentialImporterImpl @Inject constructor( + private val autofillStore: InternalAutofillStore, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : CredentialImporter { + + private val _importStatus = MutableSharedFlow(replay = 1) + + override suspend fun import( + importList: List, + originalImportListSize: Int, + ) { + appCoroutineScope.launch(dispatchers.io()) { + doImportCredentials(importList, originalImportListSize) + } + } + + private suspend fun doImportCredentials( + importList: List, + originalImportListSize: Int, + ) { + var skippedCredentials = originalImportListSize - importList.size + + _importStatus.emit(InProgress) + + val insertedIds = autofillStore.bulkInsert(importList) + + skippedCredentials += (importList.size - insertedIds.size) + _importStatus.emit(Finished(savedCredentials = insertedIds.size, numberSkipped = skippedCredentials)) + } + + override fun getImportStatus(): Flow = _importStatus +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt new file mode 100644 index 000000000000..3ec09c52ba50 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import android.net.Uri +import android.os.Parcelable +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize + +interface CsvCredentialConverter { + suspend fun readCsv(encodedBlob: String): CsvCredentialImportResult + suspend fun readCsv(fileUri: Uri): CsvCredentialImportResult + + sealed interface CsvCredentialImportResult : Parcelable { + + @Parcelize + data class Success(val numberCredentialsInSource: Int, val loginCredentialsToImport: List) : CsvCredentialImportResult + + @Parcelize + data object Error : CsvCredentialImportResult + } +} + +@ContributesBinding(AppScope::class) +class GooglePasswordManagerCsvCredentialConverter @Inject constructor( + private val parser: CsvCredentialParser, + private val fileReader: CsvFileReader, + private val credentialValidator: ImportedCredentialValidator, + private val domainNameNormalizer: DomainNameNormalizer, + private val dispatchers: DispatcherProvider, + private val blobDecoder: GooglePasswordBlobDecoder, + private val existingCredentialMatchDetector: ExistingCredentialMatchDetector, +) : CsvCredentialConverter { + + override suspend fun readCsv(encodedBlob: String): CsvCredentialImportResult { + return kotlin.runCatching { + withContext(dispatchers.io()) { + val csv = blobDecoder.decode(encodedBlob) + convertToLoginCredentials(csv) + } + }.getOrElse { CsvCredentialImportResult.Error } + } + + override suspend fun readCsv(fileUri: Uri): CsvCredentialImportResult { + return kotlin.runCatching { + withContext(dispatchers.io()) { + val csv = fileReader.readCsvFile(fileUri) + convertToLoginCredentials(csv) + } + }.getOrElse { CsvCredentialImportResult.Error } + } + + private suspend fun convertToLoginCredentials(csv: String): CsvCredentialImportResult { + return when (val parseResult = parser.parseCsv(csv)) { + is CsvCredentialParser.ParseResult.Success -> { + val toImport = deduplicateAndCleanup(parseResult.credentials) + CsvCredentialImportResult.Success(parseResult.credentials.size, toImport) + } + is CsvCredentialParser.ParseResult.Error -> CsvCredentialImportResult.Error + } + } + + private suspend fun deduplicateAndCleanup(allCredentials: List): List { + val dedupedCredentials = allCredentials.distinct() + val validCredentials = dedupedCredentials.filter { credentialValidator.isValid(it) } + val normalizedDomains = domainNameNormalizer.normalizeDomains(validCredentials) + val entriesNotAlreadySaved = filterNewCredentials(normalizedDomains) + return entriesNotAlreadySaved + } + + private suspend fun filterNewCredentials(credentials: List): List { + return existingCredentialMatchDetector.filterExistingCredentials(credentials) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt new file mode 100644 index 000000000000..a84020b06cef --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Error +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Success +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import de.siegmar.fastcsv.reader.CsvReader +import de.siegmar.fastcsv.reader.CsvRow +import javax.inject.Inject +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface CsvCredentialParser { + suspend fun parseCsv(csv: String): ParseResult + + sealed interface ParseResult { + data class Success(val credentials: List) : ParseResult + data object Error : ParseResult + } +} + +@ContributesBinding(AppScope::class) +class GooglePasswordManagerCsvCredentialParser @Inject constructor( + private val dispatchers: DispatcherProvider, +) : CsvCredentialParser { + + override suspend fun parseCsv(csv: String): ParseResult { + return kotlin.runCatching { + val credentials = convertToCredentials(csv).also { + Timber.i("Parsed CSV. Found %d credentials", it.size) + } + Success(credentials) + }.onFailure { + Timber.e(it, "Failed to parse CSV") + Error + }.getOrElse { + Error + } + } + + /** + * Format of the Google Password Manager CSV is: + * name | url | username | password | note + */ + private suspend fun convertToCredentials(csv: String): List { + return withContext(dispatchers.io()) { + val lines = mutableListOf() + val iter = CsvReader.builder().build(csv).spliterator() + iter.forEachRemaining { lines.add(it) } + Timber.d("Found %d lines in the CSV", lines.size) + + lines.firstOrNull().verifyExpectedFormat() + + // drop the header row + val credentialLines = lines.drop(1) + + return@withContext credentialLines + .mapNotNull { + if (it.fields.size != EXPECTED_HEADERS_ORDERED.size) { + Timber.w("Line is unexpected format. Expected ${EXPECTED_HEADERS_ORDERED.size} parts, found ${it.fields.size}") + return@mapNotNull null + } + + parseToCredential( + domainTitle = it.getField(0).blanksToNull(), + domain = it.getField(1).blanksToNull(), + username = it.getField(2).blanksToNull(), + password = it.getField(3).blanksToNull(), + notes = it.getField(4).blanksToNull(), + ) + } + } + } + + private fun parseToCredential( + domainTitle: String?, + domain: String?, + username: String?, + password: String?, + notes: String?, + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } + + private fun String?.blanksToNull(): String? { + return if (isNullOrBlank()) null else this + } + + private fun CsvRow?.verifyExpectedFormat() { + if (this == null) { + throw IllegalArgumentException("File not recognised as a CSV") + } + + val headers = this.fields + + if (headers.size != EXPECTED_HEADERS_ORDERED.size) { + throw IllegalArgumentException( + "CSV header size does not match expected amount. Expected: ${EXPECTED_HEADERS_ORDERED.size}, found: ${headers.size}", + ) + } + + headers.forEachIndexed { index, value -> + if (value != EXPECTED_HEADERS_ORDERED[index]) { + throw IllegalArgumentException( + "CSV header does not match expected format. Expected: ${EXPECTED_HEADERS_ORDERED[index]}, found: $value", + ) + } + } + } + + companion object { + val EXPECTED_HEADERS_ORDERED = listOf( + "name", + "url", + "username", + "password", + "note", + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt new file mode 100644 index 000000000000..7102ec5844ed --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import android.content.Context +import android.net.Uri +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.io.BufferedReader +import java.io.InputStreamReader +import javax.inject.Inject +import kotlinx.coroutines.withContext + +interface CsvFileReader { + suspend fun readCsvFile(fileUri: Uri): String +} + +@ContributesBinding(AppScope::class) +class ContentResolverFileReader @Inject constructor( + private val context: Context, + private val dispatchers: DispatcherProvider, +) : CsvFileReader { + + override suspend fun readCsvFile(fileUri: Uri): String { + return withContext(dispatchers.io()) { + context.contentResolver.openInputStream(fileUri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)).use { reader -> + buildString { + reader.forEachLine { line -> + append(line).append("\n") + } + } + } + } ?: "" + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt new file mode 100644 index 000000000000..86fc4fd60a03 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface DomainNameNormalizer { + suspend fun normalizeDomains(unnormalized: List): List +} + +@ContributesBinding(AppScope::class) +class DefaultDomainNameNormalizer @Inject constructor( + private val urlMatcher: AutofillUrlMatcher, +) : DomainNameNormalizer { + override suspend fun normalizeDomains(unnormalized: List): List { + return unnormalized.map { + val currentDomain = it.domain ?: return@map it + val normalizedDomain = urlMatcher.cleanRawUrl(currentDomain) + it.copy(domain = normalizedDomain) + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingCredentialMatchDetector.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingCredentialMatchDetector.kt new file mode 100644 index 000000000000..3eba87da73b1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingCredentialMatchDetector.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext + +interface ExistingCredentialMatchDetector { + suspend fun filterExistingCredentials(newCredentials: List): List +} + +@ContributesBinding(AppScope::class) +class DefaultExistingCredentialMatchDetector @Inject constructor( + private val autofillStore: InternalAutofillStore, + private val dispatchers: DispatcherProvider, +) : ExistingCredentialMatchDetector { + + override suspend fun filterExistingCredentials(newCredentials: List): List { + return withContext(dispatchers.io()) { + val existingCredentials = autofillStore.getAllCredentials().firstOrNull() ?: return@withContext newCredentials + + // Filter new credentials to exclude those already in the database + newCredentials.filter { newCredential -> + + existingCredentials.none { existingCredential -> + existingCredential.domain == newCredential.domain && + existingCredential.username == newCredential.username && + existingCredential.password == newCredential.password && + existingCredential.domainTitle == newCredential.domainTitle && + existingCredential.notes == newCredential.notes + } + } + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt new file mode 100644 index 000000000000..1e45b9ae4027 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import android.util.Base64 +import android.util.Base64.DEFAULT +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext + +interface GooglePasswordBlobDecoder { + suspend fun decode(data: String): String +} + +@ContributesBinding(AppScope::class) +class GooglePasswordBlobDecoderImpl @Inject constructor( + private val dispatchers: DispatcherProvider, +) : GooglePasswordBlobDecoder { + + override suspend fun decode(data: String): String { + return withContext(dispatchers.io()) { + kotlin.runCatching { + val base64Data = removeDataTypePrefix(data) + val decodedBytes = Base64.decode(base64Data, DEFAULT) + String(decodedBytes, Charsets.UTF_8) + }.getOrElse { rootCause -> + throw IllegalArgumentException("Unrecognized format", rootCause) + } + } + } + + /** + * String will start with data type. + * e.g., data:text/csv;charset=utf-8;;base64, + */ + private fun removeDataTypePrefix(data: String) = data.split(",")[1] +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt new file mode 100644 index 000000000000..5677cda8d040 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface ImportedCredentialValidator { + fun isValid(loginCredentials: LoginCredentials): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultImportedCredentialValidator @Inject constructor() : ImportedCredentialValidator { + + override fun isValid(loginCredentials: LoginCredentials): Boolean { + with(loginCredentials) { + if (domain?.startsWith(APP_PASSWORD_PREFIX) == true) return false + + if (allFieldsEmpty()) { + return false + } + + return true + } + } + + private fun LoginCredentials.allFieldsEmpty(): Boolean { + return domain.isNullOrBlank() && + username.isNullOrBlank() && + password.isNullOrBlank() && + domainTitle.isNullOrBlank() && + notes.isNullOrBlank() + } + + companion object { + private const val APP_PASSWORD_PREFIX = "android://" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt new file mode 100644 index 000000000000..48039ef97554 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.autofill.impl.importing.gpm.feature + +import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import javax.inject.Inject +import kotlinx.coroutines.withContext +import org.json.JSONObject + +interface AutofillImportPasswordConfigStore { + suspend fun getConfig(): AutofillImportPasswordSettings +} + +data class AutofillImportPasswordSettings( + val canImportFromGooglePasswords: Boolean, + val launchUrlGooglePasswords: String, + val javascriptConfigGooglePasswords: String, +) + +@ContributesBinding(AppScope::class) +class AutofillImportPasswordConfigStoreImpl @Inject constructor( + private val autofillFeature: AutofillFeature, + private val dispatchers: DispatcherProvider, + private val moshi: Moshi, +) : AutofillImportPasswordConfigStore { + + private val jsonAdapter: JsonAdapter by lazy { + moshi.adapter(CanImportFromGooglePasswordManagerConfig::class.java) + } + + override suspend fun getConfig(): AutofillImportPasswordSettings { + return withContext(dispatchers.io()) { + val config = autofillFeature.canImportFromGooglePasswordManager().getSettings()?.let { + runCatching { + jsonAdapter.fromJson(it) + }.getOrNull() + } + val launchUrl = config?.launchUrl ?: LAUNCH_URL_DEFAULT + val javascriptConfig = config?.javascriptConfig?.toString() ?: JAVASCRIPT_CONFIG_DEFAULT + + AutofillImportPasswordSettings( + canImportFromGooglePasswords = autofillFeature.canImportFromGooglePasswordManager().isEnabled(), + launchUrlGooglePasswords = launchUrl, + javascriptConfigGooglePasswords = javascriptConfig, + ) + } + } + + companion object { + internal const val JAVASCRIPT_CONFIG_DEFAULT = "\"{}\"" + internal const val LAUNCH_URL_DEFAULT = "https://passwords.google.com/options?ep=1" + } + + private data class CanImportFromGooglePasswordManagerConfig( + val launchUrl: String? = null, + val javascriptConfig: JSONObject? = null, + ) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/securestorage/SecureStorage.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/securestorage/SecureStorage.kt index e6189599f9f3..28318647b24e 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/securestorage/SecureStorage.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/securestorage/SecureStorage.kt @@ -61,10 +61,11 @@ interface SecureStorage { * This method adds a list of raw plaintext [WebsiteLoginDetailsWithCredentials] into the [SecureStorage]. * If [canAccessSecureStorage] is false when this is invoked, nothing will be done. * + * @return List of IDs that were inserted * @throws [SecureStorageException] if something went wrong while trying to perform the action. See type to get more info on the cause. */ @Throws(SecureStorageException::class) - suspend fun addWebsiteLoginDetailsWithCredentials(credentials: List) + suspend fun addWebsiteLoginDetailsWithCredentials(credentials: List): List /** * This method returns all [WebsiteLoginDetails] with the [domain] stored in the [SecureStorage]. @@ -192,9 +193,9 @@ class RealSecureStorage @Inject constructor( } @Throws(SecureStorageException::class) - override suspend fun addWebsiteLoginDetailsWithCredentials(credentials: List) { - withContext(dispatchers.io()) { - secureStorageRepository.await()?.addWebsiteLoginCredentials(credentials.map { it.toDataEntity() }) + override suspend fun addWebsiteLoginDetailsWithCredentials(credentials: List): List { + return withContext(dispatchers.io()) { + secureStorageRepository.await()?.addWebsiteLoginCredentials(credentials.map { it.toDataEntity() }) ?: emptyList() } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/InternalAutofillStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/InternalAutofillStore.kt index ed11357e61bb..2a27901a1473 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/InternalAutofillStore.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/InternalAutofillStore.kt @@ -105,6 +105,12 @@ interface InternalAutofillStore : AutofillStore { */ suspend fun reinsertCredentials(credentials: LoginCredentials) + /** + * Used to bulk insert credentials + * @return The list of IDs of the inserted credentials + */ + suspend fun bulkInsert(credentials: List): List + /** * Used to reinsert a list of credentials that were previously deleted * This supports the ability to give user a brief opportunity to 'undo' a mass deletion diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStoreTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStoreTest.kt index a784a1618d83..50cb56bb902f 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStoreTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStoreTest.kt @@ -558,8 +558,9 @@ class SecureStoreBackedAutofillStoreTest { return credentialWithId } - override suspend fun addWebsiteLoginDetailsWithCredentials(credentials: List) { + override suspend fun addWebsiteLoginDetailsWithCredentials(credentials: List): List { credentials.forEach { addWebsiteLoginDetailsWithCredentials(it) } + return credentials.map { it.details.id!! } } override suspend fun websiteLoginDetailsForDomain(domain: String): Flow> { diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt new file mode 100644 index 000000000000..14dd914691cd --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt @@ -0,0 +1,151 @@ +package com.duckduckgo.autofill.impl.importing + +import app.cash.turbine.test +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class CredentialImporterImplTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule(StandardTestDispatcher(TestCoroutineScheduler())) + private val autofillStore: InternalAutofillStore = mock() + private val dispatchers = coroutineTestRule.testDispatcherProvider + private val appCoroutineScope: CoroutineScope = coroutineTestRule.testScope + + private val credentialsAlreadyInDb = mutableListOf() + + private val testee = CredentialImporterImpl( + autofillStore = autofillStore, + dispatchers = dispatchers, + appCoroutineScope = appCoroutineScope, + ) + + @Before + fun before() = runTest { + whenever(autofillStore.bulkInsert(any())).thenAnswer { invocation -> + val credentialsToInsert = invocation.getArgument>(0) + + // Filter out the credentials that are already in the database + credentialsToInsert.filterNot { newCredential -> + credentialsAlreadyInDb.any { existingCredential -> + existingCredential == newCredential // Adjust this comparison as needed + } + } + } + } + + @Test + fun whenImportingEmptyListThenResultIsCorrect() = runTest { + listOf().import() + assertResult(numberSkippedExpected = 0, importListSizeExpected = 0) + } + + @Test + fun whenImportingSingleItemNotADuplicateThenResultIsCorrect() = runTest { + listOf(creds()).import() + assertResult(numberSkippedExpected = 0, importListSizeExpected = 1) + } + + @Test + fun whenImportingMultipleItemsNoDuplicatesThenResultIsCorrect() = runTest { + listOf( + creds(username = "username1"), + creds(username = "username2"), + ).import() + assertResult(numberSkippedExpected = 0, importListSizeExpected = 2) + } + + @Test + fun whenImportingSingleItemWhichIsADuplicateThenResultIsCorrect() = runTest { + val duplicatedLogin = creds(username = "username") + duplicatedLogin.treatAsDuplicate() + listOf(duplicatedLogin).import() + assertResult(numberSkippedExpected = 1, importListSizeExpected = 0) + } + + @Test + fun whenImportingMultipleItemsAllDuplicatesThenResultIsCorrect() = runTest { + val duplicatedLogin1 = creds(username = "username1") + val duplicatedLogin2 = creds(username = "username2") + duplicatedLogin1.treatAsDuplicate() + duplicatedLogin2.treatAsDuplicate() + + listOf(duplicatedLogin1, duplicatedLogin2).import() + assertResult(numberSkippedExpected = 2, importListSizeExpected = 0) + } + + @Test + fun whenImportingMultipleItemsSomeDuplicatesThenResultIsCorrect() = runTest { + val duplicatedLogin1 = creds(username = "username1") + val duplicatedLogin2 = creds(username = "username2") + val notADuplicate = creds(username = "username3") + duplicatedLogin1.treatAsDuplicate() + duplicatedLogin2.treatAsDuplicate() + + listOf(duplicatedLogin1, duplicatedLogin2, notADuplicate).import() + assertResult(numberSkippedExpected = 2, importListSizeExpected = 1) + } + + @Test + fun whenAllPasswordsSkippedAlreadyBeforeImportThenResultIsCorrect() = runTest { + listOf().import(originalListSize = 3) + assertResult(numberSkippedExpected = 3, importListSizeExpected = 0) + } + + @Test + fun whenSomePasswordsSkippedAlreadyBeforeImportThenResultIsCorrect() = runTest { + listOf(creds()).import(originalListSize = 3) + assertResult(numberSkippedExpected = 2, importListSizeExpected = 1) + } + + private suspend fun List.import(originalListSize: Int = this.size) { + testee.import(this, originalListSize) + } + + private suspend fun assertResult( + numberSkippedExpected: Int, + importListSizeExpected: Int, + ) { + testee.getImportStatus().test { + awaitItem() + with(awaitItem() as ImportResult.Finished) { + assertEquals("Wrong number of duplicates in result", numberSkippedExpected, numberSkipped) + assertEquals("Wrong import size in result", importListSizeExpected, savedCredentials) + } + } + } + + private fun creds( + id: Long? = null, + domain: String? = "example.com", + username: String? = "username", + password: String? = "password", + notes: String? = "notes", + domainTitle: String? = "example title", + ): LoginCredentials { + return LoginCredentials( + id = id, + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } + + private fun LoginCredentials.treatAsDuplicate() { + credentialsAlreadyInDb.add(this) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt new file mode 100644 index 000000000000..7ef23f71300f --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt @@ -0,0 +1,71 @@ +package com.duckduckgo.autofill.impl.importing + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizerImpl +import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultDomainNameNormalizerTest { + + private val testee = DefaultDomainNameNormalizer(AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl())) + + @Test + fun whenEmptyInputThenEmptyOutput() = runTest { + val input = emptyList() + val output = testee.normalizeDomains(input) + assertTrue(output.isEmpty()) + } + + @Test + fun whenInputDomainAlreadyNormalizedThenIncludedInOutput() = runTest { + val input = listOf(creds(domain = "example.com")) + val output = testee.normalizeDomains(input) + assertEquals(1, output.size) + assertEquals(input.first(), output.first()) + } + + @Test + fun whenInputDomainNotAlreadyNormalizedThenNormalizedAndIncludedInOutput() = runTest { + val input = listOf(creds(domain = "https://example.com/foo/bar")) + val output = testee.normalizeDomains(input) + assertEquals(1, output.size) + assertEquals(input.first().copy(domain = "example.com"), output.first()) + } + + @Test + fun whenInputDomainIsNullThenNormalizedToNullDomain() = runTest { + val input = listOf(creds(domain = null)) + val output = testee.normalizeDomains(input) + assertEquals(1, output.size) + assertEquals(null, output.first().domain) + } + + @Test + fun whenDomainCannotBeNormalizedThenIsIncludedUnmodified() = runTest { + val input = listOf(creds(domain = "unnormalizable")) + val output = testee.normalizeDomains(input) + assertEquals("unnormalizable", output.first().domain) + } + + private fun creds( + domain: String? = null, + username: String? = null, + password: String? = null, + notes: String? = null, + domainTitle: String? = null, + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingCredentialMatchDetectorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingCredentialMatchDetectorTest.kt new file mode 100644 index 000000000000..00faea5fed4d --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingCredentialMatchDetectorTest.kt @@ -0,0 +1,78 @@ +package com.duckduckgo.autofill.impl.importing + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class DefaultExistingCredentialMatchDetectorTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + private val autofillStore: InternalAutofillStore = mock() + + private val testee = DefaultExistingCredentialMatchDetector( + autofillStore = autofillStore, + dispatchers = coroutineTestRule.testDispatcherProvider, + ) + + @Test + fun whenNoStoredPasswordsThenDoesNotRemoveAsDuplicate() = runTest { + configureNoStoredPasswords() + val creds = creds() + val output = testee.filterExistingCredentials(listOf(creds)) + assertEquals(creds, output.first()) + } + + @Test + fun whenStoredPasswordsIsExactMatchThenDuplicatesRemoved() = runTest { + val creds = creds() + configureStoredPasswords(listOf(creds)) + val output = testee.filterExistingCredentials(listOf(creds)) + assertTrue(output.isEmpty()) + } + + @Test + fun whenListContainsSomeDuplicatesAndSomeUniqueThenOnlyUniqueReturned() = runTest { + val unique = creds(username = "1") + val duplicate = creds(username = "2") + configureStoredPasswords(listOf(duplicate)) + val output = testee.filterExistingCredentials(listOf(unique, duplicate)) + assertFalse(output.isEmpty()) + } + + private suspend fun configureNoStoredPasswords() { + whenever(autofillStore.getAllCredentials()).thenReturn(flowOf(emptyList())) + } + + private suspend fun configureStoredPasswords(credentials: List) { + whenever(autofillStore.getAllCredentials()).thenReturn(flowOf(credentials)) + } + + private fun creds( + domain: String = "example.com", + username: String = "username", + password: String = "password", + notes: String = "notes", + domainTitle: String = "example title", + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt new file mode 100644 index 000000000000..801ac953ae17 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt @@ -0,0 +1,99 @@ +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import org.junit.Assert.* +import org.junit.Test + +class DefaultImportedCredentialValidatorTest { + private val testee = DefaultImportedCredentialValidator() + + @Test + fun whenAllFieldsPopulatedThenIsValid() { + assertTrue(testee.isValid(fullyPopulatedCredentials())) + } + + @Test + fun whenNoFieldsPopulatedThenIsInvalid() { + assertFalse(testee.isValid(emptyCredentials())) + } + + @Test + fun whenUsernameMissingThenIsValid() { + val missingUsername = fullyPopulatedCredentials().copy(username = null) + assertTrue(testee.isValid(missingUsername)) + } + + @Test + fun whenPasswordMissingThenIsValid() { + val missingPassword = fullyPopulatedCredentials().copy(password = null) + assertTrue(testee.isValid(missingPassword)) + } + + @Test + fun whenDomainMissingThenIsValid() { + val missingDomain = fullyPopulatedCredentials().copy(domain = null) + assertTrue(testee.isValid(missingDomain)) + } + + @Test + fun whenTitleIsMissingThenIsValid() { + val missingTitle = fullyPopulatedCredentials().copy(domainTitle = null) + assertTrue(testee.isValid(missingTitle)) + } + + @Test + fun whenNotesIsMissingThenIsValid() { + assertTrue(testee.isValid(fullyPopulatedCredentials().copy(notes = null))) + } + + @Test + fun whenUsernameOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(username = "user"))) + } + + @Test + fun whenPasswordOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(password = "password"))) + } + + @Test + fun whenDomainOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(domain = "example.com"))) + } + + @Test + fun whenTitleIsOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(domainTitle = "title"))) + } + + @Test + fun whenNotesIsOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(notes = "notes"))) + } + + @Test + fun whenDomainIsAppPasswordThenIsNotValid() { + val appPassword = fullyPopulatedCredentials().copy(domain = "android://Jz-U_hg==@com.netflix.mediaclient/") + assertFalse(testee.isValid(appPassword)) + } + + private fun fullyPopulatedCredentials(): LoginCredentials { + return LoginCredentials( + username = "username", + password = "password", + domain = "example.com", + domainTitle = "example title", + notes = "notes", + ) + } + + private fun emptyCredentials(): LoginCredentials { + return LoginCredentials( + username = null, + password = null, + domain = null, + domainTitle = null, + notes = null, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoderImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoderImplTest.kt new file mode 100644 index 000000000000..8085f7e3f0cd --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoderImplTest.kt @@ -0,0 +1,52 @@ +package com.duckduckgo.autofill.impl.importing + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GooglePasswordBlobDecoderImplTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + private val testee = GooglePasswordBlobDecoderImpl(dispatchers = coroutineTestRule.testDispatcherProvider) + + @Test + fun whenEmptyCsvDataThenNoCredentialsReturned() = runTest { + val input = "${DATA_TYPE_PREFIX}bmFtZSx1cmwsdXNlcm5hbWUscGFzc3dvcmQsbm90ZQ==" + val expected = "name,url,username,password,note" + assertEquals(expected, testee.decode(input)) + } + + @Test + fun whenValidBlobMultiplePasswordsThenCredentialsReturned() = runTest { + val input = DATA_TYPE_PREFIX + + "bmFtZSx1cmwsdXNlcm5hbWUscGFzc3dvcmQsbm90ZQosaHR0cHM6Ly9leGFtcGxlLmNvbSx0ZXN0LXVzZXIsdGVzdC1wYXNzd29yZCx" + + "0ZXN0LW5vdGVzCmZpbGwuZGV2LGh0dHBzOi8vZmlsbC5kZXYvZm9ybS9sb2dpbi1zaW1wbGUsdGVzdC11c2VyLHRlc3RQYXNzd29yZEZpbGxEZXYs" + .trimIndent() + val expected = """ + name,url,username,password,note + ,https://example.com,test-user,test-password,test-notes + fill.dev,https://fill.dev/form/login-simple,test-user,testPasswordFillDev, + """.trimIndent() + assertEquals(expected, testee.decode(input)) + } + + @Test(expected = IllegalArgumentException::class) + fun whenMissingDataTypeThenExceptionThrown() = runTest { + testee.decode("bmFtZSx1cmwsdXNlcm5hbW") + } + + @Test(expected = IllegalArgumentException::class) + fun whenEmptyInputThenExceptionThrow() = runTest { + testee.decode("") + } + + companion object { + private const val DATA_TYPE_PREFIX = "data:text/csv;charset=utf-8;;base64," + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt new file mode 100644 index 000000000000..9039c77c52d3 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt @@ -0,0 +1,88 @@ +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class GooglePasswordManagerCsvCredentialConverterTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val parser: CsvCredentialParser = mock() + private val fileReader: CsvFileReader = mock() + private val passthroughValidator = object : ImportedCredentialValidator { + override fun isValid(loginCredentials: LoginCredentials): Boolean = true + } + private val passthroughDomainNormalizer = object : DomainNameNormalizer { + override suspend fun normalizeDomains(unnormalized: List): List { + return unnormalized + } + } + private val blobDecoder: GooglePasswordBlobDecoder = mock() + private val passthroughExistingCredentialMatchDetector = object : ExistingCredentialMatchDetector { + override suspend fun filterExistingCredentials(newCredentials: List): List { + return newCredentials + } + } + + private val testee = GooglePasswordManagerCsvCredentialConverter( + parser = parser, + fileReader = fileReader, + credentialValidator = passthroughValidator, + domainNameNormalizer = passthroughDomainNormalizer, + dispatchers = coroutineTestRule.testDispatcherProvider, + blobDecoder = blobDecoder, + existingCredentialMatchDetector = passthroughExistingCredentialMatchDetector, + ) + + @Before + fun before() = runTest { + whenever(blobDecoder.decode(any())).thenReturn("") + } + + @Test + fun whenBlobDecodedIntoEmptyListThenSuccessReturned() = runTest { + val result = configureParseResult(emptyList()) + assertEquals(0, result.numberCredentialsInSource) + assertEquals(0, result.loginCredentialsToImport.size) + } + + @Test + fun whenBlobDecodedIntoSingleItemNotADuplicateListThenSuccessReturned() = runTest { + val importSource = listOf(creds()) + val result = configureParseResult(importSource) + assertEquals(1, result.numberCredentialsInSource) + assertEquals(1, result.loginCredentialsToImport.size) + } + + private suspend fun configureParseResult(passwords: List): CsvCredentialImportResult.Success { + whenever(parser.parseCsv(any())).thenReturn(ParseResult.Success(passwords)) + return testee.readCsv("") as CsvCredentialImportResult.Success + } + + private fun creds( + domain: String? = "example.com", + username: String? = "username", + password: String? = "password", + notes: String? = "notes", + domainTitle: String? = "example title", + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt new file mode 100644 index 000000000000..d48ccbe6e9cd --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt @@ -0,0 +1,183 @@ +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Success +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.FileUtilities +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class GooglePasswordManagerCsvCredentialParserTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val testee = GooglePasswordManagerCsvCredentialParser( + dispatchers = coroutineTestRule.testDispatcherProvider, + ) + + @Test + fun whenEmptyStringThenNoPasswords() = runTest { + val result = testee.parseCsv("") + assertTrue(result is CsvCredentialParser.ParseResult.Error) + } + + @Test + fun whenHeaderRowOnlyThenNoCredentials() = runTest { + val csv = "gpm_import_header_row_only".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(0, credentials.size) + } + } + + @Test + fun whenHeaderRowHasUnknownFieldThenNoCredentials() = runTest { + val csv = "gpm_import_header_row_unknown_field".readFile() + val result = testee.parseCsv(csv) + assertTrue(result is CsvCredentialParser.ParseResult.Error) + } + + @Test + fun whenHeadersRowAndOneCredentialsRowThen1Credential() = runTest { + val csv = "gpm_import_one_valid_basic_password".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatchesCreds1() + } + } + + @Test + fun whenHeadersRowAndTwoDifferentPasswordsThen2Passwords() = runTest { + val csv = "gpm_import_two_valid_basic_passwords".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(2, credentials.size) + credentials[0].verifyMatchesCreds1() + credentials[1].verifyMatchesCreds2() + } + } + + @Test + fun whenTwoIdenticalPasswordsThen2Passwords() = runTest { + val csv = "gpm_import_two_valid_identical_passwords".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(2, credentials.size) + credentials[0].verifyMatchesCreds1() + credentials[1].verifyMatchesCreds1() + } + } + + @Test + fun whenPasswordContainsACommaThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_password_has_a_comma".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + val expected = LoginCredentials( + domain = "https://example.com", + domainTitle = "example.com", + username = "user", + password = "password, a comma it has", + notes = "notes", + ) + credentials.first().verifyMatches(expected) + } + } + + @Test + fun whenPasswordContainsOtherSpecialCharactersThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_password_has_special_characters".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + val expected = creds1.copy(password = "p\$ssw0rd`\"[]'\\") + credentials.first().verifyMatches(expected) + } + } + + @Test + fun whenNotesIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_notes".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(notes = null)) + } + } + + @Test + fun whenUsernameIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_username".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(username = null)) + } + } + + @Test + fun whenPasswordIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_password".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(password = null)) + } + } + + @Test + fun whenTitleIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_title".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(domainTitle = null)) + } + } + + @Test + fun whenDomainIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_domain".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(domain = null)) + } + } + + private fun LoginCredentials.verifyMatchesCreds1() = verifyMatches(creds1) + private fun LoginCredentials.verifyMatchesCreds2() = verifyMatches(creds2) + + private fun LoginCredentials.verifyMatches(expected: LoginCredentials) { + assertEquals(expected.domainTitle, domainTitle) + assertEquals(expected.domain, domain) + assertEquals(expected.username, username) + assertEquals(expected.password, password) + assertEquals(expected.notes, notes) + } + + private val creds1 = LoginCredentials( + domain = "https://example.com", + domainTitle = "example.com", + username = "user", + password = "password", + notes = "note", + ) + + private val creds2 = LoginCredentials( + domain = "https://example.net", + domainTitle = "example.net", + username = "user2", + password = "password2", + notes = "note2", + ) + + private fun String.readFile(): String { + val fileContents = kotlin.runCatching { + FileUtilities.loadText( + GooglePasswordManagerCsvCredentialParserTest::class.java.classLoader!!, + "csv/autofill/$this.csv", + ) + }.getOrNull() + + if (fileContents == null) { + throw IllegalArgumentException("Failed to load specified CSV file: $this") + } + return fileContents + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt new file mode 100644 index 000000000000..5264b3e54335 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt @@ -0,0 +1,90 @@ +package com.duckduckgo.autofill.impl.importing.gpm.feature + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStoreImpl.Companion.JAVASCRIPT_CONFIG_DEFAULT +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStoreImpl.Companion.LAUNCH_URL_DEFAULT +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.json.JSONObjectAdapter +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AutofillImportPasswordConfigStoreImplTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() + private val adapter: JsonAdapter = moshi.adapter(Config::class.java) + + private val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) + private val testee = AutofillImportPasswordConfigStoreImpl( + autofillFeature = autofillFeature, + dispatchers = coroutineTestRule.testDispatcherProvider, + moshi = moshi, + ) + + @Test + fun whenFeatureFlagEnabledThenCanImportGooglePasswordsConfigIsEnabled() = runTest { + configureFeature(true) + assertTrue(testee.getConfig().canImportFromGooglePasswords) + } + + @Test + fun whenFeatureFlagEnabledThenCanImportGooglePasswordsConfigIsDisabled() = runTest { + configureFeature(false) + assertFalse(testee.getConfig().canImportFromGooglePasswords) + } + + @Test + fun whenLaunchUrlNotSpecifiedInConfigThenDefaultUsed() = runTest { + configureFeature(config = Config()) + assertEquals(LAUNCH_URL_DEFAULT, testee.getConfig().launchUrlGooglePasswords) + } + + @Test + fun whenLaunchUrlSpecifiedInConfigThenOverridesDefault() = runTest { + configureFeature(config = Config(launchUrl = "https://example.com")) + assertEquals("https://example.com", testee.getConfig().launchUrlGooglePasswords) + } + + @Test + fun whenJavascriptConfigNotSpecifiedInConfigThenDefaultUsed() = runTest { + configureFeature(config = Config()) + assertEquals(JAVASCRIPT_CONFIG_DEFAULT, testee.getConfig().javascriptConfigGooglePasswords) + } + + @Test + fun whenJavascriptConfigSpecifiedInConfigThenOverridesDefault() = runTest { + configureFeature(config = Config(javascriptConfig = JavaScriptConfig(key = "value", domains = listOf("foo, bar")))) + assertEquals("""{"domains":["foo, bar"],"key":"value"}""", testee.getConfig().javascriptConfigGooglePasswords) + } + + @SuppressLint("DenyListedApi") + private fun configureFeature(enabled: Boolean = true, config: Config = Config()) { + autofillFeature.canImportFromGooglePasswordManager().setRawStoredState( + State( + remoteEnableState = enabled, + settings = adapter.toJson(config), + ), + ) + } + + private data class Config( + val launchUrl: String? = null, + val javascriptConfig: JavaScriptConfig? = null, + ) + private data class JavaScriptConfig( + val key: String, + val domains: List, + ) +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/urlmatcher/AutofillDomainNameUrlMatcherTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/urlmatcher/AutofillDomainNameUrlMatcherTest.kt index fed68f4326ae..53254554e9bc 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/urlmatcher/AutofillDomainNameUrlMatcherTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/urlmatcher/AutofillDomainNameUrlMatcherTest.kt @@ -255,6 +255,11 @@ class AutofillDomainNameUrlMatcherTest { assertFalse(testee.matchingForAutofill(visitedSite, savedSite)) } + @Test + fun whenCleanRawUrlWithEmptyStringThenEmptyStringReturned() { + assertEquals("", testee.cleanRawUrl("")) + } + @Test fun whenCleanRawUrlThenReturnOnlySchemeAndDomain() { assertEquals("www.foo.com", testee.cleanRawUrl("https://www.foo.com/path/to/foo?key=value")) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/FakeSecureStorage.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/FakeSecureStorage.kt index f07fa89ec1a2..b467677d44b7 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/FakeSecureStorage.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/FakeSecureStorage.kt @@ -44,8 +44,9 @@ internal class FakeSecureStorage : SecureStorage { return newLogin } - override suspend fun addWebsiteLoginDetailsWithCredentials(credentials: List) { + override suspend fun addWebsiteLoginDetailsWithCredentials(credentials: List): List { credentials.forEach { addWebsiteLoginDetailsWithCredentials(it) } + return credentials.map { it.details.id!! } } override suspend fun websiteLoginDetailsForDomain(domain: String): Flow> { diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_only.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_only.csv new file mode 100644 index 000000000000..56ea2b0b131e --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_only.csv @@ -0,0 +1 @@ +name,url,username,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_unknown_field.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_unknown_field.csv new file mode 100644 index 000000000000..1d3982e6b801 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_header_row_unknown_field.csv @@ -0,0 +1 @@ +name,url,username,password,note,unknown \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_domain.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_domain.csv new file mode 100644 index 000000000000..12439aed6c7a --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_domain.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,,user,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_notes.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_notes.csv new file mode 100644 index 000000000000..d23607118f6b --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_notes.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,"password", \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_password.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_password.csv new file mode 100644 index 000000000000..11d0c635a6c6 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_password.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_title.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_title.csv new file mode 100644 index 000000000000..30707aac64fb --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_title.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +,https://example.com,user,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_username.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_username.csv new file mode 100644 index 000000000000..7a8782bb31f9 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_username.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_one_valid_basic_password.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_one_valid_basic_password.csv new file mode 100644 index 000000000000..a72dbddaa359 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_one_valid_basic_password.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,password,note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_a_comma.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_a_comma.csv new file mode 100644 index 000000000000..a86c81f1b71a --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_a_comma.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,"password, a comma it has",notes \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_special_characters.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_special_characters.csv new file mode 100644 index 000000000000..4e59eadb4927 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_password_has_special_characters.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,https://example.com,user,"p$ssw0rd`""[]'\",note \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_basic_passwords.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_basic_passwords.csv new file mode 100644 index 000000000000..b873993e01f3 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_basic_passwords.csv @@ -0,0 +1,3 @@ +name,url,username,password,note +example.com,https://example.com,user,password,note +example.net,https://example.net,user2,password2,note2 \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_identical_passwords.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_identical_passwords.csv new file mode 100644 index 000000000000..513747e4a57c --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_two_valid_identical_passwords.csv @@ -0,0 +1,3 @@ +name,url,username,password,note +example.com,https://example.com,user,password,note +example.com,https://example.com,user,password,note \ No newline at end of file diff --git a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt index 5ea4fc90179d..d3914bfd444d 100644 --- a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt +++ b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt @@ -16,15 +16,19 @@ package com.duckduckgo.autofill.internal +import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreen import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.InternalDevSettings @@ -33,6 +37,12 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptEnvironmentConfiguration import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementRepository +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.InProgress +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -46,10 +56,12 @@ import com.duckduckgo.common.ui.view.button.ButtonType.GHOST_ALT import com.duckduckgo.common.ui.view.dialog.RadioListAlertDialogBuilder import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.google.android.material.snackbar.Snackbar import java.text.SimpleDateFormat import javax.inject.Inject import kotlinx.coroutines.flow.first @@ -77,6 +89,12 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var autofillStore: InternalAutofillStore + @Inject + lateinit var credentialImporter: CredentialImporter + + @Inject + lateinit var browserNav: BrowserNav + @Inject lateinit var autofillPrefsStore: AutofillPrefsStore @@ -103,6 +121,64 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var reportBreakageDataStore: AutofillSiteBreakageReportingDataStore + @Inject + lateinit var csvCredentialConverter: CsvCredentialConverter + + @Inject + lateinit var autofillImportPasswordConfigStore: AutofillImportPasswordConfigStore + + private var passwordImportWatcher = ConflatedJob() + + // used to output duration of import + private var importStartTime: Long = 0 + + private val importCsvLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data: Intent? = result.data + val fileUrl = data?.data + + logcat { "onActivityResult for CSV file request. resultCode=${result.resultCode}. uri=$fileUrl" } + if (fileUrl != null) { + lifecycleScope.launch(dispatchers.io()) { + when (val parseResult = csvCredentialConverter.readCsv(fileUrl)) { + is CsvCredentialImportResult.Success -> { + importStartTime = System.currentTimeMillis() + + credentialImporter.import( + parseResult.loginCredentialsToImport, + parseResult.numberCredentialsInSource, + ) + observePasswordInputUpdates() + } + + is CsvCredentialImportResult.Error -> { + "Failed to import passwords due to an error".showSnackbar() + } + } + } + } + } + } + + private fun observePasswordInputUpdates() { + passwordImportWatcher += lifecycleScope.launch { + credentialImporter.getImportStatus().collect { + when (it) { + is InProgress -> { + logcat { "import status: $it" } + } + + is Finished -> { + passwordImportWatcher.cancel() + val duration = System.currentTimeMillis() - importStartTime + logcat { "Imported ${it.savedCredentials} passwords, skipped ${it.numberSkipped}. Took ${duration}ms" } + "Imported ${it.savedCredentials} passwords".showSnackbar() + } + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) @@ -170,6 +246,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { configureEngagementEventHandlers() configureReportBreakagesHandlers() configureDeclineCounterHandlers() + configureImportPasswordsEventHandlers() } private fun configureReportBreakagesHandlers() { @@ -181,6 +258,24 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + @SuppressLint("QueryPermissionsNeeded") + private fun configureImportPasswordsEventHandlers() { + binding.importPasswordsLaunchGooglePasswordWebpage.setClickListener { + lifecycleScope.launch(dispatchers.io()) { + val googlePasswordsUrl = autofillImportPasswordConfigStore.getConfig().launchUrlGooglePasswords + startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, googlePasswordsUrl)) + } + } + + binding.importPasswordsImportCsv.setClickListener { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + importCsvLauncher.launch(intent) + } + } + private fun configureEngagementEventHandlers() { binding.engagementClearEngagementHistoryButton.setOnClickListener { lifecycleScope.launch(dispatchers.io()) { @@ -444,6 +539,10 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + private fun String.showSnackbar(duration: Int = Snackbar.LENGTH_LONG) { + Snackbar.make(binding.root, this, duration).show() + } + private fun Context.daysInstalledOverrideOptions(): List> { return listOf( Pair(getString(R.string.autofillDevSettingsOverrideMaxInstalledOptionNever), -1), diff --git a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml index 9018dec4ab59..1646042b7d70 100644 --- a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml +++ b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml @@ -85,6 +85,27 @@ android:layout_height="wrap_content" app:primaryText="@string/autofillDevSettingsViewSavedLogins" /> + + + + + + + + diff --git a/autofill/autofill-internal/src/main/res/values/donottranslate.xml b/autofill/autofill-internal/src/main/res/values/donottranslate.xml index 247348c0325d..fc2841729d65 100644 --- a/autofill/autofill-internal/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-internal/src/main/res/values/donottranslate.xml @@ -37,6 +37,10 @@ Number of sites: %1$d Add sample site (fill.dev) + Import Passwords + Launch Google Passwords (normal tab) + Import CSV + Maximum number of days since install OK Cancel diff --git a/autofill/autofill-store/src/main/java/com/duckduckgo/securestorage/store/SecureStorageRepository.kt b/autofill/autofill-store/src/main/java/com/duckduckgo/securestorage/store/SecureStorageRepository.kt index 87d367690c86..89f68fea85fa 100644 --- a/autofill/autofill-store/src/main/java/com/duckduckgo/securestorage/store/SecureStorageRepository.kt +++ b/autofill/autofill-store/src/main/java/com/duckduckgo/securestorage/store/SecureStorageRepository.kt @@ -32,7 +32,7 @@ interface SecureStorageRepository { suspend fun addWebsiteLoginCredential(websiteLoginCredentials: WebsiteLoginCredentialsEntity): WebsiteLoginCredentialsEntity? - suspend fun addWebsiteLoginCredentials(list: List) + suspend fun addWebsiteLoginCredentials(list: List): List suspend fun websiteLoginCredentialsForDomain(domain: String): Flow> @@ -65,8 +65,8 @@ class RealSecureStorageRepository( return websiteLoginCredentialsDao.getWebsiteLoginCredentialsById(newCredentialId) } - override suspend fun addWebsiteLoginCredentials(list: List) { - websiteLoginCredentialsDao.insert(list) + override suspend fun addWebsiteLoginCredentials(list: List): List { + return websiteLoginCredentialsDao.insert(list) } override suspend fun websiteLoginCredentialsForDomain(domain: String): Flow> { diff --git a/autofill/autofill-store/src/main/java/com/duckduckgo/securestorage/store/db/WebsiteLoginCredentialsDao.kt b/autofill/autofill-store/src/main/java/com/duckduckgo/securestorage/store/db/WebsiteLoginCredentialsDao.kt index 513113fb29fc..35c081e15f6b 100644 --- a/autofill/autofill-store/src/main/java/com/duckduckgo/securestorage/store/db/WebsiteLoginCredentialsDao.kt +++ b/autofill/autofill-store/src/main/java/com/duckduckgo/securestorage/store/db/WebsiteLoginCredentialsDao.kt @@ -31,7 +31,7 @@ interface WebsiteLoginCredentialsDao { fun insert(loginCredentials: WebsiteLoginCredentialsEntity): Long @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(loginCredentials: List) + fun insert(loginCredentials: List): List @Update(onConflict = OnConflictStrategy.REPLACE) fun update(loginCredentials: WebsiteLoginCredentialsEntity) diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt index a96eb9f44eb5..0e7e877c8020 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt @@ -93,6 +93,8 @@ interface TabRepository { suspend fun deleteAll() + suspend fun getSelectedTab(): TabEntity? + suspend fun select(tabId: String) fun updateTabPreviewImage( @@ -107,6 +109,8 @@ interface TabRepository { suspend fun selectByUrlOrNewTab(url: String) + suspend fun getTabId(url: String): String? + suspend fun setIsUserNew(isUserNew: Boolean) suspend fun setTabLayoutType(layoutType: LayoutType) diff --git a/common/common-test/build.gradle b/common/common-test/build.gradle index 1d813a7d3da1..755ba72bccb8 100644 --- a/common/common-test/build.gradle +++ b/common/common-test/build.gradle @@ -16,6 +16,7 @@ dependencies { implementation "io.reactivex.rxjava2:rxjava:_" implementation "io.reactivex.rxjava2:rxandroid:_" + implementation "com.squareup.moshi:moshi-kotlin:_" implementation Square.okHttp3.okHttp implementation KotlinX.coroutines.core // api because TestDispatcher is in the CoroutineTestRule public API diff --git a/common/common-test/src/main/java/com/duckduckgo/common/test/json/JSONObjectAdapter.kt b/common/common-test/src/main/java/com/duckduckgo/common/test/json/JSONObjectAdapter.kt new file mode 100644 index 000000000000..59f7271944f9 --- /dev/null +++ b/common/common-test/src/main/java/com/duckduckgo/common/test/json/JSONObjectAdapter.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.common.test.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import okio.Buffer +import org.json.JSONException +import org.json.JSONObject + +class JSONObjectAdapter { + + @FromJson + fun fromJson(reader: JsonReader): JSONObject? { + // Here we're expecting the JSON object, it is processed as Map by Moshi + return (reader.readJsonValue() as? Map<*, *>)?.let { data -> + try { + JSONObject(data) + } catch (e: JSONException) { + // Handle exception + return null + } + } + } + + @ToJson + fun toJson( + writer: JsonWriter, + value: JSONObject?, + ) { + value?.let { writer.run { value(Buffer().writeUtf8(value.toString())) } } + } +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/TextAlertDialogBuilder.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/TextAlertDialogBuilder.kt index 5faeaa1f0a96..fc7850d47afb 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/TextAlertDialogBuilder.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/TextAlertDialogBuilder.kt @@ -17,13 +17,23 @@ package com.duckduckgo.common.ui.view.dialog import android.content.Context +import android.text.Annotation +import android.text.SpannableString +import android.text.Spanned +import android.text.SpannedString +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.UnderlineSpan import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import com.duckduckgo.common.ui.view.button.ButtonType import com.duckduckgo.common.ui.view.button.DaxButton +import com.duckduckgo.common.ui.view.getColorFromAttr import com.duckduckgo.common.ui.view.gone import com.duckduckgo.common.ui.view.show import com.duckduckgo.mobile.android.R @@ -48,6 +58,7 @@ class TextAlertDialogBuilder(val context: Context) : DaxAlertDialog { private var listener: EventListener = DefaultEventListener() private var titleText: CharSequence = "" private var messageText: CharSequence = "" + private var messageClickable: Boolean = false private var headerImageDrawableId = 0 private var positiveButtonText: CharSequence = "" private var positiveButtonType: ButtonType = ButtonType.PRIMARY @@ -73,6 +84,47 @@ class TextAlertDialogBuilder(val context: Context) : DaxAlertDialog { return this } + fun setClickableMessage(textSequence: CharSequence, annotation: String, onClick: () -> Unit): TextAlertDialogBuilder { + val fullText = textSequence as SpannedString + val spannableString = SpannableString(fullText) + val annotations = fullText.getSpans(0, fullText.length, Annotation::class.java) + val clickableSpan = object : ClickableSpan() { + override fun onClick(widget: View) { + onClick() + } + } + + annotations?.find { it.value == annotation }?.let { + spannableString.apply { + setSpan( + clickableSpan, + fullText.getSpanStart(it), + fullText.getSpanEnd(it), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + setSpan( + UnderlineSpan(), + fullText.getSpanStart(it), + fullText.getSpanEnd(it), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + setSpan( + ForegroundColorSpan( + context.getColorFromAttr(R.attr.daxColorAccentBlue), + ), + fullText.getSpanStart(it), + fullText.getSpanEnd(it), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + + messageText = spannableString + messageClickable = true + + return this + } + fun setTitle(text: CharSequence): TextAlertDialogBuilder { titleText = text return this @@ -169,6 +221,9 @@ class TextAlertDialogBuilder(val context: Context) : DaxAlertDialog { binding.textAlertDialogMessage.gone() } else { binding.textAlertDialogMessage.text = messageText + if (messageClickable) { + binding.textAlertDialogMessage.movementMethod = LinkMovementMethod.getInstance() + } } setButtons(binding, dialog) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RadioListItem.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/RadioListItem.kt similarity index 84% rename from network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RadioListItem.kt rename to common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/RadioListItem.kt index 541a00fc39c8..7a9d7c63d5e5 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RadioListItem.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/RadioListItem.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 DuckDuckGo + * Copyright (c) 2024 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.networkprotection.impl.settings.geoswitching +package com.duckduckgo.common.ui.view.listitem import android.content.Context import android.util.AttributeSet @@ -26,13 +26,13 @@ import com.duckduckgo.common.ui.view.listitem.DaxListItem.ImageBackground import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.view.text.DaxTextView import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.networkprotection.impl.R -import com.duckduckgo.networkprotection.impl.databinding.ViewRadioListItemBinding +import com.duckduckgo.mobile.android.R +import com.duckduckgo.mobile.android.databinding.ViewRadioListItemBinding class RadioListItem @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = com.duckduckgo.mobile.android.R.attr.twoLineListItemStyle, + defStyleAttr: Int = R.attr.twoLineListItemStyle, ) : ConstraintLayout(context, attrs, defStyleAttr) { private val binding: ViewRadioListItemBinding by viewBinding() @@ -53,8 +53,15 @@ class RadioListItem @JvmOverloads constructor( attrs, R.styleable.RadioListItem, 0, - com.duckduckgo.mobile.android.R.style.Widget_DuckDuckGo_TwoLineListItem, + R.style.Widget_DuckDuckGo_TwoLineListItem, ).apply { + if (hasValue(R.styleable.RadioListItem_android_minHeight)) { + binding.itemContainer.minHeight = + getDimensionPixelSize(R.styleable.RadioListItem_android_minHeight, resources.getDimensionPixelSize(R.dimen.oneLineItemHeight)) + } else { + binding.itemContainer.minHeight = resources.getDimensionPixelSize(R.dimen.oneLineItemHeight) + } + binding.radioButton.isChecked = getBoolean(R.styleable.RadioListItem_android_checked, false) binding.primaryText.text = getString(R.styleable.RadioListItem_primaryText) @@ -98,6 +105,7 @@ class RadioListItem @JvmOverloads constructor( } fun setClickListener(onClick: () -> Unit) { + binding.radioButton.setOnClickListener { onClick() } binding.itemContainer.setOnClickListener { onClick() } } @@ -124,4 +132,8 @@ class RadioListItem @JvmOverloads constructor( fun setTrailingIconClickListener(onClick: (View) -> Unit) { trailingIconContainer.setOnClickListener { onClick(trailingIconContainer) } } + + fun setChecked(checked: Boolean) { + radioButton.isChecked = checked + } } diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/text/DaxTextInput.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/text/DaxTextInput.kt index 23b3a2152cd0..211962f4f21e 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/text/DaxTextInput.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/text/DaxTextInput.kt @@ -71,6 +71,7 @@ interface TextInput { @DrawableRes endIconRes: Int, contentDescription: String? = null, ) + fun setSelectAllOnFocus(boolean: Boolean) fun removeEndIcon() @@ -137,6 +138,9 @@ class DaxTextInput @JvmOverloads constructor( binding.internalEditText.inputType = binding.internalEditText.inputType or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS } + val enabled = getBoolean(R.styleable.DaxTextInput_android_enabled, true) + isEnabled = enabled + setFocusListener() recycle() @@ -200,11 +204,16 @@ class DaxTextInput @JvmOverloads constructor( get() = binding.internalEditText.isEnabled set(value) { binding.internalEditText.isEnabled = value - binding.internalInputLayout.isEnabled = value - binding.root.alpha = if (value) ENABLED_OPACITY else DISABLED_OPACITY handleIsEditableChangeForEndIcon(value) } + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + binding.internalInputLayout.isEnabled = enabled + binding.internalPasswordIcon.isEnabled = enabled + binding.root.alpha = if (enabled) ENABLED_OPACITY else DISABLED_OPACITY + } + override var error: String? get() = binding.internalInputLayout.error.toString() set(value) { @@ -218,8 +227,6 @@ class DaxTextInput @JvmOverloads constructor( } private fun handleIsEditableChangeForEndIcon(isEditable: Boolean) { - binding.internalPasswordIcon.isEnabled = isEditable - if (binding.internalInputLayout.endIconMode != END_ICON_NONE) { binding.internalInputLayout.isEndIconVisible = !isEditable if (isEditable && isPassword) { @@ -264,6 +271,10 @@ class DaxTextInput @JvmOverloads constructor( } } + override fun setSelectAllOnFocus(boolean: Boolean) { + binding.internalEditText.setSelectAllOnFocus(boolean) + } + override fun removeEndIcon() { binding.internalInputLayout.apply { endIconMode = END_ICON_NONE diff --git a/common/common-ui/src/main/res/color/text_input_color_selector.xml b/common/common-ui/src/main/res/color/text_input_color_selector.xml index e5a68c72aeff..db2d3e3d8831 100644 --- a/common/common-ui/src/main/res/color/text_input_color_selector.xml +++ b/common/common-ui/src/main/res/color/text_input_color_selector.xml @@ -17,5 +17,6 @@ + \ No newline at end of file diff --git a/common/common-ui/src/main/res/layout/component_text_input_view.xml b/common/common-ui/src/main/res/layout/component_text_input_view.xml index daadad3359cf..f2ef2ed1ead1 100644 --- a/common/common-ui/src/main/res/layout/component_text_input_view.xml +++ b/common/common-ui/src/main/res/layout/component_text_input_view.xml @@ -179,6 +179,35 @@ android:hint="Error" android:text="This is an error" /> + + + + + + diff --git a/common/common-ui/src/main/res/layout/dialog_text_alert.xml b/common/common-ui/src/main/res/layout/dialog_text_alert.xml index 706a28bacab1..2f4c4588ae8c 100644 --- a/common/common-ui/src/main/res/layout/dialog_text_alert.xml +++ b/common/common-ui/src/main/res/layout/dialog_text_alert.xml @@ -16,7 +16,7 @@ ~ limitations under the License. --> - - + app:layout_constraintTop_toTopOf="parent"> - + - + + + + + + + + + + + + + - - - \ No newline at end of file + \ No newline at end of file diff --git a/network-protection/network-protection-impl/src/main/res/layout/view_radio_list_item.xml b/common/common-ui/src/main/res/layout/view_radio_list_item.xml similarity index 99% rename from network-protection/network-protection-impl/src/main/res/layout/view_radio_list_item.xml rename to common/common-ui/src/main/res/layout/view_radio_list_item.xml index 24b9f26e56b3..b1682a3e9ad9 100644 --- a/network-protection/network-protection-impl/src/main/res/layout/view_radio_list_item.xml +++ b/common/common-ui/src/main/res/layout/view_radio_list_item.xml @@ -32,6 +32,7 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/keyline_3" android:minWidth="0dp" + android:minHeight="0dp" android:padding="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/network-protection/network-protection-impl/src/main/res/values/attrs-radio-list-item.xml b/common/common-ui/src/main/res/values/attrs-radio-list-item.xml similarity index 96% rename from network-protection/network-protection-impl/src/main/res/values/attrs-radio-list-item.xml rename to common/common-ui/src/main/res/values/attrs-radio-list-item.xml index 8ee6649c0be1..ad4d814b0555 100644 --- a/network-protection/network-protection-impl/src/main/res/values/attrs-radio-list-item.xml +++ b/common/common-ui/src/main/res/values/attrs-radio-list-item.xml @@ -27,5 +27,6 @@ + \ No newline at end of file diff --git a/common/common-ui/src/main/res/values/attrs-text-input.xml b/common/common-ui/src/main/res/values/attrs-text-input.xml index ac406f1a9ebb..2e7d3c06ee7d 100644 --- a/common/common-ui/src/main/res/values/attrs-text-input.xml +++ b/common/common-ui/src/main/res/values/attrs-text-input.xml @@ -18,6 +18,7 @@ + diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt index 5dbb21893018..0156af20d780 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt @@ -56,6 +56,9 @@ val Uri.isHttps: Boolean val Uri.toHttps: Uri get() = buildUpon().scheme(UrlScheme.https).build() +val Uri.isHttpOrHttps: Boolean + get() = isHttp || isHttps + val Uri.hasIpHost: Boolean get() { return baseHost?.matches(IP_REGEX) ?: false diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/headers/CustomHeadersProvider.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/headers/CustomHeadersProvider.kt new file mode 100644 index 000000000000..27a598877e9f --- /dev/null +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/headers/CustomHeadersProvider.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.common.utils.plugins.headers + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface CustomHeadersProvider { + + /** + * Returns a [Map] of custom headers that should be added to the request. + * @param url The url of the request. + * @return A [Map] of headers. + */ + fun getCustomHeaders(url: String): Map + + /** + * A plugin point for custom headers that should be added to all requests. + */ + @ContributesPluginPoint(AppScope::class) + interface CustomHeadersPlugin { + + /** + * Returns a [Map] of headers that should be added to the request IF the url passed allows for them to be + * added. + * @param url The url of the request. + * @return A [Map] of headers. + */ + fun getHeaders(url: String): Map + } +} + +@ContributesBinding(AppScope::class) +class RealCustomHeadersProvider @Inject constructor( + private val customHeadersPluginPoint: PluginPoint, +) : CustomHeadersProvider { + + override fun getCustomHeaders(url: String): Map { + val customHeaders = mutableMapOf() + customHeadersPluginPoint.getPlugins().forEach { + customHeaders.putAll(it.getHeaders(url)) + } + return customHeaders.toMap() + } +} diff --git a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt index ec42974b27c6..b70287f7ba39 100644 --- a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt +++ b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt @@ -22,6 +22,10 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import androidx.fragment.app.FragmentManager +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.AUTO +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.OVERLAY +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.SERP +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.SERP_AUTO import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled @@ -31,9 +35,6 @@ const val YOUTUBE_HOST = "youtube.com" const val YOUTUBE_MOBILE_HOST = "m.youtube.com" const val ORIGIN_QUERY_PARAM = "origin" const val ORIGIN_QUERY_PARAM_SERP = "serp" -const val ORIGIN_QUERY_PARAM_SERP_AUTO = "serp_auto" -const val ORIGIN_QUERY_PARAM_OVERLAY = "overlay" -const val ORIGIN_QUERY_PARAM_AUTO = "auto" /** * DuckPlayer interface provides a set of methods for interacting with the DuckPlayer. @@ -128,7 +129,7 @@ interface DuckPlayer { * @param uri The URI to check. * @return True if the URI is a YouTube no-cookie URI, false otherwise. */ - suspend fun isSimulatedYoutubeNoCookie(uri: Uri): Boolean + fun isSimulatedYoutubeNoCookie(uri: Uri): Boolean /** * Checks if a URI is a YouTube watch URL. @@ -146,14 +147,6 @@ interface DuckPlayer { */ fun isYouTubeUrl(uri: Uri): Boolean - /** - * Checks if a string is a YouTube no-cookie URI. - * - * @param uri The string to check. - * @return True if the string is a YouTube no-cookie URI, false otherwise. - */ - suspend fun isSimulatedYoutubeNoCookie(uri: String): Boolean - /** * Notify Duck Player of a resource request and allow Duck Player to return the data. * @@ -180,14 +173,31 @@ interface DuckPlayer { * @param destinationUrl The destination URL. * @return True if the URL should launch Duck Player, false otherwise. */ - suspend fun willNavigateToDuckPlayer( + fun willNavigateToDuckPlayer( destinationUrl: Uri, ): Boolean + /** + * Checks whether a duck Player will be opened in a new tab based on RC flag and user settings + * + * @return True if should open Duck Player in a new tab, false otherwise. + */ fun shouldOpenDuckPlayerInNewTab(): OpenDuckPlayerInNewTab + /** + * Observes whether a duck Player will be opened in a new tab based on RC flag and user settings + * + * @return Flow. True if should open Duck Player in a new tab, false otherwise. + */ fun observeShouldOpenInNewTab(): Flow + /** + * Sets the DuckPlayer origin. + * + * @param origin The DuckPlayer origin. [SERP], [SERP_AUTO], [AUTO], or [OVERLAY] + */ + fun setDuckPlayerOrigin(origin: DuckPlayerOrigin) + /** * Data class representing user preferences for Duck Player. * @@ -210,4 +220,11 @@ interface DuckPlayer { data object Off : OpenDuckPlayerInNewTab data object Unavailable : OpenDuckPlayerInNewTab } + + enum class DuckPlayerOrigin { + SERP, + SERP_AUTO, + AUTO, + OVERLAY, + } } diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerLocalFilesPath.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerLocalFilesPath.kt index 18f331d86a12..625d98004100 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerLocalFilesPath.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerLocalFilesPath.kt @@ -17,51 +17,38 @@ package com.duckduckgo.duckplayer.impl import android.content.res.AssetManager -import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn -import java.io.IOException import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking interface DuckPlayerLocalFilesPath { - suspend fun assetsPath(): List + fun assetsPath(): List } @ContributesBinding(AppScope::class) @SingleInstanceIn(AppScope::class) class RealDuckPlayerLocalFilesPath @Inject constructor( private val assetManager: AssetManager, - @AppCoroutineScope appCoroutineScope: CoroutineScope, - private val dispatcherProvider: DispatcherProvider, + dispatcherProvider: DispatcherProvider, ) : DuckPlayerLocalFilesPath { - private val assetsPathDeferred: Deferred> = appCoroutineScope.async(dispatcherProvider.io()) { - getAllAssetFilePaths("duckplayer") - } + private val assetsPaths: List = runBlocking(dispatcherProvider.io()) { getAllAssetFilePaths("duckplayer") } - override suspend fun assetsPath(): List { - return withContext(dispatcherProvider.io()) { - assetsPathDeferred.await() - } - } + override fun assetsPath(): List = assetsPaths private fun getAllAssetFilePaths(directory: String): List { val filePaths = mutableListOf() - val files = assetManager.list(directory) ?: return emptyList() + val files = runCatching { assetManager.list(directory) }.getOrNull() ?: return emptyList() files.forEach { val fullPath = "$directory/$it" - try { - assetManager.open(fullPath) - filePaths.add(fullPath.removePrefix("duckplayer/")) - } catch (e: IOException) { + if (runCatching { assetManager.list(fullPath)?.isNotEmpty() }.getOrDefault(false) == true) { filePaths.addAll(getAllAssetFilePaths(fullPath)) + } else { + filePaths.add(fullPath.removePrefix("duckplayer/")) } } return filePaths diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt index 6e0a894d9a36..e26016eb5b83 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt @@ -24,6 +24,8 @@ import android.webkit.WebResourceResponse import android.webkit.WebView import androidx.core.net.toUri import androidx.fragment.app.FragmentManager +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.di.IsMainProcess import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.common.utils.DispatcherProvider @@ -31,20 +33,18 @@ import com.duckduckgo.common.utils.UrlScheme.Companion.duck import com.duckduckgo.common.utils.UrlScheme.Companion.https import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckplayer.api.DuckPlayer -import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState +import com.duckduckgo.duckplayer.api.DuckPlayer.* +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.AUTO +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.OVERLAY +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.SERP_AUTO import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED_WIH_HELP_LINK import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED -import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.Off import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.On import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.Unavailable -import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM -import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM_AUTO -import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM_OVERLAY import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM_SERP -import com.duckduckgo.duckplayer.api.ORIGIN_QUERY_PARAM_SERP_AUTO import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled @@ -62,12 +62,15 @@ import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_ import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_WATCH_ON_YOUTUBE import com.duckduckgo.duckplayer.impl.ui.DuckPlayerPrimeBottomSheet import com.duckduckgo.duckplayer.impl.ui.DuckPlayerPrimeDialogFragment +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import java.io.InputStream import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val DUCK_PLAYER_VIDEO_ID_QUERY_PARAM = "videoID" @@ -102,20 +105,27 @@ class RealDuckPlayer @Inject constructor( private val duckPlayerLocalFilesPath: DuckPlayerLocalFilesPath, private val mimeTypeMap: MimeTypeMap, private val dispatchers: DispatcherProvider, -) : DuckPlayerInternal { + @IsMainProcess private val isMainProcess: Boolean, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : DuckPlayerInternal, PrivacyConfigCallbackPlugin { private var shouldForceYTNavigation = false private var shouldHideOverlay = false - private val isFeatureEnabled: Boolean by lazy { - duckPlayerFeature.self().isEnabled() && duckPlayerFeature.enableDuckPlayer().isEnabled() + private var duckPlayerOrigin: DuckPlayerOrigin? = null + private var isFeatureEnabled = false + private var duckPlayerDisabledHelpLink = "" + + init { + if (isMainProcess) { + loadToMemory() + } } - private lateinit var duckPlayerDisabledHelpLink: String + override fun onPrivacyConfigDownloaded() { + loadToMemory() + } override fun getDuckPlayerState(): DuckPlayerState { - if (!::duckPlayerDisabledHelpLink.isInitialized) { - duckPlayerDisabledHelpLink = duckPlayerFeatureRepository.getDuckPlayerDisabledHelpPageLink() ?: "" - } return if (isFeatureEnabled) { ENABLED } else { @@ -242,7 +252,7 @@ class RealDuckPlayer @Inject constructor( return isDuckPlayerUri(uri.toUri()) } - override suspend fun isSimulatedYoutubeNoCookie(uri: Uri): Boolean { + override fun isSimulatedYoutubeNoCookie(uri: Uri): Boolean { val validPaths = duckPlayerLocalFilesPath.assetsPath() val embedUrl = duckPlayerFeatureRepository.getYouTubeEmbedUrl() return ( @@ -255,10 +265,6 @@ class RealDuckPlayer @Inject constructor( ) } - override suspend fun isSimulatedYoutubeNoCookie(uri: String): Boolean { - return isSimulatedYoutubeNoCookie(uri.toUri()) - } - private fun getDuckPlayerAssetsPath(url: Uri): String? { return url.path?.takeIf { it.isNotBlank() }?.removePrefix("/")?.let { "$DUCK_PLAYER_ASSETS_PATH$it" } } @@ -282,8 +288,10 @@ class RealDuckPlayer @Inject constructor( private fun createDuckPlayerUriFromYoutube(uri: Uri): String { val videoIdQueryParam = duckPlayerFeatureRepository.getVideoIDQueryParam() - val origin = uri.getQueryParameter(ORIGIN_QUERY_PARAM)?.let { it } ?: ORIGIN_QUERY_PARAM_AUTO - return "$DUCK_PLAYER_URL_BASE${uri.getQueryParameter(videoIdQueryParam)}?$ORIGIN_QUERY_PARAM=$origin" + if (duckPlayerOrigin == null) { + duckPlayerOrigin = AUTO + } + return "$DUCK_PLAYER_URL_BASE${uri.getQueryParameter(videoIdQueryParam)}" } override suspend fun intercept( @@ -381,10 +389,10 @@ class RealDuckPlayer @Inject constructor( return null } - private suspend fun doesYoutubeUrlComeFromDuckPlayer(url: Uri, request: WebResourceRequest? = null): Boolean { + private fun doesYoutubeUrlComeFromDuckPlayer(url: Uri, request: WebResourceRequest? = null): Boolean { val videoIdQueryParam = duckPlayerFeatureRepository.getVideoIDQueryParam() val requestedVideoId = url.getQueryParameter(videoIdQueryParam) - val isSimulated: suspend (String?) -> Boolean = { uri -> + val isSimulated: (String?) -> Boolean = { uri -> uri?.let { isSimulatedYoutubeNoCookie(it.toUri()) } == true } @@ -423,14 +431,14 @@ class RealDuckPlayer @Inject constructor( withContext(dispatchers.main()) { webView.loadUrl(youtubeUrl) } - val origin = url.getQueryParameter(ORIGIN_QUERY_PARAM) - if (origin == ORIGIN_QUERY_PARAM_SERP || origin == ORIGIN_QUERY_PARAM_SERP_AUTO) { + if (url.getQueryParameter(ORIGIN_QUERY_PARAM) == ORIGIN_QUERY_PARAM_SERP || duckPlayerOrigin == SERP_AUTO) { pixel.fire(DUCK_PLAYER_VIEW_FROM_SERP) - } else if (origin == ORIGIN_QUERY_PARAM_AUTO) { + } else if (duckPlayerOrigin == AUTO) { pixel.fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_AUTOMATIC) - } else if (origin != ORIGIN_QUERY_PARAM_OVERLAY) { + } else if (duckPlayerOrigin != OVERLAY) { pixel.fire(DUCK_PLAYER_VIEW_FROM_OTHER) } + duckPlayerOrigin = null } } return WebResourceResponse(null, null, null) @@ -449,7 +457,7 @@ class RealDuckPlayer @Inject constructor( return duckPlayerFeatureRepository.getYouTubeEmbedUrl() } - override suspend fun willNavigateToDuckPlayer( + override fun willNavigateToDuckPlayer( destinationUrl: Uri, ): Boolean { return ( @@ -470,4 +478,15 @@ class RealDuckPlayer @Inject constructor( (if (!duckPlayerFeature.openInNewTab().isEnabled()) Unavailable else if (it) On else Off) } } + + private fun loadToMemory() { + appCoroutineScope.launch(dispatchers.io()) { + isFeatureEnabled = duckPlayerFeature.self().isEnabled() && duckPlayerFeature.enableDuckPlayer().isEnabled() + duckPlayerDisabledHelpLink = duckPlayerFeatureRepository.getDuckPlayerDisabledHelpPageLink() ?: "" + } + } + + override fun setDuckPlayerOrigin(origin: DuckPlayerOrigin) { + duckPlayerOrigin = origin + } } diff --git a/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt b/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt index 802fdad96fb2..fceb894a79ae 100644 --- a/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt +++ b/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt @@ -29,6 +29,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.common.utils.UrlScheme.Companion.duck import com.duckduckgo.common.utils.UrlScheme.Companion.https +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.AUTO import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED_WIH_HELP_LINK import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED @@ -87,6 +88,8 @@ class RealDuckPlayerTest { mockDuckPlayerLocalFilesPath, mimeType, dispatcherProvider, + true, + coroutineRule.testScope, ) @Before @@ -124,8 +127,8 @@ class RealDuckPlayerTest { @Test fun whenDuckPlayerStateIsDisabledWithHelpLink_getDuckPlayerStateReturnsDisabledWithHelpLink() = runTest { - setFeatureToggle(false) whenever(mockDuckPlayerFeatureRepository.getDuckPlayerDisabledHelpPageLink()).thenReturn("help_link") + setFeatureToggle(false) val result = testee.getDuckPlayerState() @@ -554,9 +557,10 @@ class RealDuckPlayerTest { @Test fun whenUriIsDuckPlayerUriWithOriginAuto_interceptProcessesDuckPlayerUri() = runTest { val request: WebResourceRequest = mock() - val url: Uri = Uri.parse("duck://player/12345?origin=auto") + val url: Uri = Uri.parse("duck://player/12345") val webView: WebView = mock() whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + testee.setDuckPlayerOrigin(AUTO) val result = testee.intercept(request, url, webView) @@ -653,7 +657,7 @@ class RealDuckPlayerTest { val result = testee.intercept(request, url, webView) - verify(webView).loadUrl("duck://player/12345?origin=auto") + verify(webView).loadUrl("duck://player/12345") assertNotNull(result) } @@ -695,7 +699,7 @@ class RealDuckPlayerTest { val result = testee.intercept(request, url, webView) - verify(webView).loadUrl("duck://player/123456?origin=auto") + verify(webView).loadUrl("duck://player/123456") assertNotNull(result) } @@ -777,5 +781,6 @@ class RealDuckPlayerTest { private fun setFeatureToggle(enabled: Boolean) { duckPlayerFeature.self().setRawStoredState(State(enabled)) duckPlayerFeature.enableDuckPlayer().setRawStoredState(State(enabled)) + testee.onPrivacyConfigDownloaded() } } diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/DuckDuckGoLoadingBarExperimentManager.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/DuckDuckGoLoadingBarExperimentManager.kt deleted file mode 100644 index d7f576743351..000000000000 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/DuckDuckGoLoadingBarExperimentManager.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * 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. - */ - -package com.duckduckgo.experiments.impl.loadingbarexperiment - -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.app.di.IsMainProcess -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager -import com.squareup.anvil.annotations.ContributesBinding -import dagger.SingleInstanceIn -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import timber.log.Timber - -@ContributesBinding(AppScope::class) -@SingleInstanceIn(AppScope::class) -class DuckDuckGoLoadingBarExperimentManager @Inject constructor( - private val loadingBarExperimentDataStore: LoadingBarExperimentDataStore, - private val loadingBarExperimentFeature: LoadingBarExperimentFeature, - private val uriLoadedPixelFeature: UriLoadedPixelFeature, - @AppCoroutineScope appCoroutineScope: CoroutineScope, - dispatcherProvider: DispatcherProvider, - @IsMainProcess isMainProcess: Boolean, -) : LoadingBarExperimentManager { - - private var cachedShouldSendUriLoadedPixel: Boolean = false - private var cachedVariant: Boolean = false - private var hasVariant: Boolean = false - private var enabled: Boolean = false - - override val variant: Boolean - get() = cachedVariant - - override val shouldSendUriLoadedPixel: Boolean - get() = cachedShouldSendUriLoadedPixel - - init { - appCoroutineScope.launch(dispatcherProvider.io()) { - if (isMainProcess) { - Timber.d("Loading bar experiment: Experimental variables initialized") - loadToMemory() - } - } - } - - override fun isExperimentEnabled(): Boolean { - Timber.d("Loading bar experiment: Retrieving experiment status") - return hasVariant && enabled - } - - override suspend fun update() { - Timber.d("Loading bar experiment: Experimental variables updated") - loadToMemory() - } - - private fun loadToMemory() { - cachedVariant = loadingBarExperimentDataStore.variant - hasVariant = loadingBarExperimentDataStore.hasVariant - enabled = loadingBarExperimentFeature.self().isEnabled() - cachedShouldSendUriLoadedPixel = uriLoadedPixelFeature.self().isEnabled() - } -} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentDataStore.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentDataStore.kt deleted file mode 100644 index 3f0f268ab1b3..000000000000 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentDataStore.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * 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. - */ - -package com.duckduckgo.experiments.impl.loadingbarexperiment - -import android.content.SharedPreferences -import androidx.core.content.edit -import com.duckduckgo.data.store.api.SharedPreferencesProvider -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject - -interface LoadingBarExperimentDataStore { - var variant: Boolean - val hasVariant: Boolean -} - -@ContributesBinding(AppScope::class) -class LoadingBarExperimentSharedPreferences @Inject constructor( - private val sharedPreferencesProvider: SharedPreferencesProvider, -) : LoadingBarExperimentDataStore { - - override var variant: Boolean - get() = preferences.getBoolean(KEY_VARIANT, false) - set(value) = preferences.edit { putBoolean(KEY_VARIANT, value) } - - override val hasVariant: Boolean - get() = preferences.contains(KEY_VARIANT) - - private val preferences: SharedPreferences by lazy { sharedPreferencesProvider.getSharedPreferences(FILENAME) } - - companion object { - private const val FILENAME = "com.duckduckgo.app.loadingbarexperiment" - private const val KEY_VARIANT = "com.duckduckgo.app.loadingbarexperiment.variant" - } -} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentVariantInitializer.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentVariantInitializer.kt deleted file mode 100644 index eb188649062f..000000000000 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentVariantInitializer.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * 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. - */ - -package com.duckduckgo.experiments.impl.loadingbarexperiment - -import androidx.annotation.VisibleForTesting -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager -import com.duckduckgo.experiments.impl.loadingbarexperiment.LoadingBarExperimentPixels.LOADING_BAR_EXPERIMENT_ENROLLMENT_CONTROL -import com.duckduckgo.experiments.impl.loadingbarexperiment.LoadingBarExperimentPixels.LOADING_BAR_EXPERIMENT_ENROLLMENT_TEST -import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin -import com.squareup.anvil.annotations.ContributesMultibinding -import dagger.SingleInstanceIn -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.apache.commons.math3.distribution.EnumeratedIntegerDistribution - -@ContributesMultibinding( - scope = AppScope::class, - boundType = PrivacyConfigCallbackPlugin::class, -) -@SingleInstanceIn(AppScope::class) -class LoadingBarExperimentVariantInitializer @Inject constructor( - private val loadingBarExperimentManager: LoadingBarExperimentManager, - private val loadingBarExperimentDataStore: LoadingBarExperimentDataStore, - private val loadingBarExperimentFeature: LoadingBarExperimentFeature, - private val pixel: Pixel, - @AppCoroutineScope private val appCoroutineScope: CoroutineScope, - private val dispatcherProvider: DispatcherProvider, -) : PrivacyConfigCallbackPlugin { - - private fun initialize() { - if (!loadingBarExperimentDataStore.hasVariant && - loadingBarExperimentFeature.self().isEnabled() && - loadingBarExperimentFeature.allocateVariants().isEnabled() - ) { - loadingBarExperimentDataStore.variant = generateRandomBoolean() - if (loadingBarExperimentDataStore.variant) { - pixel.fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_TEST) - } else { - pixel.fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_CONTROL) - } - } - } - - // Test variant = true, Control variant = false - @VisibleForTesting - fun generateRandomBoolean(): Boolean { - val values = intArrayOf(0, 1) - val probabilities = doubleArrayOf(1.0, 1.0) - val distribution = EnumeratedIntegerDistribution(values, probabilities) - return distribution.sample() == 1 - } - - override fun onPrivacyConfigDownloaded() { - appCoroutineScope.launch(dispatcherProvider.io()) { - initialize() - loadingBarExperimentManager.update() - } - } -} diff --git a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/DuckDuckGoLoadingBarExperimentManagerTest.kt b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/DuckDuckGoLoadingBarExperimentManagerTest.kt deleted file mode 100644 index 2176a1b724a6..000000000000 --- a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/DuckDuckGoLoadingBarExperimentManagerTest.kt +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * 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 com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.experiments.impl.loadingbarexperiment.DuckDuckGoLoadingBarExperimentManager -import com.duckduckgo.experiments.impl.loadingbarexperiment.LoadingBarExperimentDataStore -import com.duckduckgo.experiments.impl.loadingbarexperiment.LoadingBarExperimentFeature -import com.duckduckgo.experiments.impl.loadingbarexperiment.UriLoadedPixelFeature -import com.duckduckgo.feature.toggles.api.Toggle -import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.mockito.kotlin.* - -class DuckDuckGoLoadingBarExperimentManagerTest { - - private lateinit var testee: DuckDuckGoLoadingBarExperimentManager - - private val mockLoadingBarExperimentDataStore: LoadingBarExperimentDataStore = mock() - private val mockLoadingBarExperimentFeature: LoadingBarExperimentFeature = mock() - private val mockUriLoadedPixelFeature: UriLoadedPixelFeature = mock() - private val mockToggle: Toggle = mock() - private val mockUriLoadedKillSwitch: Toggle = mock() - - @get:Rule - val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - - @Before - fun setup() { - whenever(mockLoadingBarExperimentFeature.self()).thenReturn(mockToggle) - whenever(mockUriLoadedPixelFeature.self()).thenReturn(mockUriLoadedKillSwitch) - whenever(mockUriLoadedKillSwitch.isEnabled()).thenReturn(true) - } - - @Test - fun whenHasVariantAndIsEnabledThenIsExperimentEnabledReturnsTrue() { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(true) - whenever(mockToggle.isEnabled()).thenReturn(true) - - initialize() - - assertTrue(testee.isExperimentEnabled()) - verify(mockLoadingBarExperimentDataStore).hasVariant - verify(mockLoadingBarExperimentFeature.self()).isEnabled() - } - - @Test - fun whenHasNoVariantThenIsExperimentEnabledReturnsFalse() { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(true) - - initialize() - - assertFalse(testee.isExperimentEnabled()) - verify(mockLoadingBarExperimentDataStore).hasVariant - } - - @Test - fun whenIsNotEnabledThenIsExperimentEnabledReturnsFalse() { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(true) - whenever(mockToggle.isEnabled()).thenReturn(false) - - initialize() - - assertFalse(testee.isExperimentEnabled()) - verify(mockLoadingBarExperimentDataStore).hasVariant - } - - @Test - fun whenHasNoVariantAndIsNotEnabledThenIsExperimentEnabledReturnsFalse() { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(false) - - initialize() - - assertFalse(testee.isExperimentEnabled()) - verify(mockLoadingBarExperimentDataStore).hasVariant - } - - @Test - fun whenUpdateCalledThenCachedVariablesAreUpdated() = runTest { - var numInvocations = 0 - - initialize() - - verifyVariablesUpdated(++numInvocations) - - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(true) - - testee.update() - - assertFalse(testee.isExperimentEnabled()) - verifyVariablesUpdated(++numInvocations) - - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(false) - - testee.update() - - assertFalse(testee.isExperimentEnabled()) - verifyVariablesUpdated(++numInvocations) - - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(true) - whenever(mockToggle.isEnabled()).thenReturn(false) - - testee.update() - - assertFalse(testee.isExperimentEnabled()) - verifyVariablesUpdated(++numInvocations) - - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(true) - whenever(mockToggle.isEnabled()).thenReturn(true) - - testee.update() - - assertTrue(testee.isExperimentEnabled()) - verifyVariablesUpdated(++numInvocations) - } - - @Test - fun whenGetVariantThenVariantIsReturned() { - whenever(mockLoadingBarExperimentDataStore.variant).thenReturn(true) - - initialize() - - assertTrue(testee.variant) - verify(mockLoadingBarExperimentDataStore).variant - } - - @Test - fun whenShouldSendUriLoadedPixelEnabledThenReturnTrue() { - initialize() - - assertTrue(testee.shouldSendUriLoadedPixel) - } - - @Test - fun whenShouldSendUriLoadedPixelDisabledThenReturnFalse() { - whenever(mockUriLoadedKillSwitch.isEnabled()).thenReturn(false) - - initialize() - - assertFalse(testee.shouldSendUriLoadedPixel) - } - - private fun initialize() { - testee = DuckDuckGoLoadingBarExperimentManager( - mockLoadingBarExperimentDataStore, - mockLoadingBarExperimentFeature, - mockUriLoadedPixelFeature, - TestScope(), - coroutineTestRule.testDispatcherProvider, - isMainProcess = true, - ) - } - - private fun verifyVariablesUpdated(numInvocations: Int) { - verify(mockLoadingBarExperimentDataStore, times(numInvocations)).hasVariant - verify(mockLoadingBarExperimentDataStore, times(numInvocations)).variant - verify(mockToggle, times(numInvocations)).isEnabled() - verify(mockUriLoadedKillSwitch, times(numInvocations)).isEnabled() - } -} diff --git a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentSharedPreferencesTest.kt b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentSharedPreferencesTest.kt deleted file mode 100644 index 52cff5843942..000000000000 --- a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentSharedPreferencesTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * 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. - */ - -package com.duckduckgo.experiments.impl.loadingbarexperiment - -import android.content.SharedPreferences -import com.duckduckgo.data.store.api.SharedPreferencesProvider -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.* -import org.mockito.kotlin.whenever - -class LoadingBarExperimentSharedPreferencesTest { - - private lateinit var testee: LoadingBarExperimentSharedPreferences - - private val sharedPreferencesProvider: SharedPreferencesProvider = mock() - private val sharedPreferences: SharedPreferences = mock() - - @Before - fun setUp() { - whenever(sharedPreferencesProvider.getSharedPreferences("com.duckduckgo.app.loadingbarexperiment")).thenReturn(sharedPreferences) - - testee = LoadingBarExperimentSharedPreferences(sharedPreferencesProvider) - } - - @Test - fun whenVariantIsSetToTrueThenReturnTrue() { - whenever(sharedPreferences.getBoolean("com.duckduckgo.app.loadingbarexperiment.variant", false)).thenReturn(true) - - assertTrue(testee.variant) - } - - @Test - fun whenVariantIsSetToFalseThenReturnFalse() { - whenever(sharedPreferences.getBoolean("com.duckduckgo.app.loadingbarexperiment.variant", false)).thenReturn(false) - - assertFalse(testee.variant) - } - - @Test - fun whenVariantIsNotSetThenHasVariantReturnFalse() { - whenever(sharedPreferences.contains("com.duckduckgo.app.loadingbarexperiment.variant")).thenReturn(false) - - assertFalse(testee.hasVariant) - } - - @Test - fun whenVariantIsSetThenHasVariantReturnTrue() { - whenever(sharedPreferences.contains("com.duckduckgo.app.loadingbarexperiment.variant")).thenReturn(true) - - assertTrue(testee.hasVariant) - } -} diff --git a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentVariantInitializerTest.kt b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentVariantInitializerTest.kt deleted file mode 100644 index e4c2931d7c47..000000000000 --- a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/loadingbarexperiment/LoadingBarExperimentVariantInitializerTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * 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. - */ - -package com.duckduckgo.experiments.impl.loadingbarexperiment - -import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager -import com.duckduckgo.experiments.impl.loadingbarexperiment.LoadingBarExperimentPixels.LOADING_BAR_EXPERIMENT_ENROLLMENT_CONTROL -import com.duckduckgo.experiments.impl.loadingbarexperiment.LoadingBarExperimentPixels.LOADING_BAR_EXPERIMENT_ENROLLMENT_TEST -import com.duckduckgo.feature.toggles.api.Toggle -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.mockito.kotlin.* - -class LoadingBarExperimentVariantInitializerTest { - - @get:Rule - var coroutineRule = CoroutineTestRule() - - private lateinit var testee: LoadingBarExperimentVariantInitializer - - private val mockLoadingBarExperimentManager: LoadingBarExperimentManager = mock() - private val mockLoadingBarExperimentDataStore: LoadingBarExperimentDataStore = mock() - private val mockLoadingBarExperimentFeature: LoadingBarExperimentFeature = mock() - private val mockPixel: Pixel = mock() - private val mockToggle: Toggle = mock() - private val mockAllocationToggle: Toggle = mock() - - @Before - fun setup() { - testee = spy( - LoadingBarExperimentVariantInitializer( - mockLoadingBarExperimentManager, - mockLoadingBarExperimentDataStore, - mockLoadingBarExperimentFeature, - mockPixel, - coroutineRule.testScope, - coroutineRule.testDispatcherProvider, - ), - ) - whenever(mockLoadingBarExperimentFeature.self()).thenReturn(mockToggle) - whenever(mockLoadingBarExperimentFeature.allocateVariants()).thenReturn(mockAllocationToggle) - } - - @Test - fun whenPrivacyConfigDownloadedAndGeneratedBooleanIsTrueThenVariantSetToTrueAndTestPixelFired() = runTest { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(true) - whenever(mockAllocationToggle.isEnabled()).thenReturn(true) - whenever(mockLoadingBarExperimentDataStore.variant).thenReturn(true) - whenever(testee.generateRandomBoolean()).thenReturn(true) - - testee.onPrivacyConfigDownloaded() - - verify(mockLoadingBarExperimentDataStore).variant = true - verify(mockPixel).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_TEST) - } - - @Test - fun whenPrivacyConfigDownloadedAndGeneratedBooleanIsFalseThenVariantSetToFalseAndControlPixelFired() = runTest { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(true) - whenever(mockAllocationToggle.isEnabled()).thenReturn(true) - whenever(mockLoadingBarExperimentDataStore.variant).thenReturn(false) - whenever(testee.generateRandomBoolean()).thenReturn(false) - - testee.onPrivacyConfigDownloaded() - - verify(mockLoadingBarExperimentDataStore).variant = false - verify(mockPixel).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_CONTROL) - } - - @Test - fun whenPrivacyConfigDownloadedAndVariantAlreadySetThenNoVariantChangesAndNoPixelsFired() = runTest { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(true) - - testee.onPrivacyConfigDownloaded() - - verify(mockLoadingBarExperimentDataStore, never()).variant = any() - verify(mockPixel, never()).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_TEST) - verify(mockPixel, never()).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_CONTROL) - } - - @Test - fun whenPrivacyConfigDownloadedAndFeatureToggleDisabledThenNoVariantChangesAndNoPixelsFired() = runTest { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(false) - - testee.onPrivacyConfigDownloaded() - - verify(mockLoadingBarExperimentDataStore, never()).variant = any() - verify(mockPixel, never()).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_TEST) - verify(mockPixel, never()).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_CONTROL) - } - - @Test - fun whenPrivacyConfigDownloadedAndAllocationToggleDisabledThenNoVariantChangesAndNoPixelsFired() = runTest { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(true) - whenever(mockAllocationToggle.isEnabled()).thenReturn(false) - - testee.onPrivacyConfigDownloaded() - - verify(mockLoadingBarExperimentDataStore, never()).variant = any() - verify(mockPixel, never()).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_TEST) - verify(mockPixel, never()).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_CONTROL) - } - - @Test - fun whenPrivacyConfigDownloadedAndBothTogglesDisabledThenNoVariantChangesAndNoPixelsFired() = runTest { - whenever(mockLoadingBarExperimentDataStore.hasVariant).thenReturn(false) - whenever(mockToggle.isEnabled()).thenReturn(false) - whenever(mockAllocationToggle.isEnabled()).thenReturn(false) - - testee.onPrivacyConfigDownloaded() - - verify(mockLoadingBarExperimentDataStore, never()).variant = any() - verify(mockPixel, never()).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_TEST) - verify(mockPixel, never()).fire(LOADING_BAR_EXPERIMENT_ENROLLMENT_CONTROL) - } - - @Test - fun whenPrivacyConfigDownloadedThenLoadingBarExperimentManagerUpdated() = runTest { - testee.onPrivacyConfigDownloaded() - - verify(mockLoadingBarExperimentManager).update() - } -} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 24f13819d051..c57356ffaa95 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -124,6 +124,7 @@ platform :android do options_release_number = options[:release_number] options_release_notes = options[:release_notes] + options_release_notes = options[:release_notes] options_notes_type = options[:notes_type] newVersion = determine_version_number( diff --git a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureSettings.kt b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureSettings.kt index 9635a5c5d59e..377c37b83cf2 100644 --- a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureSettings.kt +++ b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureSettings.kt @@ -15,7 +15,9 @@ */ package com.duckduckgo.feature.toggles.api +@Deprecated(message = "Not needed anymore. Settings is now supported in top-leve and sub-features and Toggle#getSettings returns it") object FeatureSettings { + @Deprecated(message = "Not needed anymore. Settings is now supported in top-leve and sub-features and Toggle#getSettings returns it") interface Store { fun store( jsonString: String, diff --git a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt index 154419ad14c3..5b46860d3563 100644 --- a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt +++ b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt @@ -194,9 +194,9 @@ interface Toggle { fun getRawStoredState(): State? /** - * @return a Map of containing the config of the feature or an empty map + * @return a JSON string containing the `settings`` of the feature or null if not present in the remote config */ - fun getConfig(): Map + fun getSettings(): String? /** * @return a [Cohort] if one has been assigned or `null` otherwise. @@ -224,7 +224,7 @@ interface Toggle { val metadataInfo: String? = null, val cohorts: List = emptyList(), val assignedCohort: Cohort? = null, - val config: Map = emptyMap(), + val settings: String? = null, ) { data class Target( val variantKey: String?, @@ -521,7 +521,7 @@ internal class ToggleImpl constructor( ) } - override fun getConfig(): Map = store.get(key)?.config ?: emptyMap() + override fun getSettings(): String? = store.get(key)?.settings override fun getCohort(): Cohort? { return store.get(key)?.assignedCohort diff --git a/feature-toggles/feature-toggles-impl/lint-baseline.xml b/feature-toggles/feature-toggles-impl/lint-baseline.xml index d6dcefa52c78..1a704e9c033e 100644 --- a/feature-toggles/feature-toggles-impl/lint-baseline.xml +++ b/feature-toggles/feature-toggles-impl/lint-baseline.xml @@ -8,7 +8,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -19,7 +19,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -30,7 +30,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -41,7 +41,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -52,7 +52,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -63,7 +63,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -74,7 +74,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -85,7 +85,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -96,7 +96,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -107,7 +107,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -118,7 +118,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -129,7 +129,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -140,7 +140,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -151,7 +151,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -162,7 +162,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -173,7 +173,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -184,7 +184,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -195,7 +195,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -206,7 +206,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -217,7 +217,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -228,7 +228,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -239,7 +239,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -250,7 +250,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -261,7 +261,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -272,7 +272,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -283,7 +283,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -294,7 +294,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -305,7 +305,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -316,7 +316,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -327,7 +327,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -338,7 +338,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -349,7 +349,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -360,7 +360,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -371,7 +371,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -382,7 +382,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -393,7 +393,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -404,7 +404,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -415,7 +415,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -426,7 +426,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -437,7 +437,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -448,7 +448,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -459,7 +459,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -470,7 +470,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -481,51 +481,62 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + @@ -536,7 +547,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -855,7 +866,7 @@ errorLine2=" ^"> @@ -866,7 +877,7 @@ errorLine2=" ^"> @@ -877,7 +888,7 @@ errorLine2=" ^"> @@ -888,7 +899,7 @@ errorLine2=" ^"> @@ -899,7 +910,7 @@ errorLine2=" ^"> @@ -910,7 +921,7 @@ errorLine2=" ^"> @@ -921,7 +932,7 @@ errorLine2=" ^"> @@ -932,7 +943,7 @@ errorLine2=" ^"> @@ -943,7 +954,7 @@ errorLine2=" ^"> @@ -965,7 +976,7 @@ errorLine2=" ^"> @@ -976,29 +987,7 @@ errorLine2=" ^"> - - - - - - - - @@ -1009,7 +998,7 @@ errorLine2=" ^"> @@ -1020,7 +1009,7 @@ errorLine2=" ^"> @@ -1031,7 +1020,7 @@ errorLine2=" ^"> @@ -1042,7 +1031,7 @@ errorLine2=" ^"> @@ -1053,7 +1042,7 @@ errorLine2=" ^"> @@ -1064,7 +1053,7 @@ errorLine2=" ^"> @@ -1075,7 +1064,7 @@ errorLine2=" ^"> @@ -1086,7 +1075,7 @@ errorLine2=" ^"> diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt index 6e30501edcd6..ddf18a59e6b4 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt @@ -36,6 +36,8 @@ import com.duckduckgo.feature.toggles.codegen.ContributesRemoteFeatureCodeGenera import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types import dagger.Lazy import dagger.SingleInstanceIn import java.time.ZoneId @@ -3525,6 +3527,10 @@ class ContributesRemoteFeatureCodeGeneratorTest { @Test fun `test config parsed correctly`() { + val moshi = Moshi.Builder().build() + val adapter = moshi.adapter>( + Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java), + ) val feature = generatedFeatureNewInstance() val privacyPlugin = (feature as PrivacyFeaturePlugin) @@ -3536,12 +3542,30 @@ class ContributesRemoteFeatureCodeGeneratorTest { { "hash": "1", "state": "disabled", + "settings": { + "foo": "foo/value", + "bar": { + "key": "value", + "number": 2, + "boolean": true, + "complex": { + "boolean": true + } + } + }, "features": { "fooFeature": { "state": "enabled", - "config": { + "settings": { "foo": "foo/value", - "bar": "bar/value" + "bar": { + "key": "value", + "number": 2, + "boolean": true, + "complex": { + "boolean": true + } + } }, "rollout": { "steps": [ @@ -3567,14 +3591,23 @@ class ContributesRemoteFeatureCodeGeneratorTest { ), ) - var stateConfig = testFeature.fooFeature().getRawStoredState()?.config!! - var config = testFeature.fooFeature().getConfig() + val topLevelStateConfig = testFeature.self().getRawStoredState()?.settings?.let { adapter.fromJson(it) } ?: emptyMap() + val topLevelConfig = testFeature.self().getSettings()?.let { adapter.fromJson(it) } ?: emptyMap() + assertTrue(topLevelStateConfig.size == 2) + assertEquals("foo/value", topLevelStateConfig["foo"]) + assertEquals(mapOf("key" to "value", "number" to 2.0, "boolean" to true, "complex" to mapOf("boolean" to true)), topLevelStateConfig["bar"]) + assertTrue(topLevelConfig.size == 2) + assertEquals("foo/value", topLevelConfig["foo"]) + assertEquals(mapOf("key" to "value", "number" to 2.0, "boolean" to true, "complex" to mapOf("boolean" to true)), topLevelConfig["bar"]) + + var stateConfig = testFeature.fooFeature().getRawStoredState()?.settings?.let { adapter.fromJson(it) } ?: emptyMap() + var config = testFeature.fooFeature().getSettings()?.let { adapter.fromJson(it) } ?: emptyMap() assertTrue(stateConfig.size == 2) assertEquals("foo/value", stateConfig["foo"]) - assertEquals("bar/value", stateConfig["bar"]) + assertEquals(mapOf("key" to "value", "number" to 2.0, "boolean" to true, "complex" to mapOf("boolean" to true)), stateConfig["bar"]) assertTrue(config.size == 2) assertEquals("foo/value", config["foo"]) - assertEquals("bar/value", config["bar"]) + assertEquals(mapOf("key" to "value", "number" to 2.0, "boolean" to true, "complex" to mapOf("boolean" to true)), config["bar"]) // Delete config key, should remove assertTrue( @@ -3587,7 +3620,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { "features": { "fooFeature": { "state": "enabled", - "config": { + "settings": { "foo": "foo/value" }, "rollout": { @@ -3614,8 +3647,8 @@ class ContributesRemoteFeatureCodeGeneratorTest { ), ) - stateConfig = testFeature.fooFeature().getRawStoredState()?.config!! - config = testFeature.fooFeature().getConfig() + stateConfig = testFeature.fooFeature().getRawStoredState()?.settings?.let { adapter.fromJson(it) } ?: emptyMap() + config = testFeature.fooFeature().getSettings()?.let { adapter.fromJson(it) } ?: emptyMap() assertTrue(stateConfig.size == 1) assertEquals("foo/value", stateConfig["foo"]) assertNull(stateConfig["bar"]) @@ -3623,7 +3656,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertEquals("foo/value", config["foo"]) assertNull(config["bar"]) - // delete config, returns empty map + // delete config, returns empty assertTrue( privacyPlugin.store( "testFeature", @@ -3654,8 +3687,8 @@ class ContributesRemoteFeatureCodeGeneratorTest { ), ) - stateConfig = testFeature.fooFeature().getRawStoredState()?.config!! - config = testFeature.fooFeature().getConfig() + stateConfig = testFeature.fooFeature().getRawStoredState()?.settings?.let { adapter.fromJson(it) } ?: emptyMap() + config = testFeature.fooFeature().getSettings()?.let { adapter.fromJson(it) } ?: emptyMap() assertTrue(stateConfig.isEmpty()) assertTrue(config.isEmpty()) @@ -3670,7 +3703,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { "features": { "fooFeature": { "state": "enabled", - "config": { + "settings": { "x": "x/value", "y": "y/value" }, @@ -3698,8 +3731,8 @@ class ContributesRemoteFeatureCodeGeneratorTest { ), ) - stateConfig = testFeature.fooFeature().getRawStoredState()?.config!! - config = testFeature.fooFeature().getConfig() + stateConfig = testFeature.fooFeature().getRawStoredState()?.settings?.let { adapter.fromJson(it) } ?: emptyMap() + config = testFeature.fooFeature().getSettings()?.let { adapter.fromJson(it) } ?: emptyMap() assertTrue(stateConfig.size == 2) assertEquals("x/value", stateConfig["x"]) assertEquals("y/value", stateConfig["y"]) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt index 96d10de03bca..ee6e7161833d 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt @@ -90,7 +90,7 @@ internal class WgVpnRoutes { val wgVpnRoutesIncludingLocal: Map = mapOf( "0.0.0.0" to 5, "8.0.0.0" to 7, - // Excluded range: 10.0.0.0 -> 10.255.255.255 + "10.0.0.0" to 8, "11.0.0.0" to 8, "12.0.0.0" to 6, "16.0.0.0" to 4, diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/subscription/NetpSubscriptionManager.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/subscription/NetpSubscriptionManager.kt index 9439808df175..643b764de7d5 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/subscription/NetpSubscriptionManager.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/subscription/NetpSubscriptionManager.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext interface NetpSubscriptionManager { - suspend fun getToken(): String? suspend fun getVpnStatus(): VpnStatus suspend fun vpnStatus(): Flow enum class VpnStatus { @@ -55,10 +54,6 @@ class RealNetpSubscriptionManager @Inject constructor( private val dispatcherProvider: DispatcherProvider, ) : NetpSubscriptionManager { - override suspend fun getToken(): String? = withContext(dispatcherProvider.io()) { - subscriptions.getAccessToken() - } - override suspend fun getVpnStatus(): VpnStatus { val hasValidEntitlement = hasValidEntitlement() return getVpnStatusInternal(hasValidEntitlement) diff --git a/network-protection/network-protection-impl/src/main/res/layout/activity_netp_geoswitching.xml b/network-protection/network-protection-impl/src/main/res/layout/activity_netp_geoswitching.xml index 42787d399abe..103ed48ea541 100644 --- a/network-protection/network-protection-impl/src/main/res/layout/activity_netp_geoswitching.xml +++ b/network-protection/network-protection-impl/src/main/res/layout/activity_netp_geoswitching.xml @@ -39,7 +39,7 @@ android:layout_height="wrap_content" app:primaryText="@string/netpGeoswitchingHeaderRecommended" /> - - \ No newline at end of file diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js index 71525ddea5b0..98ef773dd61e 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js @@ -2,6 +2,7 @@ (function () { 'use strict'; + /* eslint-disable no-redeclare */ const Set$1 = globalThis.Set; const Reflect$1 = globalThis.Reflect; const customElementsGet = globalThis.customElements?.get.bind(globalThis.customElements); @@ -13,11 +14,13 @@ const objectDefineProperty = Object.defineProperty; const URL$1 = globalThis.URL; const Proxy$1 = globalThis.Proxy; + const hasOwnProperty = Object.prototype.hasOwnProperty; + /* eslint-disable no-redeclare, no-global-assign */ /* global cloneInto, exportFunction, false */ // Only use globalThis for testing this breaks window.wrappedJSObject code in Firefox - // eslint-disable-next-line no-global-assign + let globalObj = typeof window === 'undefined' ? globalThis : window; let Error$1 = globalObj.Error; let messageSecret; @@ -25,15 +28,15 @@ // save a reference to original CustomEvent amd dispatchEvent so they can't be overriden to forge messages const OriginalCustomEvent = typeof CustomEvent === 'undefined' ? null : CustomEvent; const originalWindowDispatchEvent = typeof window === 'undefined' ? null : window.dispatchEvent.bind(window); - function registerMessageSecret (secret) { + function registerMessageSecret(secret) { messageSecret = secret; } /** * @returns {HTMLElement} the element to inject the script into */ - function getInjectionElement () { - return document.head || document.documentElement + function getInjectionElement() { + return document.head || document.documentElement; } /** @@ -41,41 +44,41 @@ * @param {string} css * @returns {HTMLLinkElement | HTMLStyleElement} */ - function createStyleElement (css) { + function createStyleElement(css) { let style; { style = document.createElement('style'); style.innerText = css; } - return style + return style; } /** * Injects a script into the page, avoiding CSP restrictions if possible. */ - function injectGlobalStyles (css) { + function injectGlobalStyles(css) { const style = createStyleElement(css); getInjectionElement().appendChild(style); } // linear feedback shift register to find a random approximation - function nextRandom (v) { - return Math.abs((v >> 1) | (((v << 62) ^ (v << 61)) & (~(~0 << 63) << 62))) + function nextRandom(v) { + return Math.abs((v >> 1) | (((v << 62) ^ (v << 61)) & (~(~0 << 63) << 62))); } const exemptionLists = {}; - function shouldExemptUrl (type, url) { + function shouldExemptUrl(type, url) { for (const regex of exemptionLists[type]) { if (regex.test(url)) { - return true + return true; } } - return false + return false; } let debug = false; - function initStringExemptionLists (args) { + function initStringExemptionLists(args) { const { stringExemptionLists } = args; debug = args.debug; for (const type in stringExemptionLists) { @@ -90,18 +93,18 @@ * Best guess effort if the document is being framed * @returns {boolean} if we infer the document is framed */ - function isBeingFramed () { + function isBeingFramed() { if (globalThis.location && 'ancestorOrigins' in globalThis.location) { - return globalThis.location.ancestorOrigins.length > 0 + return globalThis.location.ancestorOrigins.length > 0; } - return globalThis.top !== globalThis.window + return globalThis.top !== globalThis.window; } /** * Best guess effort of the tabs hostname; where possible always prefer the args.site.domain * @returns {string|null} inferred tab hostname */ - function getTabHostname () { + function getTabHostname() { let framingOrigin = null; try { // @ts-expect-error - globalThis.top is possibly 'null' here @@ -122,7 +125,7 @@ } catch { framingOrigin = null; } - return framingOrigin + return framingOrigin; } /** @@ -131,12 +134,12 @@ * @param {string} exceptionDomain * @returns {boolean} */ - function matchHostname (hostname, exceptionDomain) { - return hostname === exceptionDomain || hostname.endsWith(`.${exceptionDomain}`) + function matchHostname(hostname, exceptionDomain) { + return hostname === exceptionDomain || hostname.endsWith(`.${exceptionDomain}`); } const lineTest = /(\()?(https?:[^)]+):[0-9]+:[0-9]+(\))?/; - function getStackTraceUrls (stack) { + function getStackTraceUrls(stack) { const urls = new Set$1(); try { const errorLines = stack.split('\n'); @@ -150,36 +153,36 @@ } catch (e) { // Fall through } - return urls + return urls; } - function getStackTraceOrigins (stack) { + function getStackTraceOrigins(stack) { const urls = getStackTraceUrls(stack); const origins = new Set$1(); for (const url of urls) { origins.add(url.hostname); } - return origins + return origins; } // Checks the stack trace if there are known libraries that are broken. - function shouldExemptMethod (type) { + function shouldExemptMethod(type) { // Short circuit stack tracing if we don't have checks if (!(type in exemptionLists) || exemptionLists[type].length === 0) { - return false + return false; } const stack = getStack(); const errorFiles = getStackTraceUrls(stack); for (const path of errorFiles) { if (shouldExemptUrl(type, path.href)) { - return true + return true; } } - return false + return false; } // Iterate through the key, passing an item index and a byte to be modified - function iterateDataKey (key, callback) { + function iterateDataKey(key, callback) { let item = key.charCodeAt(0); for (const i in key) { let byte = key.charCodeAt(i); @@ -187,7 +190,7 @@ const res = callback(item, byte); // Exit early if callback returns null if (res === null) { - return + return; } // find next item to perturb @@ -199,27 +202,27 @@ } } - function isFeatureBroken (args, feature) { + function isFeatureBroken(args, feature) { return isWindowsSpecificFeature(feature) ? !args.site.enabledFeatures.includes(feature) - : args.site.isBroken || args.site.allowlisted || !args.site.enabledFeatures.includes(feature) + : args.site.isBroken || args.site.allowlisted || !args.site.enabledFeatures.includes(feature); } - function camelcase (dashCaseText) { + function camelcase(dashCaseText) { return dashCaseText.replace(/-(.)/g, (match, letter) => { - return letter.toUpperCase() - }) + return letter.toUpperCase(); + }); } // We use this method to detect M1 macs and set appropriate API values to prevent sites from detecting fingerprinting protections - function isAppleSilicon () { + function isAppleSilicon() { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl'); // Best guess if the device is an Apple Silicon // https://stackoverflow.com/a/65412357 // @ts-expect-error - Object is possibly 'null' - return gl.getSupportedExtensions().indexOf('WEBGL_compressed_texture_etc') !== -1 + return gl.getSupportedExtensions().indexOf('WEBGL_compressed_texture_etc') !== -1; } /** @@ -227,23 +230,23 @@ * If a value contains a criteria that is a match for this environment then return that value. * Otherwise return the first value that doesn't have a criteria. * - * @param {*[]} configSetting - Config setting which should contain a list of possible values + * @param {ConfigSetting[]} configSetting - Config setting which should contain a list of possible values * @returns {*|undefined} - The value from the list that best matches the criteria in the config */ - function processAttrByCriteria (configSetting) { + function processAttrByCriteria(configSetting) { let bestOption; for (const item of configSetting) { if (item.criteria) { if (item.criteria.arch === 'AppleSilicon' && isAppleSilicon()) { bestOption = item; - break + break; } } else { bestOption = item; } } - return bestOption + return bestOption; } const functionMap = { @@ -251,76 +254,86 @@ debug: (...args) => { console.log('debugger', ...args); // eslint-disable-next-line no-debugger - debugger + debugger; }, - // eslint-disable-next-line @typescript-eslint/no-empty-function - noop: () => { } + + noop: () => {}, }; + /** + * @typedef {object} ConfigSetting + * @property {'undefined' | 'number' | 'string' | 'function' | 'boolean' | 'null' | 'array' | 'object'} type + * @property {string} [functionName] + * @property {boolean | string | number} value + * @property {object} [criteria] + * @property {string} criteria.arch + */ + /** * Processes a structured config setting and returns the value according to its type - * @param {*} configSetting + * @param {ConfigSetting} configSetting * @param {*} [defaultValue] * @returns */ - function processAttr (configSetting, defaultValue) { + function processAttr(configSetting, defaultValue) { if (configSetting === undefined) { - return defaultValue + return defaultValue; } const configSettingType = typeof configSetting; switch (configSettingType) { - case 'object': - if (Array.isArray(configSetting)) { - configSetting = processAttrByCriteria(configSetting); - if (configSetting === undefined) { - return defaultValue + case 'object': + if (Array.isArray(configSetting)) { + configSetting = processAttrByCriteria(configSetting); + if (configSetting === undefined) { + return defaultValue; + } } - } - if (!configSetting.type) { - return defaultValue - } + if (!configSetting.type) { + return defaultValue; + } - if (configSetting.type === 'function') { - if (configSetting.functionName && functionMap[configSetting.functionName]) { - return functionMap[configSetting.functionName] + if (configSetting.type === 'function') { + if (configSetting.functionName && functionMap[configSetting.functionName]) { + return functionMap[configSetting.functionName]; + } } - } - if (configSetting.type === 'undefined') { - return undefined - } + if (configSetting.type === 'undefined') { + return undefined; + } - return configSetting.value - default: - return defaultValue + // All JSON expressable types are handled here + return configSetting.value; + default: + return defaultValue; } } - function getStack () { - return new Error$1().stack + function getStack() { + return new Error$1().stack; } /** * @param {*[]} argsArray * @returns {string} */ - function debugSerialize (argsArray) { + function debugSerialize(argsArray) { const maxSerializedSize = 1000; const serializedArgs = argsArray.map((arg) => { try { const serializableOut = JSON.stringify(arg); if (serializableOut.length > maxSerializedSize) { - return `` + return ``; } - return serializableOut + return serializableOut; } catch (e) { // Sometimes this happens when we can't serialize an object to string but we still wish to log it and make other args readable - return '' + return ''; } }); - return JSON.stringify(serializedArgs) + return JSON.stringify(serializedArgs); } /** @@ -339,7 +352,7 @@ * @param {string} property * @param {ProxyObject

} proxyObject */ - constructor (feature, objectScope, property, proxyObject) { + constructor(feature, objectScope, property, proxyObject) { this.objectScope = objectScope; this.property = property; this.feature = feature; @@ -356,14 +369,14 @@ kind: this.property, documentUrl: document.location.href, stack: getStack(), - args: debugSerialize(args[2]) + args: debugSerialize(args[2]), }); } // The normal return value if (isExempt) { - return DDGReflect.apply(...args) + return DDGReflect.apply(...args); } - return proxyObject.apply(...args) + return proxyObject.apply(...args); }; const getMethod = (target, prop, receiver) => { this.feature.addDebugFlag(); @@ -371,11 +384,11 @@ const method = Reflect.get(target, prop, receiver).bind(target); Object.defineProperty(method, 'toString', { value: String.toString.bind(String.toString), - enumerable: false + enumerable: false, }); - return method + return method; } - return DDGReflect.get(target, prop, receiver) + return DDGReflect.get(target, prop, receiver); }; { this._native = objectScope[property]; @@ -387,41 +400,41 @@ } // Actually apply the proxy to the native property - overload () { + overload() { { this.objectScope[this.property] = this.internal; } } - overloadDescriptor () { + overloadDescriptor() { // TODO: this is not always correct! Use wrap* or shim* methods instead this.feature.defineProperty(this.objectScope, this.property, { value: this.internal, writable: true, enumerable: true, - configurable: true + configurable: true, }); } } const maxCounter = new Map(); - function numberOfTimesDebugged (feature) { + function numberOfTimesDebugged(feature) { if (!maxCounter.has(feature)) { maxCounter.set(feature, 1); } else { maxCounter.set(feature, maxCounter.get(feature) + 1); } - return maxCounter.get(feature) + return maxCounter.get(feature); } const DEBUG_MAX_TIMES = 5000; - function postDebugMessage (feature, message, allowNonDebug = false) { + function postDebugMessage(feature, message, allowNonDebug = false) { if (!debug && !allowNonDebug) { - return + return; } if (numberOfTimesDebugged(feature) > DEBUG_MAX_TIMES) { - return + return; } if (message.stack) { const scriptOrigins = [...getStackTraceOrigins(message.stack)]; @@ -429,7 +442,7 @@ } globalObj.postMessage({ action: feature, - message + message, }); } @@ -447,10 +460,10 @@ * @param {object[]} featureList * @returns {boolean} */ - function isUnprotectedDomain (topLevelHostname, featureList) { + function isUnprotectedDomain(topLevelHostname, featureList) { let unprotectedDomain = false; if (!topLevelHostname) { - return false + return false; } const domainParts = topLevelHostname.split('.'); @@ -458,12 +471,12 @@ while (domainParts.length > 1 && !unprotectedDomain) { const partialDomain = domainParts.join('.'); - unprotectedDomain = featureList.filter(domain => domain.domain === partialDomain).length > 0; + unprotectedDomain = featureList.filter((domain) => domain.domain === partialDomain).length > 0; domainParts.shift(); } - return unprotectedDomain + return unprotectedDomain; } /** @@ -485,11 +498,11 @@ /** * Used to inialize extension code in the load phase */ - function computeLimitedSiteObject () { + function computeLimitedSiteObject() { const topLevelHostname = getTabHostname(); return { - domain: topLevelHostname - } + domain: topLevelHostname, + }; } /** @@ -497,18 +510,18 @@ * @param {UserPreferences} preferences * @returns {string | number | undefined} */ - function getPlatformVersion (preferences) { + function getPlatformVersion(preferences) { if (preferences.versionNumber) { - return preferences.versionNumber + return preferences.versionNumber; } if (preferences.versionString) { - return preferences.versionString + return preferences.versionString; } - return undefined + return undefined; } - function parseVersionString (versionString) { - return versionString.split('.').map(Number) + function parseVersionString(versionString) { + return versionString.split('.').map(Number); } /** @@ -516,7 +529,7 @@ * @param {string} applicationVersionString * @returns {boolean} */ - function satisfiesMinVersion (minVersionString, applicationVersionString) { + function satisfiesMinVersion(minVersionString, applicationVersionString) { const minVersions = parseVersionString(minVersionString); const currentVersions = parseVersionString(applicationVersionString); const maxLength = Math.max(minVersions.length, currentVersions.length); @@ -524,13 +537,13 @@ const minNumberPart = minVersions[i] || 0; const currentVersionPart = currentVersions[i] || 0; if (currentVersionPart > minNumberPart) { - return true + return true; } if (currentVersionPart < minNumberPart) { - return false + return false; } } - return true + return true; } /** @@ -538,17 +551,17 @@ * @param {string | number | undefined} currentVersion * @returns {boolean} */ - function isSupportedVersion (minSupportedVersion, currentVersion) { + function isSupportedVersion(minSupportedVersion, currentVersion) { if (typeof currentVersion === 'string' && typeof minSupportedVersion === 'string') { if (satisfiesMinVersion(minSupportedVersion, currentVersion)) { - return true + return true; } } else if (typeof currentVersion === 'number' && typeof minSupportedVersion === 'number') { if (minSupportedVersion <= currentVersion) { - return true + return true; } } - return false + return false; } /** @@ -563,10 +576,10 @@ * @param {UserPreferences} preferences * @param {string[]} platformSpecificFeatures */ - function processConfig (data, userList, preferences, platformSpecificFeatures = []) { + function processConfig(data, userList, preferences, platformSpecificFeatures = []) { const topLevelHostname = getTabHostname(); const site = computeLimitedSiteObject(); - const allowlisted = userList.filter(domain => domain === topLevelHostname).length > 0; + const allowlisted = userList.filter((domain) => domain === topLevelHostname).length > 0; /** @type {Record} */ const output = { ...preferences }; if (output.platform) { @@ -580,7 +593,7 @@ output.site = Object.assign(site, { isBroken, allowlisted, - enabledFeatures + enabledFeatures, }); // Copy feature settings from remote config to preferences object @@ -588,7 +601,7 @@ output.trackerLookup = {"org":{"cdn77":{"rsc":{"1558334541":1}},"adsrvr":1,"ampproject":1,"browser-update":1,"flowplayer":1,"privacy-center":1,"webvisor":1,"framasoft":1,"do-not-tracker":1,"trackersimulator":1},"io":{"1dmp":1,"1rx":1,"4dex":1,"adnami":1,"aidata":1,"arcspire":1,"bidr":1,"branch":1,"center":1,"cloudimg":1,"concert":1,"connectad":1,"cordial":1,"dcmn":1,"extole":1,"getblue":1,"hbrd":1,"instana":1,"karte":1,"leadsmonitor":1,"litix":1,"lytics":1,"marchex":1,"mediago":1,"mrf":1,"narrative":1,"ntv":1,"optad360":1,"oracleinfinity":1,"oribi":1,"p-n":1,"personalizer":1,"pghub":1,"piano":1,"powr":1,"pzz":1,"searchspring":1,"segment":1,"siteimproveanalytics":1,"sspinc":1,"t13":1,"webgains":1,"wovn":1,"yellowblue":1,"zprk":1,"axept":1,"akstat":1,"clarium":1,"hotjar":1},"com":{"2020mustang":1,"33across":1,"360yield":1,"3lift":1,"4dsply":1,"4strokemedia":1,"8353e36c2a":1,"a-mx":1,"a2z":1,"aamsitecertifier":1,"absorbingband":1,"abstractedauthority":1,"abtasty":1,"acexedge":1,"acidpigs":1,"acsbapp":1,"acuityplatform":1,"ad-score":1,"ad-stir":1,"adalyser":1,"adapf":1,"adara":1,"adblade":1,"addthis":1,"addtoany":1,"adelixir":1,"adentifi":1,"adextrem":1,"adgrx":1,"adhese":1,"adition":1,"adkernel":1,"adlightning":1,"adlooxtracking":1,"admanmedia":1,"admedo":1,"adnium":1,"adnxs-simple":1,"adnxs":1,"adobedtm":1,"adotmob":1,"adpone":1,"adpushup":1,"adroll":1,"adrta":1,"ads-twitter":1,"ads3-adnow":1,"adsafeprotected":1,"adstanding":1,"adswizz":1,"adtdp":1,"adtechus":1,"adtelligent":1,"adthrive":1,"adtlgc":1,"adtng":1,"adultfriendfinder":1,"advangelists":1,"adventive":1,"adventori":1,"advertising":1,"aegpresents":1,"affinity":1,"affirm":1,"agilone":1,"agkn":1,"aimbase":1,"albacross":1,"alcmpn":1,"alexametrics":1,"alicdn":1,"alikeaddition":1,"aliveachiever":1,"aliyuncs":1,"alluringbucket":1,"aloofvest":1,"amazon-adsystem":1,"amazon":1,"ambiguousafternoon":1,"amplitude":1,"analytics-egain":1,"aniview":1,"annoyedairport":1,"annoyingclover":1,"anyclip":1,"anymind360":1,"app-us1":1,"appboycdn":1,"appdynamics":1,"appsflyer":1,"aralego":1,"aspiringattempt":1,"aswpsdkus":1,"atemda":1,"att":1,"attentivemobile":1,"attractionbanana":1,"audioeye":1,"audrte":1,"automaticside":1,"avanser":1,"avmws":1,"aweber":1,"aweprt":1,"azure":1,"b0e8":1,"badgevolcano":1,"bagbeam":1,"ballsbanana":1,"bandborder":1,"batch":1,"bawdybalance":1,"bc0a":1,"bdstatic":1,"bedsberry":1,"beginnerpancake":1,"benchmarkemail":1,"betweendigital":1,"bfmio":1,"bidtheatre":1,"billowybelief":1,"bimbolive":1,"bing":1,"bizographics":1,"bizrate":1,"bkrtx":1,"blismedia":1,"blogherads":1,"bluecava":1,"bluekai":1,"blushingbread":1,"boatwizard":1,"boilingcredit":1,"boldchat":1,"booking":1,"borderfree":1,"bounceexchange":1,"brainlyads":1,"brand-display":1,"brandmetrics":1,"brealtime":1,"brightfunnel":1,"brightspotcdn":1,"btloader":1,"btstatic":1,"bttrack":1,"btttag":1,"bumlam":1,"butterbulb":1,"buttonladybug":1,"buzzfeed":1,"buzzoola":1,"byside":1,"c3tag":1,"cabnnr":1,"calculatorstatement":1,"callrail":1,"calltracks":1,"capablecup":1,"captcha-delivery":1,"carpentercomparison":1,"cartstack":1,"carvecakes":1,"casalemedia":1,"cattlecommittee":1,"cdninstagram":1,"cdnwidget":1,"channeladvisor":1,"chargecracker":1,"chartbeat":1,"chatango":1,"chaturbate":1,"cheqzone":1,"cherriescare":1,"chickensstation":1,"childlikecrowd":1,"childlikeform":1,"chocolateplatform":1,"cintnetworks":1,"circlelevel":1,"ck-ie":1,"clcktrax":1,"cleanhaircut":1,"clearbit":1,"clearbitjs":1,"clickagy":1,"clickcease":1,"clickcertain":1,"clicktripz":1,"clientgear":1,"cloudflare":1,"cloudflareinsights":1,"cloudflarestream":1,"cobaltgroup":1,"cobrowser":1,"cognitivlabs":1,"colossusssp":1,"combativecar":1,"comm100":1,"googleapis":{"commondatastorage":1,"imasdk":1,"storage":1,"fonts":1,"maps":1,"www":1},"company-target":1,"condenastdigital":1,"confusedcart":1,"connatix":1,"contextweb":1,"conversionruler":1,"convertkit":1,"convertlanguage":1,"cootlogix":1,"coveo":1,"cpmstar":1,"cquotient":1,"crabbychin":1,"cratecamera":1,"crazyegg":1,"creative-serving":1,"creativecdn":1,"criteo":1,"crowdedmass":1,"crowdriff":1,"crownpeak":1,"crsspxl":1,"ctnsnet":1,"cudasvc":1,"cuddlethehyena":1,"cumbersomecarpenter":1,"curalate":1,"curvedhoney":1,"cushiondrum":1,"cutechin":1,"cxense":1,"d28dc30335":1,"dailymotion":1,"damdoor":1,"dampdock":1,"dapperfloor":1,"datadoghq-browser-agent":1,"decisivebase":1,"deepintent":1,"defybrick":1,"delivra":1,"demandbase":1,"detectdiscovery":1,"devilishdinner":1,"dimelochat":1,"disagreeabledrop":1,"discreetfield":1,"disqus":1,"dmpxs":1,"dockdigestion":1,"dotomi":1,"doubleverify":1,"drainpaste":1,"dramaticdirection":1,"driftt":1,"dtscdn":1,"dtscout":1,"dwin1":1,"dynamics":1,"dynamicyield":1,"dynatrace":1,"ebaystatic":1,"ecal":1,"eccmp":1,"elfsight":1,"elitrack":1,"eloqua":1,"en25":1,"encouragingthread":1,"enormousearth":1,"ensighten":1,"enviousshape":1,"eqads":1,"ero-advertising":1,"esputnik":1,"evergage":1,"evgnet":1,"exdynsrv":1,"exelator":1,"exoclick":1,"exosrv":1,"expansioneggnog":1,"expedia":1,"expertrec":1,"exponea":1,"exponential":1,"extole":1,"ezodn":1,"ezoic":1,"ezoiccdn":1,"facebook":1,"facil-iti":1,"fadewaves":1,"fallaciousfifth":1,"farmergoldfish":1,"fastly-insights":1,"fearlessfaucet":1,"fiftyt":1,"financefear":1,"fitanalytics":1,"five9":1,"fixedfold":1,"fksnk":1,"flashtalking":1,"flipp":1,"flowerstreatment":1,"floweryflavor":1,"flutteringfireman":1,"flux-cdn":1,"foresee":1,"fortunatemark":1,"fouanalytics":1,"fox":1,"fqtag":1,"frailfruit":1,"freezingbuilding":1,"fronttoad":1,"fullstory":1,"functionalfeather":1,"fuzzybasketball":1,"gammamaximum":1,"gbqofs":1,"geetest":1,"geistm":1,"geniusmonkey":1,"geoip-js":1,"getbread":1,"getcandid":1,"getclicky":1,"getdrip":1,"getelevar":1,"getrockerbox":1,"getshogun":1,"getsitecontrol":1,"giraffepiano":1,"glassdoor":1,"gloriousbeef":1,"godpvqnszo":1,"google-analytics":1,"google":1,"googleadservices":1,"googlehosted":1,"googleoptimize":1,"googlesyndication":1,"googletagmanager":1,"googletagservices":1,"gorgeousedge":1,"govx":1,"grainmass":1,"greasysquare":1,"greylabeldelivery":1,"groovehq":1,"growsumo":1,"gstatic":1,"guarantee-cdn":1,"guiltlessbasketball":1,"gumgum":1,"haltingbadge":1,"hammerhearing":1,"handsomelyhealth":1,"harborcaption":1,"hawksearch":1,"amazonaws":{"us-east-2":{"s3":{"hb-obv2":1}}},"heapanalytics":1,"hellobar":1,"hhbypdoecp":1,"hiconversion":1,"highwebmedia":1,"histats":1,"hlserve":1,"hocgeese":1,"hollowafterthought":1,"honorableland":1,"hotjar":1,"hp":1,"hs-banner":1,"htlbid":1,"htplayground":1,"hubspot":1,"ib-ibi":1,"id5-sync":1,"igodigital":1,"iheart":1,"iljmp":1,"illiweb":1,"impactcdn":1,"impactradius-event":1,"impressionmonster":1,"improvedcontactform":1,"improvedigital":1,"imrworldwide":1,"indexww":1,"infolinks":1,"infusionsoft":1,"inmobi":1,"inq":1,"inside-graph":1,"instagram":1,"intentiq":1,"intergient":1,"investingchannel":1,"invocacdn":1,"iperceptions":1,"iplsc":1,"ipredictive":1,"iteratehq":1,"ivitrack":1,"j93557g":1,"jaavnacsdw":1,"jimstatic":1,"journity":1,"js7k":1,"jscache":1,"juiceadv":1,"juicyads":1,"justanswer":1,"justpremium":1,"jwpcdn":1,"kakao":1,"kampyle":1,"kargo":1,"kissmetrics":1,"klarnaservices":1,"klaviyo":1,"knottyswing":1,"krushmedia":1,"ktkjmp":1,"kxcdn":1,"laboredlocket":1,"ladesk":1,"ladsp":1,"laughablelizards":1,"leadsrx":1,"lendingtree":1,"levexis":1,"liadm":1,"licdn":1,"lightboxcdn":1,"lijit":1,"linkedin":1,"linksynergy":1,"list-manage":1,"listrakbi":1,"livechatinc":1,"livejasmin":1,"localytics":1,"loggly":1,"loop11":1,"looseloaf":1,"lovelydrum":1,"lunchroomlock":1,"lwonclbench":1,"macromill":1,"maddeningpowder":1,"mailchimp":1,"mailchimpapp":1,"mailerlite":1,"maillist-manage":1,"marinsm":1,"marketiq":1,"marketo":1,"marphezis":1,"marriedbelief":1,"materialparcel":1,"matheranalytics":1,"mathtag":1,"maxmind":1,"mczbf":1,"measlymiddle":1,"medallia":1,"meddleplant":1,"media6degrees":1,"mediacategory":1,"mediavine":1,"mediawallahscript":1,"medtargetsystem":1,"megpxs":1,"memberful":1,"memorizematch":1,"mentorsticks":1,"metaffiliation":1,"metricode":1,"metricswpsh":1,"mfadsrvr":1,"mgid":1,"micpn":1,"microadinc":1,"minutemedia-prebid":1,"minutemediaservices":1,"mixpo":1,"mkt932":1,"mktoresp":1,"mktoweb":1,"ml314":1,"moatads":1,"mobtrakk":1,"monsido":1,"mookie1":1,"motionflowers":1,"mountain":1,"mouseflow":1,"mpeasylink":1,"mql5":1,"mrtnsvr":1,"murdoog":1,"mxpnl":1,"mybestpro":1,"myregistry":1,"nappyattack":1,"navistechnologies":1,"neodatagroup":1,"nervoussummer":1,"netmng":1,"newrelic":1,"newscgp":1,"nextdoor":1,"ninthdecimal":1,"nitropay":1,"noibu":1,"nondescriptnote":1,"nosto":1,"npttech":1,"ntvpwpush":1,"nuance":1,"nutritiousbean":1,"nxsttv":1,"omappapi":1,"omnisnippet1":1,"omnisrc":1,"omnitagjs":1,"ondemand":1,"oneall":1,"onesignal":1,"onetag-sys":1,"oo-syringe":1,"ooyala":1,"opecloud":1,"opentext":1,"opera":1,"opmnstr":1,"opti-digital":1,"optimicdn":1,"optimizely":1,"optinmonster":1,"optmnstr":1,"optmstr":1,"optnmnstr":1,"optnmstr":1,"osano":1,"otm-r":1,"outbrain":1,"overconfidentfood":1,"ownlocal":1,"pailpatch":1,"panickypancake":1,"panoramicplane":1,"parastorage":1,"pardot":1,"parsely":1,"partplanes":1,"patreon":1,"paypal":1,"pbstck":1,"pcmag":1,"peerius":1,"perfdrive":1,"perfectmarket":1,"permutive":1,"picreel":1,"pinterest":1,"pippio":1,"piwikpro":1,"pixlee":1,"placidperson":1,"pleasantpump":1,"plotrabbit":1,"pluckypocket":1,"pocketfaucet":1,"possibleboats":1,"postaffiliatepro":1,"postrelease":1,"potatoinvention":1,"powerfulcopper":1,"predictplate":1,"prepareplanes":1,"pricespider":1,"priceypies":1,"pricklydebt":1,"profusesupport":1,"proofpoint":1,"protoawe":1,"providesupport":1,"pswec":1,"psychedelicarithmetic":1,"psyma":1,"ptengine":1,"publir":1,"pubmatic":1,"pubmine":1,"pubnation":1,"qualaroo":1,"qualtrics":1,"quantcast":1,"quantserve":1,"quantummetric":1,"quietknowledge":1,"quizzicalpartner":1,"quizzicalzephyr":1,"quora":1,"r42tag":1,"radiateprose":1,"railwayreason":1,"rakuten":1,"rambunctiousflock":1,"rangeplayground":1,"rating-widget":1,"realsrv":1,"rebelswing":1,"reconditerake":1,"reconditerespect":1,"recruitics":1,"reddit":1,"redditstatic":1,"rehabilitatereason":1,"repeatsweater":1,"reson8":1,"resonantrock":1,"resonate":1,"responsiveads":1,"restrainstorm":1,"restructureinvention":1,"retargetly":1,"revcontent":1,"rezync":1,"rfihub":1,"rhetoricalloss":1,"richaudience":1,"righteouscrayon":1,"rightfulfall":1,"riotgames":1,"riskified":1,"rkdms":1,"rlcdn":1,"rmtag":1,"rogersmedia":1,"rokt":1,"route":1,"rtbsystem":1,"rubiconproject":1,"ruralrobin":1,"s-onetag":1,"saambaa":1,"sablesong":1,"sail-horizon":1,"salesforceliveagent":1,"samestretch":1,"sascdn":1,"satisfycork":1,"savoryorange":1,"scarabresearch":1,"scaredsnakes":1,"scaredsong":1,"scaredstomach":1,"scarfsmash":1,"scene7":1,"scholarlyiq":1,"scintillatingsilver":1,"scorecardresearch":1,"screechingstove":1,"screenpopper":1,"scribblestring":1,"sddan":1,"seatsmoke":1,"securedvisit":1,"seedtag":1,"sefsdvc":1,"segment":1,"sekindo":1,"selectivesummer":1,"selfishsnake":1,"servebom":1,"servedbyadbutler":1,"servenobid":1,"serverbid":1,"serving-sys":1,"shakegoldfish":1,"shamerain":1,"shapecomb":1,"shappify":1,"shareaholic":1,"sharethis":1,"sharethrough":1,"shopifyapps":1,"shopperapproved":1,"shrillspoon":1,"sibautomation":1,"sicksmash":1,"signifyd":1,"singroot":1,"site":1,"siteimprove":1,"siteimproveanalytics":1,"sitescout":1,"sixauthority":1,"skillfuldrop":1,"skimresources":1,"skisofa":1,"sli-spark":1,"slickstream":1,"slopesoap":1,"smadex":1,"smartadserver":1,"smashquartz":1,"smashsurprise":1,"smg":1,"smilewanted":1,"smoggysnakes":1,"snapchat":1,"snapkit":1,"snigelweb":1,"socdm":1,"sojern":1,"songsterritory":1,"sonobi":1,"soundstocking":1,"spectacularstamp":1,"speedcurve":1,"sphereup":1,"spiceworks":1,"spookyexchange":1,"spookyskate":1,"spookysleet":1,"sportradarserving":1,"sportslocalmedia":1,"spotxchange":1,"springserve":1,"srvmath":1,"ssl-images-amazon":1,"stackadapt":1,"stakingsmile":1,"statcounter":1,"steadfastseat":1,"steadfastsound":1,"steadfastsystem":1,"steelhousemedia":1,"steepsquirrel":1,"stereotypedsugar":1,"stickyadstv":1,"stiffgame":1,"stingycrush":1,"straightnest":1,"stripchat":1,"strivesquirrel":1,"strokesystem":1,"stupendoussleet":1,"stupendoussnow":1,"stupidscene":1,"sulkycook":1,"sumo":1,"sumologic":1,"sundaysky":1,"superficialeyes":1,"superficialsquare":1,"surveymonkey":1,"survicate":1,"svonm":1,"swankysquare":1,"symantec":1,"taboola":1,"tailtarget":1,"talkable":1,"tamgrt":1,"tangycover":1,"taobao":1,"tapad":1,"tapioni":1,"taptapnetworks":1,"taskanalytics":1,"tealiumiq":1,"techlab-cdn":1,"technoratimedia":1,"techtarget":1,"tediousticket":1,"teenytinyshirt":1,"tendertest":1,"the-ozone-project":1,"theadex":1,"themoneytizer":1,"theplatform":1,"thestar":1,"thinkitten":1,"threetruck":1,"thrtle":1,"tidaltv":1,"tidiochat":1,"tiktok":1,"tinypass":1,"tiqcdn":1,"tiresomethunder":1,"trackjs":1,"traffichaus":1,"trafficjunky":1,"trafmag":1,"travelaudience":1,"treasuredata":1,"tremorhub":1,"trendemon":1,"tribalfusion":1,"trovit":1,"trueleadid":1,"truoptik":1,"truste":1,"trustpilot":1,"trvdp":1,"tsyndicate":1,"tubemogul":1,"turn":1,"tvpixel":1,"tvsquared":1,"tweakwise":1,"twitter":1,"tynt":1,"typicalteeth":1,"u5e":1,"ubembed":1,"uidapi":1,"ultraoranges":1,"unbecominglamp":1,"unbxdapi":1,"undertone":1,"uninterestedquarter":1,"unpkg":1,"unrulymedia":1,"unwieldyhealth":1,"unwieldyplastic":1,"upsellit":1,"urbanairship":1,"usabilla":1,"usbrowserspeed":1,"usemessages":1,"userreport":1,"uservoice":1,"valuecommerce":1,"vengefulgrass":1,"vidazoo":1,"videoplayerhub":1,"vidoomy":1,"viglink":1,"visualwebsiteoptimizer":1,"vivaclix":1,"vk":1,"vlitag":1,"voicefive":1,"volatilevessel":1,"voraciousgrip":1,"voxmedia":1,"vrtcal":1,"w3counter":1,"walkme":1,"warmafterthought":1,"warmquiver":1,"webcontentassessor":1,"webengage":1,"webeyez":1,"webtraxs":1,"webtrends-optimize":1,"webtrends":1,"wgplayer":1,"woosmap":1,"worldoftulo":1,"wpadmngr":1,"wpshsdk":1,"wpushsdk":1,"wsod":1,"wt-safetag":1,"wysistat":1,"xg4ken":1,"xiti":1,"xlirdr":1,"xlivrdr":1,"xnxx-cdn":1,"y-track":1,"yahoo":1,"yandex":1,"yieldmo":1,"yieldoptimizer":1,"yimg":1,"yotpo":1,"yottaa":1,"youtube-nocookie":1,"youtube":1,"zemanta":1,"zendesk":1,"zeotap":1,"zestycrime":1,"zonos":1,"zoominfo":1,"zopim":1,"createsend1":1,"veoxa":1,"parchedsofa":1,"sooqr":1,"adtraction":1,"addthisedge":1,"adsymptotic":1,"bootstrapcdn":1,"bugsnag":1,"dmxleo":1,"dtssrv":1,"fontawesome":1,"hs-scripts":1,"jwpltx":1,"nereserv":1,"onaudience":1,"outbrainimg":1,"quantcount":1,"rtactivate":1,"shopifysvc":1,"stripe":1,"twimg":1,"vimeo":1,"vimeocdn":1,"wp":1,"2znp09oa":1,"4jnzhl0d0":1,"6ldu6qa":1,"82o9v830":1,"abilityscale":1,"aboardamusement":1,"aboardlevel":1,"abovechat":1,"abruptroad":1,"absentairport":1,"absorbingcorn":1,"absorbingprison":1,"abstractedamount":1,"absurdapple":1,"abundantcoin":1,"acceptableauthority":1,"accurateanimal":1,"accuratecoal":1,"achieverknee":1,"acidicstraw":1,"acridangle":1,"acridtwist":1,"actoramusement":1,"actuallysheep":1,"actuallysnake":1,"actuallything":1,"adamantsnail":1,"addictedattention":1,"adorableanger":1,"adorableattention":1,"adventurousamount":1,"afraidlanguage":1,"aftermathbrother":1,"agilebreeze":1,"agreeablearch":1,"agreeabletouch":1,"aheadday":1,"aheadgrow":1,"aheadmachine":1,"ak0gsh40":1,"alertarithmetic":1,"aliasanvil":1,"alleythecat":1,"aloofmetal":1,"alpineactor":1,"ambientdusk":1,"ambientlagoon":1,"ambiguousanger":1,"ambiguousdinosaurs":1,"ambiguousincome":1,"ambrosialsummit":1,"amethystzenith":1,"amuckafternoon":1,"amusedbucket":1,"analogwonder":1,"analyzecorona":1,"ancientact":1,"annoyingacoustics":1,"anxiousapples":1,"aquaticowl":1,"ar1nvz5":1,"archswimming":1,"aromamirror":1,"arrivegrowth":1,"artthevoid":1,"aspiringapples":1,"aspiringtoy":1,"astonishingfood":1,"astralhustle":1,"astrallullaby":1,"attendchase":1,"attractivecap":1,"audioarctic":1,"automaticturkey":1,"availablerest":1,"avalonalbum":1,"averageactivity":1,"awarealley":1,"awesomeagreement":1,"awzbijw":1,"axiomaticalley":1,"axiomaticanger":1,"azuremystique":1,"backupcat":1,"badgeboat":1,"badgerabbit":1,"baitbaseball":1,"balloonbelieve":1,"bananabarrel":1,"barbarousbase":1,"basilfish":1,"basketballbelieve":1,"baskettexture":1,"bawdybeast":1,"beamvolcano":1,"beancontrol":1,"bearmoonlodge":1,"beetleend":1,"begintrain":1,"berserkhydrant":1,"bespokesandals":1,"bestboundary":1,"bewilderedbattle":1,"bewilderedblade":1,"bhcumsc":1,"bikepaws":1,"bikesboard":1,"billowybead":1,"binspiredtees":1,"birthdaybelief":1,"blackbrake":1,"bleachbubble":1,"bleachscarecrow":1,"bleedlight":1,"blesspizzas":1,"blissfulcrescendo":1,"blissfullagoon":1,"blueeyedblow":1,"blushingbeast":1,"boatsvest":1,"boilingbeetle":1,"boostbehavior":1,"boredcrown":1,"bouncyproperty":1,"boundarybusiness":1,"boundlessargument":1,"boundlessbrake":1,"boundlessveil":1,"brainybasin":1,"brainynut":1,"branchborder":1,"brandsfive":1,"brandybison":1,"bravebone":1,"bravecalculator":1,"breadbalance":1,"breakableinsurance":1,"breakfastboat":1,"breezygrove":1,"brianwould":1,"brighttoe":1,"briskstorm":1,"broadborder":1,"broadboundary":1,"broadcastbed":1,"broaddoor":1,"brotherslocket":1,"bruisebaseball":1,"brunchforher":1,"buildingknife":1,"bulbbait":1,"burgersalt":1,"burlywhistle":1,"burnbubble":1,"bushesbag":1,"bustlingbath":1,"bustlingbook":1,"butterburst":1,"cakesdrum":1,"calculatingcircle":1,"calculatingtoothbrush":1,"callousbrake":1,"calmcactus":1,"calypsocapsule":1,"cannonchange":1,"capablecows":1,"capriciouscorn":1,"captivatingcanyon":1,"captivatingillusion":1,"captivatingpanorama":1,"captivatingperformance":1,"carefuldolls":1,"caringcast":1,"caringzinc":1,"carloforward":1,"carscannon":1,"cartkitten":1,"catalogcake":1,"catschickens":1,"causecherry":1,"cautiouscamera":1,"cautiouscherries":1,"cautiouscrate":1,"cautiouscredit":1,"cavecurtain":1,"ceciliavenus":1,"celestialeuphony":1,"celestialquasar":1,"celestialspectra":1,"chaireggnog":1,"chairscrack":1,"chairsdonkey":1,"chalkoil":1,"changeablecats":1,"channelcamp":1,"charmingplate":1,"charscroll":1,"cheerycraze":1,"chessbranch":1,"chesscolor":1,"chesscrowd":1,"childlikeexample":1,"chilledliquid":1,"chingovernment":1,"chinsnakes":1,"chipperisle":1,"chivalrouscord":1,"chubbycreature":1,"chunkycactus":1,"cicdserver":1,"cinemabonus":1,"clammychicken":1,"cloisteredcord":1,"cloisteredcurve":1,"closedcows":1,"closefriction":1,"cloudhustles":1,"cloudjumbo":1,"clovercabbage":1,"clumsycar":1,"coatfood":1,"cobaltoverture":1,"coffeesidehustle":1,"coldbalance":1,"coldcreatives":1,"colorfulafterthought":1,"colossalclouds":1,"colossalcoat":1,"colossalcry":1,"combativedetail":1,"combbit":1,"combcattle":1,"combcompetition":1,"cometquote":1,"comfortablecheese":1,"comfygoodness":1,"companyparcel":1,"comparereaction":1,"compiledoctor":1,"concernedchange":1,"concernedchickens":1,"condemnedcomb":1,"conditionchange":1,"conditioncrush":1,"confesschairs":1,"configchain":1,"connectashelf":1,"consciouschairs":1,"consciouscheese":1,"consciousdirt":1,"consumerzero":1,"controlcola":1,"controlhall":1,"convertbatch":1,"cooingcoal":1,"coordinatedbedroom":1,"coordinatedcoat":1,"copycarpenter":1,"copyrightaccesscontrols":1,"coralreverie":1,"corgibeachday":1,"cosmicsculptor":1,"cosmosjackson":1,"courageousbaby":1,"coverapparatus":1,"coverlayer":1,"cozydusk":1,"cozyhillside":1,"cozytryst":1,"crackedsafe":1,"crafthenry":1,"crashchance":1,"craterbox":1,"creatorcherry":1,"creatorpassenger":1,"creaturecabbage":1,"crimsonmeadow":1,"critictruck":1,"crookedcreature":1,"cruisetourist":1,"cryptvalue":1,"crystalboulevard":1,"crystalstatus":1,"cubchannel":1,"cubepins":1,"cuddlycake":1,"cuddlylunchroom":1,"culturedcamera":1,"culturedfeather":1,"cumbersomecar":1,"cumbersomecloud":1,"curiouschalk":1,"curioussuccess":1,"curlycannon":1,"currentcollar":1,"curtaincows":1,"curvycord":1,"curvycry":1,"cushionpig":1,"cutcurrent":1,"cyclopsdial":1,"dailydivision":1,"damagedadvice":1,"damageddistance":1,"dancemistake":1,"dandydune":1,"dandyglow":1,"dapperdiscussion":1,"datastoried":1,"daughterstone":1,"daymodern":1,"dazzlingbook":1,"deafeningdock":1,"deafeningdowntown":1,"debonairdust":1,"debonairtree":1,"debugentity":1,"decidedrum":1,"decisivedrawer":1,"decisiveducks":1,"decoycreation":1,"deerbeginner":1,"defeatedbadge":1,"defensevest":1,"degreechariot":1,"delegatediscussion":1,"delicatecascade":1,"deliciousducks":1,"deltafault":1,"deluxecrate":1,"dependenttrip":1,"desirebucket":1,"desiredirt":1,"detailedgovernment":1,"detailedkitten":1,"detectdinner":1,"detourgame":1,"deviceseal":1,"deviceworkshop":1,"dewdroplagoon":1,"difficultfog":1,"digestiondrawer":1,"dinnerquartz":1,"diplomahawaii":1,"direfuldesk":1,"discreetquarter":1,"distributionneck":1,"distributionpocket":1,"distributiontomatoes":1,"disturbedquiet":1,"divehope":1,"dk4ywix":1,"dogsonclouds":1,"dollardelta":1,"doubledefend":1,"doubtdrawer":1,"dq95d35":1,"dreamycanyon":1,"driftpizza":1,"drollwharf":1,"drydrum":1,"dustydime":1,"dustyhammer":1,"eagereden":1,"eagerflame":1,"eagerknight":1,"earthyfarm":1,"eatablesquare":1,"echochief":1,"echoinghaven":1,"effervescentcoral":1,"effervescentvista":1,"effulgentnook":1,"effulgenttempest":1,"ejyymghi":1,"elasticchange":1,"elderlybean":1,"elderlytown":1,"elephantqueue":1,"elusivebreeze":1,"elusivecascade":1,"elysiantraverse":1,"embellishedmeadow":1,"embermosaic":1,"emberwhisper":1,"eminentbubble":1,"eminentend":1,"emptyescort":1,"enchantedskyline":1,"enchantingdiscovery":1,"enchantingenchantment":1,"enchantingmystique":1,"enchantingtundra":1,"enchantingvalley":1,"encourageshock":1,"endlesstrust":1,"endurablebulb":1,"energeticexample":1,"energeticladybug":1,"engineergrape":1,"engineertrick":1,"enigmaticblossom":1,"enigmaticcanyon":1,"enigmaticvoyage":1,"enormousfoot":1,"enterdrama":1,"entertainskin":1,"enthusiastictemper":1,"enviousthread":1,"equablekettle":1,"etherealbamboo":1,"ethereallagoon":1,"etherealpinnacle":1,"etherealquasar":1,"etherealripple":1,"evanescentedge":1,"evasivejar":1,"eventexistence":1,"exampleshake":1,"excitingtub":1,"exclusivebrass":1,"executeknowledge":1,"exhibitsneeze":1,"exquisiteartisanship":1,"extractobservation":1,"extralocker":1,"extramonies":1,"exuberantedge":1,"facilitatebreakfast":1,"fadechildren":1,"fadedsnow":1,"fairfeeling":1,"fairiesbranch":1,"fairytaleflame":1,"falseframe":1,"familiarrod":1,"fancyactivity":1,"fancydune":1,"fancygrove":1,"fangfeeling":1,"fantastictone":1,"farethief":1,"farshake":1,"farsnails":1,"fastenfather":1,"fasterfineart":1,"fasterjson":1,"fatcoil":1,"faucetfoot":1,"faultycanvas":1,"fearfulfish":1,"fearfulmint":1,"fearlesstramp":1,"featherstage":1,"feeblestamp":1,"feignedfaucet":1,"fernwaycloud":1,"fertilefeeling":1,"fewjuice":1,"fewkittens":1,"finalizeforce":1,"finestpiece":1,"finitecube":1,"firecatfilms":1,"fireworkcamp":1,"firstendpoint":1,"firstfrogs":1,"firsttexture":1,"fitmessage":1,"fivesidedsquare":1,"flakyfeast":1,"flameuncle":1,"flimsycircle":1,"flimsythought":1,"flippedfunnel":1,"floodprincipal":1,"flourishingcollaboration":1,"flourishingendeavor":1,"flourishinginnovation":1,"flourishingpartnership":1,"flowersornament":1,"flowerycreature":1,"floweryfact":1,"floweryoperation":1,"foambench":1,"followborder":1,"forecasttiger":1,"foretellfifth":1,"forevergears":1,"forgetfulflowers":1,"forgetfulsnail":1,"fractalcoast":1,"framebanana":1,"franticroof":1,"frantictrail":1,"frazzleart":1,"freakyglass":1,"frequentflesh":1,"friendlycrayon":1,"friendlyfold":1,"friendwool":1,"frightenedpotato":1,"frogator":1,"frogtray":1,"frugalfiestas":1,"fumblingform":1,"functionalcrown":1,"funoverbored":1,"funoverflow":1,"furnstudio":1,"furryfork":1,"furryhorses":1,"futuristicapparatus":1,"futuristicfairies":1,"futuristicfifth":1,"futuristicframe":1,"fuzzyaudio":1,"fuzzyerror":1,"gardenovens":1,"gaudyairplane":1,"geekactive":1,"generalprose":1,"generateoffice":1,"giantsvessel":1,"giddycoat":1,"gitcrumbs":1,"givevacation":1,"gladglen":1,"gladysway":1,"glamhawk":1,"gleamingcow":1,"gleaminghaven":1,"glisteningguide":1,"glisteningsign":1,"glitteringbrook":1,"glowingmeadow":1,"gluedpixel":1,"goldfishgrowth":1,"gondolagnome":1,"goodbark":1,"gracefulmilk":1,"grandfatherguitar":1,"gravitygive":1,"gravitykick":1,"grayoranges":1,"grayreceipt":1,"greyinstrument":1,"gripcorn":1,"groovyornament":1,"grouchybrothers":1,"grouchypush":1,"grumpydime":1,"grumpydrawer":1,"guardeddirection":1,"guardedschool":1,"guessdetail":1,"guidecent":1,"guildalpha":1,"gulliblegrip":1,"gustocooking":1,"gustygrandmother":1,"habitualhumor":1,"halcyoncanyon":1,"halcyonsculpture":1,"hallowedinvention":1,"haltingdivision":1,"haltinggold":1,"handleteeth":1,"handnorth":1,"handsomehose":1,"handsomeindustry":1,"handsomelythumb":1,"handsomeyam":1,"handyfield":1,"handyfireman":1,"handyincrease":1,"haplesshydrant":1,"haplessland":1,"happysponge":1,"harborcub":1,"harmonicbamboo":1,"harmonywing":1,"hatefulrequest":1,"headydegree":1,"headyhook":1,"healflowers":1,"hearinglizards":1,"heartbreakingmind":1,"hearthorn":1,"heavydetail":1,"heavyplayground":1,"helpcollar":1,"helpflame":1,"hfc195b":1,"highfalutinbox":1,"highfalutinhoney":1,"hilariouszinc":1,"historicalbeam":1,"homelycrown":1,"honeybulb":1,"honeywhipped":1,"honorablehydrant":1,"horsenectar":1,"hospitablehall":1,"hospitablehat":1,"howdyinbox":1,"humdrumhobbies":1,"humdrumtouch":1,"hurtgrape":1,"hypnoticwound":1,"hystericalcloth":1,"hystericalfinger":1,"idolscene":1,"idyllicjazz":1,"illinvention":1,"illustriousoatmeal":1,"immensehoney":1,"imminentshake":1,"importantmeat":1,"importedincrease":1,"importedinsect":1,"importlocate":1,"impossibleexpansion":1,"impossiblemove":1,"impulsejewel":1,"impulselumber":1,"incomehippo":1,"incompetentjoke":1,"inconclusiveaction":1,"infamousstream":1,"innocentlamp":1,"innocentwax":1,"inputicicle":1,"inquisitiveice":1,"inquisitiveinvention":1,"intelligentscissors":1,"intentlens":1,"interestdust":1,"internalcondition":1,"internalsink":1,"iotapool":1,"irritatingfog":1,"itemslice":1,"ivykiosk":1,"jadeitite":1,"jaderooster":1,"jailbulb":1,"joblessdrum":1,"jollylens":1,"joyfulkeen":1,"joyoussurprise":1,"jubilantaura":1,"jubilantcanyon":1,"jubilantcascade":1,"jubilantglimmer":1,"jubilanttempest":1,"jubilantwhisper":1,"justicejudo":1,"kaputquill":1,"keenquill":1,"kindhush":1,"kitesquirrel":1,"knitstamp":1,"laboredlight":1,"lameletters":1,"lamplow":1,"largebrass":1,"lasttaco":1,"leaplunchroom":1,"leftliquid":1,"lemonpackage":1,"lemonsandjoy":1,"liftedknowledge":1,"lightenafterthought":1,"lighttalon":1,"livelumber":1,"livelylaugh":1,"livelyreward":1,"livingsleet":1,"lizardslaugh":1,"loadsurprise":1,"lonelyflavor":1,"longingtrees":1,"lorenzourban":1,"losslace":1,"loudlunch":1,"loveseashore":1,"lp3tdqle":1,"ludicrousarch":1,"lumberamount":1,"luminousboulevard":1,"luminouscatalyst":1,"luminoussculptor":1,"lumpygnome":1,"lumpylumber":1,"lustroushaven":1,"lyricshook":1,"madebyintent":1,"magicaljoin":1,"magnetairport":1,"majesticmountainrange":1,"majesticwaterscape":1,"majesticwilderness":1,"maliciousmusic":1,"managedpush":1,"mantrafox":1,"marblediscussion":1,"markahouse":1,"markedmeasure":1,"marketspiders":1,"marriedmailbox":1,"marriedvalue":1,"massivemark":1,"materialisticmoon":1,"materialmilk":1,"materialplayground":1,"meadowlullaby":1,"meatydime":1,"mediatescarf":1,"mediumshort":1,"mellowhush":1,"mellowmailbox":1,"melodiouschorus":1,"melodiouscomposition":1,"meltmilk":1,"memopilot":1,"memorizeneck":1,"meremark":1,"merequartz":1,"merryopal":1,"merryvault":1,"messagenovice":1,"messyoranges":1,"mightyspiders":1,"mimosamajor":1,"mindfulgem":1,"minorcattle":1,"minusmental":1,"minuteburst":1,"miscreantmoon":1,"mistyhorizon":1,"mittencattle":1,"mixedreading":1,"modularmental":1,"monacobeatles":1,"moorshoes":1,"motionlessbag":1,"motionlessbelief":1,"motionlessmeeting":1,"movemeal":1,"muddledaftermath":1,"muddledmemory":1,"mundanenail":1,"mundanepollution":1,"mushywaste":1,"muteknife":1,"mutemailbox":1,"mysticalagoon":1,"naivestatement":1,"nappyneck":1,"neatshade":1,"nebulacrescent":1,"nebulajubilee":1,"nebulousamusement":1,"nebulousgarden":1,"nebulousquasar":1,"nebulousripple":1,"needlessnorth":1,"needyneedle":1,"neighborlywatch":1,"niftygraphs":1,"niftyhospital":1,"niftyjelly":1,"nightwound":1,"nimbleplot":1,"nocturnalloom":1,"nocturnalmystique":1,"noiselessplough":1,"nonchalantnerve":1,"nondescriptcrowd":1,"nondescriptstocking":1,"nostalgicknot":1,"nostalgicneed":1,"notifyglass":1,"nudgeduck":1,"nullnorth":1,"numberlessring":1,"numerousnest":1,"nuttyorganization":1,"oafishchance":1,"oafishobservation":1,"obscenesidewalk":1,"observantice":1,"oldfashionedoffer":1,"omgthink":1,"omniscientfeeling":1,"onlywoofs":1,"opalquill":1,"operationchicken":1,"operationnail":1,"oppositeoperation":1,"optimallimit":1,"opulentsylvan":1,"orientedargument":1,"orionember":1,"ourblogthing":1,"outgoinggiraffe":1,"outsidevibe":1,"outstandingincome":1,"outstandingsnails":1,"overkick":1,"overratedchalk":1,"oxygenfuse":1,"pailcrime":1,"painstakingpickle":1,"paintpear":1,"paleleaf":1,"pamelarandom":1,"panickycurtain":1,"parallelbulb":1,"pardonpopular":1,"parentpicture":1,"parsimoniouspolice":1,"passivepolo":1,"pastoralroad":1,"pawsnug":1,"peacefullimit":1,"pedromister":1,"pedropanther":1,"perceivequarter":1,"perkyjade":1,"petiteumbrella":1,"philippinch":1,"photographpan":1,"piespower":1,"piquantgrove":1,"piquantmeadow":1,"piquantpigs":1,"piquantprice":1,"piquantvortex":1,"pixeledhub":1,"pizzasnut":1,"placeframe":1,"placidactivity":1,"planebasin":1,"plantdigestion":1,"playfulriver":1,"plotparent":1,"pluckyzone":1,"poeticpackage":1,"pointdigestion":1,"pointlesshour":1,"pointlesspocket":1,"pointlessprofit":1,"pointlessrifle":1,"polarismagnet":1,"polishedcrescent":1,"polishedfolly":1,"politeplanes":1,"politicalflip":1,"politicalporter":1,"popplantation":1,"possiblepencil":1,"powderjourney":1,"powerfulblends":1,"preciousplanes":1,"prefixpatriot":1,"presetrabbits":1,"previousplayground":1,"previouspotato":1,"pricklypollution":1,"pristinegale":1,"probablepartner":1,"processplantation":1,"producepickle":1,"productsurfer":1,"profitrumour":1,"promiseair":1,"proofconvert":1,"propertypotato":1,"protestcopy":1,"psychedelicchess":1,"publicsofa":1,"puffyloss":1,"puffypaste":1,"puffypull":1,"puffypurpose":1,"pulsatingmeadow":1,"pumpedpancake":1,"pumpedpurpose":1,"punyplant":1,"puppytooth":1,"purposepipe":1,"quacksquirrel":1,"quaintcan":1,"quaintlake":1,"quantumlagoon":1,"quantumshine":1,"queenskart":1,"quillkick":1,"quirkybliss":1,"quirkysugar":1,"quixoticnebula":1,"rabbitbreath":1,"rabbitrifle":1,"radiantcanopy":1,"radiantlullaby":1,"railwaygiraffe":1,"raintwig":1,"rainyhand":1,"rainyrule":1,"rangecake":1,"raresummer":1,"reactjspdf":1,"readingguilt":1,"readymoon":1,"readysnails":1,"realizedoor":1,"realizerecess":1,"rebelclover":1,"rebelhen":1,"rebelsubway":1,"receiptcent":1,"receptiveink":1,"receptivereaction":1,"recessrain":1,"reconditeprison":1,"reflectivestatement":1,"refundradar":1,"regularplants":1,"regulatesleet":1,"relationrest":1,"reloadphoto":1,"rememberdiscussion":1,"rentinfinity":1,"replaceroute":1,"resonantbrush":1,"respectrain":1,"resplendentecho":1,"retrievemint":1,"rhetoricalactivity":1,"rhetoricalveil":1,"rhymezebra":1,"rhythmrule":1,"richstring":1,"rigidrobin":1,"rigidveil":1,"rigorlab":1,"ringplant":1,"ringsrecord":1,"ritzykey":1,"ritzyrepresentative":1,"ritzyveil":1,"rockpebbles":1,"rollconnection":1,"roofrelation":1,"roseincome":1,"rottenray":1,"rusticprice":1,"ruthlessdegree":1,"ruthlessmilk":1,"sableloss":1,"sablesmile":1,"sadloaf":1,"saffronrefuge":1,"sagargift":1,"saltsacademy":1,"samesticks":1,"samplesamba":1,"scarcecard":1,"scarceshock":1,"scarcesign":1,"scarcestructure":1,"scarcesurprise":1,"scaredcomfort":1,"scaredsidewalk":1,"scaredslip":1,"scaredsnake":1,"scaredswing":1,"scarefowl":1,"scatteredheat":1,"scatteredquiver":1,"scatteredstream":1,"scenicapparel":1,"scientificshirt":1,"scintillatingscissors":1,"scissorsstatement":1,"scrapesleep":1,"scratchsofa":1,"screechingfurniture":1,"screechingstocking":1,"scribbleson":1,"scrollservice":1,"scrubswim":1,"seashoresociety":1,"secondhandfall":1,"secretivesheep":1,"secretspiders":1,"secretturtle":1,"seedscissors":1,"seemlysuggestion":1,"selfishsea":1,"sendingspire":1,"sensorsmile":1,"separatesort":1,"seraphichorizon":1,"seraphicjubilee":1,"serendipityecho":1,"serenecascade":1,"serenepebble":1,"serenesurf":1,"serioussuit":1,"serpentshampoo":1,"settleshoes":1,"shadeship":1,"shaggytank":1,"shakyseat":1,"shakysurprise":1,"shakytaste":1,"shallowblade":1,"sharkskids":1,"sheargovernor":1,"shesubscriptions":1,"shinypond":1,"shirtsidewalk":1,"shiveringspot":1,"shiverscissors":1,"shockinggrass":1,"shockingship":1,"shredquiz":1,"shydinosaurs":1,"sierrakermit":1,"signaturepod":1,"siliconslow":1,"sillyscrew":1,"simplesidewalk":1,"simulateswing":1,"sincerebuffalo":1,"sincerepelican":1,"sinceresubstance":1,"sinkbooks":1,"sixscissors":1,"sizzlingsmoke":1,"slaysweater":1,"slimyscarf":1,"slinksuggestion":1,"smallershops":1,"smashshoe":1,"smilewound":1,"smilingcattle":1,"smilingswim":1,"smilingwaves":1,"smoggysongs":1,"smoggystation":1,"snacktoken":1,"snakemineral":1,"snakeslang":1,"sneakwind":1,"sneakystew":1,"snoresmile":1,"snowmentor":1,"soggysponge":1,"soggyzoo":1,"solarislabyrinth":1,"somberscarecrow":1,"sombersea":1,"sombersquirrel":1,"sombersticks":1,"sombersurprise":1,"soothingglade":1,"sophisticatedstove":1,"sordidsmile":1,"soresidewalk":1,"soresneeze":1,"sorethunder":1,"soretrain":1,"sortsail":1,"sortsummer":1,"sowlettuce":1,"spadelocket":1,"sparkgoal":1,"sparklingshelf":1,"specialscissors":1,"spellmist":1,"spellsalsa":1,"spiffymachine":1,"spirebaboon":1,"spookystitch":1,"spoonsilk":1,"spotlessstamp":1,"spottednoise":1,"springolive":1,"springsister":1,"springsnails":1,"sproutingbag":1,"sprydelta":1,"sprysummit":1,"spuriousair":1,"spuriousbase":1,"spurioussquirrel":1,"spuriousstranger":1,"spysubstance":1,"squalidscrew":1,"squeakzinc":1,"squealingturn":1,"stakingbasket":1,"stakingshock":1,"staleshow":1,"stalesummer":1,"starkscale":1,"startingcars":1,"statshunt":1,"statuesqueship":1,"stayaction":1,"steadycopper":1,"stealsteel":1,"steepscale":1,"steepsister":1,"stepcattle":1,"stepplane":1,"stepwisevideo":1,"stereoproxy":1,"stewspiders":1,"stiffstem":1,"stimulatingsneeze":1,"stingsquirrel":1,"stingyshoe":1,"stingyspoon":1,"stockingsleet":1,"stockingsneeze":1,"stomachscience":1,"stonechin":1,"stopstomach":1,"stormyachiever":1,"stormyfold":1,"strangeclocks":1,"strangersponge":1,"strangesink":1,"streetsort":1,"stretchsister":1,"stretchsneeze":1,"stretchsquirrel":1,"stripedbat":1,"strivesidewalk":1,"sturdysnail":1,"subletyoke":1,"sublimequartz":1,"subsequentswim":1,"substantialcarpenter":1,"substantialgrade":1,"succeedscene":1,"successfulscent":1,"suddensoda":1,"sugarfriction":1,"suggestionbridge":1,"summerobject":1,"sunshinegates":1,"superchichair":1,"superficialspring":1,"superviseshoes":1,"supportwaves":1,"suspectmark":1,"swellstocking":1,"swelteringsleep":1,"swingslip":1,"swordgoose":1,"syllablesight":1,"synonymousrule":1,"synonymoussticks":1,"synthesizescarecrow":1,"tackytrains":1,"tacojournal":1,"talltouch":1,"tangibleteam":1,"tangyamount":1,"tastelesstrees":1,"tastelesstrucks":1,"tastesnake":1,"tawdryson":1,"tearfulglass":1,"techconverter":1,"tediousbear":1,"tedioustooth":1,"teenytinycellar":1,"teenytinytongue":1,"telephoneapparatus":1,"tempertrick":1,"tempttalk":1,"temptteam":1,"terriblethumb":1,"terrifictooth":1,"testadmiral":1,"texturetrick":1,"therapeuticcars":1,"thickticket":1,"thicktrucks":1,"thingsafterthought":1,"thingstaste":1,"thinkitwice":1,"thirdrespect":1,"thirstytwig":1,"thomastorch":1,"thoughtlessknot":1,"thrivingmarketplace":1,"ticketaunt":1,"ticklesign":1,"tidymitten":1,"tightpowder":1,"tinyswans":1,"tinytendency":1,"tiredthroat":1,"toolcapital":1,"toomanyalts":1,"torpidtongue":1,"trackcaddie":1,"tradetooth":1,"trafficviews":1,"tranquilamulet":1,"tranquilarchipelago":1,"tranquilcan":1,"tranquilcanyon":1,"tranquilplume":1,"tranquilside":1,"tranquilveil":1,"tranquilveranda":1,"trappush":1,"treadbun":1,"tremendousearthquake":1,"tremendousplastic":1,"tremendoustime":1,"tritebadge":1,"tritethunder":1,"tritetongue":1,"troubledtail":1,"troubleshade":1,"truckstomatoes":1,"truculentrate":1,"tumbleicicle":1,"tuneupcoffee":1,"twistloss":1,"twistsweater":1,"typicalairplane":1,"ubiquitoussea":1,"ubiquitousyard":1,"ultravalid":1,"unablehope":1,"unaccountablecreator":1,"unaccountablepie":1,"unarmedindustry":1,"unbecominghall":1,"uncoveredexpert":1,"understoodocean":1,"unequalbrake":1,"unequaltrail":1,"unknowncontrol":1,"unknowncrate":1,"unknowntray":1,"untidyquestion":1,"untidyrice":1,"unusedstone":1,"unusualtitle":1,"unwieldyimpulse":1,"uppitytime":1,"uselesslumber":1,"validmemo":1,"vanfireworks":1,"vanishmemory":1,"velvetnova":1,"velvetquasar":1,"venomousvessel":1,"venusgloria":1,"verdantanswer":1,"verdantlabyrinth":1,"verdantloom":1,"verdantsculpture":1,"verseballs":1,"vibrantcelebration":1,"vibrantgale":1,"vibranthaven":1,"vibrantpact":1,"vibrantsundown":1,"vibranttalisman":1,"vibrantvale":1,"victoriousrequest":1,"virtualvincent":1,"vividcanopy":1,"vividfrost":1,"vividmeadow":1,"vividplume":1,"voicelessvein":1,"voidgoo":1,"volatileprofit":1,"waitingnumber":1,"wantingwindow":1,"warnwing":1,"washbanana":1,"wateryvan":1,"waterywave":1,"waterywrist":1,"wearbasin":1,"websitesdude":1,"wellgroomedapparel":1,"wellgroomedhydrant":1,"wellmadefrog":1,"westpalmweb":1,"whimsicalcanyon":1,"whimsicalgrove":1,"whineattempt":1,"whirlwealth":1,"whiskyqueue":1,"whisperingcascade":1,"whisperingcrib":1,"whisperingquasar":1,"whisperingsummit":1,"whispermeeting":1,"wildcommittee":1,"wirecomic":1,"wiredforcoffee":1,"wirypaste":1,"wistfulwaste":1,"wittypopcorn":1,"wittyshack":1,"workoperation":1,"worldlever":1,"worriednumber":1,"worriedwine":1,"wretchedfloor":1,"wrongpotato":1,"wrongwound":1,"wtaccesscontrol":1,"xovq5nemr":1,"yieldingwoman":1,"zbwp6ghm":1,"zephyrcatalyst":1,"zephyrlabyrinth":1,"zestyhorizon":1,"zestyrover":1,"zestywire":1,"zipperxray":1,"zonewedgeshaft":1},"net":{"2mdn":1,"2o7":1,"3gl":1,"a-mo":1,"acint":1,"adform":1,"adhigh":1,"admixer":1,"adobedc":1,"adspeed":1,"adverticum":1,"apicit":1,"appier":1,"akamaized":{"assets-momentum":1},"aticdn":1,"edgekey":{"au":1,"ca":1,"ch":1,"cn":1,"com-v1":1,"es":1,"ihg":1,"in":1,"io":1,"it":1,"jp":1,"net":1,"org":1,"com":{"scene7":1},"uk-v1":1,"uk":1},"azure":1,"azurefd":1,"bannerflow":1,"bf-tools":1,"bidswitch":1,"bitsngo":1,"blueconic":1,"boldapps":1,"buysellads":1,"cachefly":1,"cedexis":1,"certona":1,"confiant-integrations":1,"contentsquare":1,"criteo":1,"crwdcntrl":1,"cloudfront":{"d1af033869koo7":1,"d1cr9zxt7u0sgu":1,"d1s87id6169zda":1,"d1vg5xiq7qffdj":1,"d1y068gyog18cq":1,"d214hhm15p4t1d":1,"d21gpk1vhmjuf5":1,"d2zah9y47r7bi2":1,"d38b8me95wjkbc":1,"d38xvr37kwwhcm":1,"d3fv2pqyjay52z":1,"d3i4yxtzktqr9n":1,"d3odp2r1osuwn0":1,"d5yoctgpv4cpx":1,"d6tizftlrpuof":1,"dbukjj6eu5tsf":1,"dn0qt3r0xannq":1,"dsh7ky7308k4b":1,"d2g3ekl4mwm40k":1},"demdex":1,"dotmetrics":1,"doubleclick":1,"durationmedia":1,"e-planning":1,"edgecastcdn":1,"emsecure":1,"episerver":1,"esm1":1,"eulerian":1,"everestjs":1,"everesttech":1,"eyeota":1,"ezoic":1,"fastly":{"global":{"shared":{"f2":1},"sni":{"j":1}},"map":{"prisa-us-eu":1,"scribd":1},"ssl":{"global":{"qognvtzku-x":1}}},"facebook":1,"fastclick":1,"fonts":1,"azureedge":{"fp-cdn":1,"sdtagging":1},"fuseplatform":1,"fwmrm":1,"go-mpulse":1,"hadronid":1,"hs-analytics":1,"hsleadflows":1,"im-apps":1,"impervadns":1,"iocnt":1,"iprom":1,"jsdelivr":1,"kanade-ad":1,"krxd":1,"line-scdn":1,"listhub":1,"livecom":1,"livedoor":1,"liveperson":1,"lkqd":1,"llnwd":1,"lpsnmedia":1,"magnetmail":1,"marketo":1,"maxymiser":1,"media":1,"microad":1,"mobon":1,"monetate":1,"mxptint":1,"myfonts":1,"myvisualiq":1,"naver":1,"nr-data":1,"ojrq":1,"omtrdc":1,"onecount":1,"openx":1,"openxcdn":1,"opta":1,"owneriq":1,"pages02":1,"pages03":1,"pages04":1,"pages05":1,"pages06":1,"pages08":1,"pingdom":1,"pmdstatic":1,"popads":1,"popcash":1,"primecaster":1,"pro-market":1,"akamaihd":{"pxlclnmdecom-a":1},"rfihub":1,"sancdn":1,"sc-static":1,"semasio":1,"sensic":1,"sexad":1,"smaato":1,"spreadshirts":1,"storygize":1,"tfaforms":1,"trackcmp":1,"trackedlink":1,"tradetracker":1,"truste-svc":1,"uuidksinc":1,"viafoura":1,"visilabs":1,"visx":1,"w55c":1,"wdsvc":1,"witglobal":1,"yandex":1,"yastatic":1,"yieldlab":1,"zencdn":1,"zucks":1,"opencmp":1,"azurewebsites":{"app-fnsp-matomo-analytics-prod":1},"ad-delivery":1,"chartbeat":1,"msecnd":1,"cloudfunctions":{"us-central1-adaptive-growth":1},"eviltracker":1},"co":{"6sc":1,"ayads":1,"getlasso":1,"idio":1,"increasingly":1,"jads":1,"nanorep":1,"nc0":1,"pcdn":1,"prmutv":1,"resetdigital":1,"t":1,"tctm":1,"zip":1},"gt":{"ad":1},"ru":{"adfox":1,"adriver":1,"digitaltarget":1,"mail":1,"mindbox":1,"rambler":1,"rutarget":1,"sape":1,"smi2":1,"tns-counter":1,"top100":1,"ulogin":1,"yandex":1,"yadro":1},"jp":{"adingo":1,"admatrix":1,"auone":1,"co":{"dmm":1,"i-mobile":1,"rakuten":1,"yahoo":1},"fout":1,"genieesspv":1,"gmossp-sp":1,"gsspat":1,"gssprt":1,"ne":{"hatena":1},"i2i":1,"impact-ad":1,"microad":1,"nakanohito":1,"r10s":1,"reemo-ad":1,"rtoaster":1,"shinobi":1,"team-rec":1,"uncn":1,"yimg":1,"yjtag":1},"pl":{"adocean":1,"gemius":1,"nsaudience":1,"onet":1,"salesmanago":1,"wp":1},"pro":{"adpartner":1,"piwik":1,"usocial":1},"de":{"adscale":1,"auswaertiges-amt":1,"fiduciagad":1,"ioam":1,"itzbund":1,"vgwort":1,"werk21system":1},"re":{"adsco":1},"info":{"adxbid":1,"bitrix":1,"navistechnologies":1,"usergram":1,"webantenna":1},"tv":{"affec":1,"attn":1,"iris":1,"ispot":1,"samba":1,"teads":1,"twitch":1,"videohub":1},"dev":{"amazon":1},"us":{"amung":1,"samplicio":1,"slgnt":1,"trkn":1,"owlsr":1},"media":{"andbeyond":1,"nextday":1,"townsquare":1,"underdog":1},"link":{"app":1},"cloud":{"avct":1,"egain":1,"matomo":1},"delivery":{"ay":1,"monu":1},"ly":{"bit":1},"br":{"com":{"btg360":1,"clearsale":1,"jsuol":1,"shopconvert":1,"shoptarget":1,"soclminer":1},"org":{"ivcbrasil":1}},"ch":{"ch":1,"da-services":1,"google":1},"me":{"channel":1,"contentexchange":1,"grow":1,"line":1,"loopme":1,"t":1},"ms":{"clarity":1},"my":{"cnt":1},"se":{"codigo":1},"to":{"cpx":1,"tawk":1},"chat":{"crisp":1,"gorgias":1},"fr":{"d-bi":1,"open-system":1,"weborama":1},"uk":{"co":{"dailymail":1,"hsbc":1}},"gov":{"dhs":1},"ai":{"e-volution":1,"hybrid":1,"m2":1,"nrich":1,"wknd":1},"be":{"geoedge":1},"au":{"com":{"google":1,"news":1,"nine":1,"zipmoney":1,"telstra":1}},"stream":{"ibclick":1},"cz":{"imedia":1,"seznam":1,"trackad":1},"app":{"infusionsoft":1,"permutive":1,"shop":1},"tech":{"ingage":1,"primis":1},"eu":{"kameleoon":1,"medallia":1,"media01":1,"ocdn":1,"rqtrk":1,"slgnt":1},"fi":{"kesko":1,"simpli":1},"live":{"lura":1},"services":{"marketingautomation":1},"sg":{"mediacorp":1},"bi":{"newsroom":1},"fm":{"pdst":1},"ad":{"pixel":1},"xyz":{"playground":1},"it":{"plug":1,"repstatic":1},"cc":{"popin":1},"network":{"pub":1},"nl":{"rijksoverheid":1},"fyi":{"sda":1},"es":{"socy":1},"im":{"spot":1},"market":{"spotim":1},"am":{"tru":1},"no":{"uio":1,"medietall":1},"at":{"waust":1},"pe":{"shop":1},"ca":{"bc":{"gov":1}},"gg":{"clean":1},"example":{"ad-company":1},"site":{"ad-company":1,"third-party":{"bad":1,"broken":1}},"pw":{"5mcwl":1,"fvl1f":1,"h78xb":1,"i9w8p":1,"k54nw":1,"tdzvm":1,"tzwaw":1,"vq1qi":1,"zlp6s":1},"pub":{"admiral":1}}; output.bundledConfig = data; - return output + return output; } /** @@ -599,20 +612,24 @@ * @param {string[]} platformSpecificFeatures * @returns {string[]} */ - function computeEnabledFeatures (data, topLevelHostname, platformVersion, platformSpecificFeatures = []) { + function computeEnabledFeatures(data, topLevelHostname, platformVersion, platformSpecificFeatures = []) { const remoteFeatureNames = Object.keys(data.features); - const platformSpecificFeaturesNotInRemoteConfig = platformSpecificFeatures.filter((featureName) => !remoteFeatureNames.includes(featureName)); - const enabledFeatures = remoteFeatureNames.filter((featureName) => { - const feature = data.features[featureName]; - // Check that the platform supports minSupportedVersion checks and that the feature has a minSupportedVersion - if (feature.minSupportedVersion && platformVersion) { - if (!isSupportedVersion(feature.minSupportedVersion, platformVersion)) { - return false + const platformSpecificFeaturesNotInRemoteConfig = platformSpecificFeatures.filter( + (featureName) => !remoteFeatureNames.includes(featureName), + ); + const enabledFeatures = remoteFeatureNames + .filter((featureName) => { + const feature = data.features[featureName]; + // Check that the platform supports minSupportedVersion checks and that the feature has a minSupportedVersion + if (feature.minSupportedVersion && platformVersion) { + if (!isSupportedVersion(feature.minSupportedVersion, platformVersion)) { + return false; + } } - } - return feature.state === 'enabled' && !isUnprotectedDomain(topLevelHostname, feature.exceptions) - }).concat(platformSpecificFeaturesNotInRemoteConfig); // only disable platform specific features if it's explicitly disabled in remote config - return enabledFeatures + return feature.state === 'enabled' && !isUnprotectedDomain(topLevelHostname, feature.exceptions); + }) + .concat(platformSpecificFeaturesNotInRemoteConfig); // only disable platform specific features if it's explicitly disabled in remote config + return enabledFeatures; } /** @@ -621,44 +638,47 @@ * @param {string[]} enabledFeatures * @returns {Record} */ - function parseFeatureSettings (data, enabledFeatures) { + function parseFeatureSettings(data, enabledFeatures) { /** @type {Record} */ const featureSettings = {}; const remoteFeatureNames = Object.keys(data.features); remoteFeatureNames.forEach((featureName) => { if (!enabledFeatures.includes(featureName)) { - return + return; } featureSettings[featureName] = data.features[featureName].settings; }); - return featureSettings + return featureSettings; } - function isGloballyDisabled (args) { - return args.site.allowlisted || args.site.isBroken + function isGloballyDisabled(args) { + return args.site.allowlisted || args.site.isBroken; } const windowsSpecificFeatures = ['windowsPermissionUsage']; - function isWindowsSpecificFeature (featureName) { - return windowsSpecificFeatures.includes(featureName) + function isWindowsSpecificFeature(featureName) { + return windowsSpecificFeatures.includes(featureName); } - function createCustomEvent (eventName, eventDetail) { + function createCustomEvent(eventName, eventDetail) { // @ts-expect-error - possibly null - return new OriginalCustomEvent(eventName, eventDetail) + return new OriginalCustomEvent(eventName, eventDetail); } /** @deprecated */ - function legacySendMessage (messageType, options) { + function legacySendMessage(messageType, options) { // FF & Chrome - return originalWindowDispatchEvent && originalWindowDispatchEvent(createCustomEvent('sendMessageProxy' + messageSecret, { detail: { messageType, options } })) + return ( + originalWindowDispatchEvent && + originalWindowDispatchEvent(createCustomEvent('sendMessageProxy' + messageSecret, { detail: { messageType, options } })) + ); // TBD other platforms } - const baseFeatures = /** @type {const} */([ + const baseFeatures = /** @type {const} */ ([ 'fingerprintingAudio', 'fingerprintingBattery', 'fingerprintingCanvas', @@ -670,10 +690,11 @@ 'fingerprintingTemporaryStorage', 'navigatorInterface', 'elementHiding', - 'exceptionHandler' + 'exceptionHandler', + 'apiManipulation', ]); - const otherFeatures = /** @type {const} */([ + const otherFeatures = /** @type {const} */ ([ 'clickToLoad', 'cookie', 'duckPlayer', @@ -683,66 +704,28 @@ 'brokerProtection', 'performanceMetrics', 'breakageReporting', - 'autofillPasswordImport' + 'autofillPasswordImport', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ const platformSupport = { - apple: [ - 'webCompat', - ...baseFeatures - ], - 'apple-isolated': [ - 'duckPlayer', - 'brokerProtection', - 'performanceMetrics', - 'clickToLoad' - ], - android: [ - ...baseFeatures, - 'webCompat', - 'clickToLoad', - 'breakageReporting', - 'duckPlayer' - ], - 'android-autofill-password-import': [ - 'autofillPasswordImport' - ], - windows: [ - 'cookie', - ...baseFeatures, - 'windowsPermissionUsage', - 'duckPlayer', - 'brokerProtection', - 'breakageReporting' - ], - firefox: [ - 'cookie', - ...baseFeatures, - 'clickToLoad' - ], - chrome: [ - 'cookie', - ...baseFeatures, - 'clickToLoad' - ], - 'chrome-mv3': [ - 'cookie', - ...baseFeatures, - 'clickToLoad' - ], - integration: [ - ...baseFeatures, - ...otherFeatures - ] + apple: ['webCompat', ...baseFeatures], + 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad'], + android: [...baseFeatures, 'webCompat', 'clickToLoad', 'breakageReporting', 'duckPlayer'], + 'android-autofill-password-import': ['autofillPasswordImport'], + windows: ['cookie', ...baseFeatures, 'windowsPermissionUsage', 'duckPlayer', 'brokerProtection', 'breakageReporting'], + firefox: ['cookie', ...baseFeatures, 'clickToLoad'], + chrome: ['cookie', ...baseFeatures, 'clickToLoad'], + 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad'], + integration: [...baseFeatures, ...otherFeatures], }; /** * Performance monitor, holds reference to PerformanceMark instances. */ class PerformanceMonitor { - constructor () { + constructor() { this.marks = []; } @@ -751,16 +734,16 @@ * @param {string} name * @returns {PerformanceMark} */ - mark (name) { + mark(name) { const mark = new PerformanceMark(name); this.marks.push(mark); - return mark + return mark; } /** * Measure all performance markers */ - measureAll () { + measureAll() { this.marks.forEach((mark) => { mark.measure(); }); @@ -770,527 +753,565 @@ /** * Tiny wrapper around performance.mark and performance.measure */ + // eslint-disable-next-line no-redeclare class PerformanceMark { /** * @param {string} name */ - constructor (name) { + constructor(name) { this.name = name; performance.mark(this.name + 'Start'); } - end () { + end() { performance.mark(this.name + 'End'); } - measure () { + measure() { performance.measure(this.name, this.name + 'Start', this.name + 'End'); } } // @ts-nocheck - const sjcl = (() => { - /*jslint indent: 2, bitwise: false, nomen: false, plusplus: false, white: false, regexp: false */ - /*global document, window, escape, unescape, module, require, Uint32Array */ - - /** - * The Stanford Javascript Crypto Library, top-level namespace. - * @namespace - */ - var sjcl = { - /** - * Symmetric ciphers. - * @namespace - */ - cipher: {}, - - /** - * Hash functions. Right now only SHA256 is implemented. - * @namespace - */ - hash: {}, - - /** - * Key exchange functions. Right now only SRP is implemented. - * @namespace - */ - keyexchange: {}, - - /** - * Cipher modes of operation. - * @namespace - */ - mode: {}, - - /** - * Miscellaneous. HMAC and PBKDF2. - * @namespace - */ - misc: {}, - - /** - * Bit array encoders and decoders. - * @namespace - * - * @description - * The members of this namespace are functions which translate between - * SJCL's bitArrays and other objects (usually strings). Because it - * isn't always clear which direction is encoding and which is decoding, - * the method names are "fromBits" and "toBits". - */ - codec: {}, - - /** - * Exceptions. - * @namespace - */ - exception: { - /** - * Ciphertext is corrupt. - * @constructor + const sjcl = (() => { + /*jslint indent: 2, bitwise: false, nomen: false, plusplus: false, white: false, regexp: false */ + /*global document, window, escape, unescape, module, require, Uint32Array */ + + /** + * The Stanford Javascript Crypto Library, top-level namespace. + * @namespace */ - corrupt: function(message) { - this.toString = function() { return "CORRUPT: "+this.message; }; - this.message = message; - }, - + var sjcl = { + /** + * Symmetric ciphers. + * @namespace + */ + cipher: {}, + + /** + * Hash functions. Right now only SHA256 is implemented. + * @namespace + */ + hash: {}, + + /** + * Key exchange functions. Right now only SRP is implemented. + * @namespace + */ + keyexchange: {}, + + /** + * Cipher modes of operation. + * @namespace + */ + mode: {}, + + /** + * Miscellaneous. HMAC and PBKDF2. + * @namespace + */ + misc: {}, + + /** + * Bit array encoders and decoders. + * @namespace + * + * @description + * The members of this namespace are functions which translate between + * SJCL's bitArrays and other objects (usually strings). Because it + * isn't always clear which direction is encoding and which is decoding, + * the method names are "fromBits" and "toBits". + */ + codec: {}, + + /** + * Exceptions. + * @namespace + */ + exception: { + /** + * Ciphertext is corrupt. + * @constructor + */ + corrupt: function (message) { + this.toString = function () { + return 'CORRUPT: ' + this.message; + }; + this.message = message; + }, + + /** + * Invalid parameter. + * @constructor + */ + invalid: function (message) { + this.toString = function () { + return 'INVALID: ' + this.message; + }; + this.message = message; + }, + + /** + * Bug or missing feature in SJCL. + * @constructor + */ + bug: function (message) { + this.toString = function () { + return 'BUG: ' + this.message; + }; + this.message = message; + }, + + /** + * Something isn't ready. + * @constructor + */ + notReady: function (message) { + this.toString = function () { + return 'NOT READY: ' + this.message; + }; + this.message = message; + }, + }, + }; + /** @fileOverview Arrays of bits, encoded as arrays of Numbers. + * + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh + */ + /** - * Invalid parameter. - * @constructor + * Arrays of bits, encoded as arrays of Numbers. + * @namespace + * @description + *

+ * These objects are the currency accepted by SJCL's crypto functions. + *

+ * + *

+ * Most of our crypto primitives operate on arrays of 4-byte words internally, + * but many of them can take arguments that are not a multiple of 4 bytes. + * This library encodes arrays of bits (whose size need not be a multiple of 8 + * bits) as arrays of 32-bit words. The bits are packed, big-endian, into an + * array of words, 32 bits at a time. Since the words are double-precision + * floating point numbers, they fit some extra data. We use this (in a private, + * possibly-changing manner) to encode the number of bits actually present + * in the last word of the array. + *

+ * + *

+ * Because bitwise ops clear this out-of-band data, these arrays can be passed + * to ciphers like AES which want arrays of words. + *

*/ - invalid: function(message) { - this.toString = function() { return "INVALID: "+this.message; }; - this.message = message; - }, - + sjcl.bitArray = { + /** + * Array slices in units of bits. + * @param {bitArray} a The array to slice. + * @param {Number} bstart The offset to the start of the slice, in bits. + * @param {Number} bend The offset to the end of the slice, in bits. If this is undefined, + * slice until the end of the array. + * @return {bitArray} The requested slice. + */ + bitSlice: function (a, bstart, bend) { + a = sjcl.bitArray._shiftRight(a.slice(bstart / 32), 32 - (bstart & 31)).slice(1); + return bend === undefined ? a : sjcl.bitArray.clamp(a, bend - bstart); + }, + + /** + * Extract a number packed into a bit array. + * @param {bitArray} a The array to slice. + * @param {Number} bstart The offset to the start of the slice, in bits. + * @param {Number} blength The length of the number to extract. + * @return {Number} The requested slice. + */ + extract: function (a, bstart, blength) { + // FIXME: this Math.floor is not necessary at all, but for some reason + // seems to suppress a bug in the Chromium JIT. + var x, + sh = Math.floor((-bstart - blength) & 31); + if (((bstart + blength - 1) ^ bstart) & -32) { + // it crosses a boundary + x = (a[(bstart / 32) | 0] << (32 - sh)) ^ (a[(bstart / 32 + 1) | 0] >>> sh); + } else { + // within a single word + x = a[(bstart / 32) | 0] >>> sh; + } + return x & ((1 << blength) - 1); + }, + + /** + * Concatenate two bit arrays. + * @param {bitArray} a1 The first array. + * @param {bitArray} a2 The second array. + * @return {bitArray} The concatenation of a1 and a2. + */ + concat: function (a1, a2) { + if (a1.length === 0 || a2.length === 0) { + return a1.concat(a2); + } + + var last = a1[a1.length - 1], + shift = sjcl.bitArray.getPartial(last); + if (shift === 32) { + return a1.concat(a2); + } else { + return sjcl.bitArray._shiftRight(a2, shift, last | 0, a1.slice(0, a1.length - 1)); + } + }, + + /** + * Find the length of an array of bits. + * @param {bitArray} a The array. + * @return {Number} The length of a, in bits. + */ + bitLength: function (a) { + var l = a.length, + x; + if (l === 0) { + return 0; + } + x = a[l - 1]; + return (l - 1) * 32 + sjcl.bitArray.getPartial(x); + }, + + /** + * Truncate an array. + * @param {bitArray} a The array. + * @param {Number} len The length to truncate to, in bits. + * @return {bitArray} A new array, truncated to len bits. + */ + clamp: function (a, len) { + if (a.length * 32 < len) { + return a; + } + a = a.slice(0, Math.ceil(len / 32)); + var l = a.length; + len = len & 31; + if (l > 0 && len) { + a[l - 1] = sjcl.bitArray.partial(len, a[l - 1] & (0x80000000 >> (len - 1)), 1); + } + return a; + }, + + /** + * Make a partial word for a bit array. + * @param {Number} len The number of bits in the word. + * @param {Number} x The bits. + * @param {Number} [_end=0] Pass 1 if x has already been shifted to the high side. + * @return {Number} The partial word. + */ + partial: function (len, x, _end) { + if (len === 32) { + return x; + } + return (_end ? x | 0 : x << (32 - len)) + len * 0x10000000000; + }, + + /** + * Get the number of bits used by a partial word. + * @param {Number} x The partial word. + * @return {Number} The number of bits used by the partial word. + */ + getPartial: function (x) { + return Math.round(x / 0x10000000000) || 32; + }, + + /** + * Compare two arrays for equality in a predictable amount of time. + * @param {bitArray} a The first array. + * @param {bitArray} b The second array. + * @return {boolean} true if a == b; false otherwise. + */ + equal: function (a, b) { + if (sjcl.bitArray.bitLength(a) !== sjcl.bitArray.bitLength(b)) { + return false; + } + var x = 0, + i; + for (i = 0; i < a.length; i++) { + x |= a[i] ^ b[i]; + } + return x === 0; + }, + + /** Shift an array right. + * @param {bitArray} a The array to shift. + * @param {Number} shift The number of bits to shift. + * @param {Number} [carry=0] A byte to carry in + * @param {bitArray} [out=[]] An array to prepend to the output. + * @private + */ + _shiftRight: function (a, shift, carry, out) { + var i, + last2 = 0, + shift2; + if (out === undefined) { + out = []; + } + + for (; shift >= 32; shift -= 32) { + out.push(carry); + carry = 0; + } + if (shift === 0) { + return out.concat(a); + } + + for (i = 0; i < a.length; i++) { + out.push(carry | (a[i] >>> shift)); + carry = a[i] << (32 - shift); + } + last2 = a.length ? a[a.length - 1] : 0; + shift2 = sjcl.bitArray.getPartial(last2); + out.push(sjcl.bitArray.partial((shift + shift2) & 31, shift + shift2 > 32 ? carry : out.pop(), 1)); + return out; + }, + + /** xor a block of 4 words together. + * @private + */ + _xor4: function (x, y) { + return [x[0] ^ y[0], x[1] ^ y[1], x[2] ^ y[2], x[3] ^ y[3]]; + }, + + /** byteswap a word array inplace. + * (does not handle partial words) + * @param {sjcl.bitArray} a word array + * @return {sjcl.bitArray} byteswapped array + */ + byteswapM: function (a) { + var i, + v, + m = 0xff00; + for (i = 0; i < a.length; ++i) { + v = a[i]; + a[i] = (v >>> 24) | ((v >>> 8) & m) | ((v & m) << 8) | (v << 24); + } + return a; + }, + }; + /** @fileOverview Bit array codec implementations. + * + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh + */ + /** - * Bug or missing feature in SJCL. - * @constructor + * UTF-8 strings + * @namespace + */ + sjcl.codec.utf8String = { + /** Convert from a bitArray to a UTF-8 string. */ + fromBits: function (arr) { + var out = '', + bl = sjcl.bitArray.bitLength(arr), + i, + tmp; + for (i = 0; i < bl / 8; i++) { + if ((i & 3) === 0) { + tmp = arr[i / 4]; + } + out += String.fromCharCode(((tmp >>> 8) >>> 8) >>> 8); + tmp <<= 8; + } + return decodeURIComponent(escape(out)); + }, + + /** Convert from a UTF-8 string to a bitArray. */ + toBits: function (str) { + str = unescape(encodeURIComponent(str)); + var out = [], + i, + tmp = 0; + for (i = 0; i < str.length; i++) { + tmp = (tmp << 8) | str.charCodeAt(i); + if ((i & 3) === 3) { + out.push(tmp); + tmp = 0; + } + } + if (i & 3) { + out.push(sjcl.bitArray.partial(8 * (i & 3), tmp)); + } + return out; + }, + }; + /** @fileOverview Bit array codec implementations. + * + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh + */ + + /** + * Hexadecimal + * @namespace + */ + sjcl.codec.hex = { + /** Convert from a bitArray to a hex string. */ + fromBits: function (arr) { + var out = '', + i; + for (i = 0; i < arr.length; i++) { + out += ((arr[i] | 0) + 0xf00000000000).toString(16).substr(4); + } + return out.substr(0, sjcl.bitArray.bitLength(arr) / 4); //.replace(/(.{8})/g, "$1 "); + }, + /** Convert from a hex string to a bitArray. */ + toBits: function (str) { + var i, + out = [], + len; + str = str.replace(/\s|0x/g, ''); + len = str.length; + str = str + '00000000'; + for (i = 0; i < str.length; i += 8) { + out.push(parseInt(str.substr(i, 8), 16) ^ 0); + } + return sjcl.bitArray.clamp(out, len * 4); + }, + }; + + /** @fileOverview Javascript SHA-256 implementation. + * + * An older version of this implementation is available in the public + * domain, but this one is (c) Emily Stark, Mike Hamburg, Dan Boneh, + * Stanford University 2008-2010 and BSD-licensed for liability + * reasons. + * + * Special thanks to Aldo Cortesi for pointing out several bugs in + * this code. + * + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh */ - bug: function(message) { - this.toString = function() { return "BUG: "+this.message; }; - this.message = message; - }, /** - * Something isn't ready. + * Context for a SHA-256 operation in progress. * @constructor */ - notReady: function(message) { - this.toString = function() { return "NOT READY: "+this.message; }; - this.message = message; - } - } - }; - /** @fileOverview Arrays of bits, encoded as arrays of Numbers. - * - * @author Emily Stark - * @author Mike Hamburg - * @author Dan Boneh - */ + sjcl.hash.sha256 = function (hash) { + if (!this._key[0]) { + this._precompute(); + } + if (hash) { + this._h = hash._h.slice(0); + this._buffer = hash._buffer.slice(0); + this._length = hash._length; + } else { + this.reset(); + } + }; - /** - * Arrays of bits, encoded as arrays of Numbers. - * @namespace - * @description - *

- * These objects are the currency accepted by SJCL's crypto functions. - *

- * - *

- * Most of our crypto primitives operate on arrays of 4-byte words internally, - * but many of them can take arguments that are not a multiple of 4 bytes. - * This library encodes arrays of bits (whose size need not be a multiple of 8 - * bits) as arrays of 32-bit words. The bits are packed, big-endian, into an - * array of words, 32 bits at a time. Since the words are double-precision - * floating point numbers, they fit some extra data. We use this (in a private, - * possibly-changing manner) to encode the number of bits actually present - * in the last word of the array. - *

- * - *

- * Because bitwise ops clear this out-of-band data, these arrays can be passed - * to ciphers like AES which want arrays of words. - *

- */ - sjcl.bitArray = { - /** - * Array slices in units of bits. - * @param {bitArray} a The array to slice. - * @param {Number} bstart The offset to the start of the slice, in bits. - * @param {Number} bend The offset to the end of the slice, in bits. If this is undefined, - * slice until the end of the array. - * @return {bitArray} The requested slice. - */ - bitSlice: function (a, bstart, bend) { - a = sjcl.bitArray._shiftRight(a.slice(bstart/32), 32 - (bstart & 31)).slice(1); - return (bend === undefined) ? a : sjcl.bitArray.clamp(a, bend-bstart); - }, - - /** - * Extract a number packed into a bit array. - * @param {bitArray} a The array to slice. - * @param {Number} bstart The offset to the start of the slice, in bits. - * @param {Number} blength The length of the number to extract. - * @return {Number} The requested slice. - */ - extract: function(a, bstart, blength) { - // FIXME: this Math.floor is not necessary at all, but for some reason - // seems to suppress a bug in the Chromium JIT. - var x, sh = Math.floor((-bstart-blength) & 31); - if ((bstart + blength - 1 ^ bstart) & -32) { - // it crosses a boundary - x = (a[bstart/32|0] << (32 - sh)) ^ (a[bstart/32+1|0] >>> sh); - } else { - // within a single word - x = a[bstart/32|0] >>> sh; - } - return x & ((1< 0 && len) { - a[l-1] = sjcl.bitArray.partial(len, a[l-1] & 0x80000000 >> (len-1), 1); - } - return a; - }, - - /** - * Make a partial word for a bit array. - * @param {Number} len The number of bits in the word. - * @param {Number} x The bits. - * @param {Number} [_end=0] Pass 1 if x has already been shifted to the high side. - * @return {Number} The partial word. - */ - partial: function (len, x, _end) { - if (len === 32) { return x; } - return (_end ? x|0 : x << (32-len)) + len * 0x10000000000; - }, - - /** - * Get the number of bits used by a partial word. - * @param {Number} x The partial word. - * @return {Number} The number of bits used by the partial word. - */ - getPartial: function (x) { - return Math.round(x/0x10000000000) || 32; - }, - - /** - * Compare two arrays for equality in a predictable amount of time. - * @param {bitArray} a The first array. - * @param {bitArray} b The second array. - * @return {boolean} true if a == b; false otherwise. - */ - equal: function (a, b) { - if (sjcl.bitArray.bitLength(a) !== sjcl.bitArray.bitLength(b)) { - return false; - } - var x = 0, i; - for (i=0; i= 32; shift -= 32) { - out.push(carry); - carry = 0; - } - if (shift === 0) { - return out.concat(a); - } - - for (i=0; i>>shift); - carry = a[i] << (32-shift); - } - last2 = a.length ? a[a.length-1] : 0; - shift2 = sjcl.bitArray.getPartial(last2); - out.push(sjcl.bitArray.partial(shift+shift2 & 31, (shift + shift2 > 32) ? carry : out.pop(),1)); - return out; - }, - - /** xor a block of 4 words together. - * @private - */ - _xor4: function(x,y) { - return [x[0]^y[0],x[1]^y[1],x[2]^y[2],x[3]^y[3]]; - }, - - /** byteswap a word array inplace. - * (does not handle partial words) - * @param {sjcl.bitArray} a word array - * @return {sjcl.bitArray} byteswapped array - */ - byteswapM: function(a) { - var i, v, m = 0xff00; - for (i = 0; i < a.length; ++i) { - v = a[i]; - a[i] = (v >>> 24) | ((v >>> 8) & m) | ((v & m) << 8) | (v << 24); - } - return a; - } - }; - /** @fileOverview Bit array codec implementations. - * - * @author Emily Stark - * @author Mike Hamburg - * @author Dan Boneh - */ + /** + * Hash a string or an array of words. + * @static + * @param {bitArray|String} data the data to hash. + * @return {bitArray} The hash value, an array of 16 big-endian words. + */ + sjcl.hash.sha256.hash = function (data) { + return new sjcl.hash.sha256().update(data).finalize(); + }; - /** - * UTF-8 strings - * @namespace - */ - sjcl.codec.utf8String = { - /** Convert from a bitArray to a UTF-8 string. */ - fromBits: function (arr) { - var out = "", bl = sjcl.bitArray.bitLength(arr), i, tmp; - for (i=0; i>> 8 >>> 8 >>> 8); - tmp <<= 8; - } - return decodeURIComponent(escape(out)); - }, - - /** Convert from a UTF-8 string to a bitArray. */ - toBits: function (str) { - str = unescape(encodeURIComponent(str)); - var out = [], i, tmp=0; - for (i=0; i 9007199254740991) { + throw new sjcl.exception.invalid('Cannot hash more than 2^53 - 1 bits'); + } + + if (typeof Uint32Array !== 'undefined') { + var c = new Uint32Array(b); + var j = 0; + for (i = 512 + ol - ((512 + ol) & 511); i <= nl; i += 512) { + this._block(c.subarray(16 * j, 16 * (j + 1))); + j += 1; + } + b.splice(0, 16 * j); + } else { + for (i = 512 + ol - ((512 + ol) & 511); i <= nl; i += 512) { + this._block(b.splice(0, 16)); + } + } + return this; + }, - /** - * Hash a string or an array of words. - * @static - * @param {bitArray|String} data the data to hash. - * @return {bitArray} The hash value, an array of 16 big-endian words. - */ - sjcl.hash.sha256.hash = function (data) { - return (new sjcl.hash.sha256()).update(data).finalize(); - }; + /** + * Complete hashing and output the hash value. + * @return {bitArray} The hash value, an array of 8 big-endian words. + */ + finalize: function () { + var i, + b = this._buffer, + h = this._h; - sjcl.hash.sha256.prototype = { - /** - * The hash's block size, in bits. - * @constant - */ - blockSize: 512, - - /** - * Reset the hash state. - * @return this - */ - reset:function () { - this._h = this._init.slice(0); - this._buffer = []; - this._length = 0; - return this; - }, - - /** - * Input several words to the hash. - * @param {bitArray|String} data the data to hash. - * @return this - */ - update: function (data) { - if (typeof data === "string") { - data = sjcl.codec.utf8String.toBits(data); - } - var i, b = this._buffer = sjcl.bitArray.concat(this._buffer, data), - ol = this._length, - nl = this._length = ol + sjcl.bitArray.bitLength(data); - if (nl > 9007199254740991){ - throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits"); - } - - if (typeof Uint32Array !== 'undefined') { - var c = new Uint32Array(b); - var j = 0; - for (i = 512+ol - ((512+ol) & 511); i <= nl; i+= 512) { - this._block(c.subarray(16 * j, 16 * (j+1))); - j += 1; - } - b.splice(0, 16 * j); - } else { - for (i = 512+ol - ((512+ol) & 511); i <= nl; i+= 512) { - this._block(b.splice(0,16)); - } - } - return this; - }, - - /** - * Complete hashing and output the hash value. - * @return {bitArray} The hash value, an array of 8 big-endian words. - */ - finalize:function () { - var i, b = this._buffer, h = this._h; - - // Round out and push the buffer - b = sjcl.bitArray.concat(b, [sjcl.bitArray.partial(1,1)]); - - // Round out the buffer to a multiple of 16 words, less the 2 length words. - for (i = b.length + 2; i & 15; i++) { - b.push(0); - } - - // append the length - b.push(Math.floor(this._length / 0x100000000)); - b.push(this._length | 0); - - while (b.length) { - this._block(b.splice(0,16)); - } - - this.reset(); - return h; - }, - - /** - * The SHA-256 initialization vector, to be precomputed. - * @private - */ - _init:[], - /* + // Round out and push the buffer + b = sjcl.bitArray.concat(b, [sjcl.bitArray.partial(1, 1)]); + + // Round out the buffer to a multiple of 16 words, less the 2 length words. + for (i = b.length + 2; i & 15; i++) { + b.push(0); + } + + // append the length + b.push(Math.floor(this._length / 0x100000000)); + b.push(this._length | 0); + + while (b.length) { + this._block(b.splice(0, 16)); + } + + this.reset(); + return h; + }, + + /** + * The SHA-256 initialization vector, to be precomputed. + * @private + */ + _init: [], + /* _init:[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19], */ - - /** - * The SHA-256 hash key, to be precomputed. - * @private - */ - _key:[], - /* + + /** + * The SHA-256 hash key, to be precomputed. + * @private + */ + _key: [], + /* _key: [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, @@ -1302,213 +1323,248 @@ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2], */ + /** + * Function to precompute _init and _key. + * @private + */ + _precompute: function () { + var i = 0, + prime = 2, + factor, + isPrime; + + function frac(x) { + return ((x - Math.floor(x)) * 0x100000000) | 0; + } + + for (; i < 64; prime++) { + isPrime = true; + for (factor = 2; factor * factor <= prime; factor++) { + if (prime % factor === 0) { + isPrime = false; + break; + } + } + if (isPrime) { + if (i < 8) { + this._init[i] = frac(Math.pow(prime, 1 / 2)); + } + this._key[i] = frac(Math.pow(prime, 1 / 3)); + i++; + } + } + }, - /** - * Function to precompute _init and _key. - * @private - */ - _precompute: function () { - var i = 0, prime = 2, factor, isPrime; + /** + * Perform one cycle of SHA-256. + * @param {Uint32Array|bitArray} w one block of words. + * @private + */ + _block: function (w) { + var i, + tmp, + a, + b, + h = this._h, + k = this._key, + h0 = h[0], + h1 = h[1], + h2 = h[2], + h3 = h[3], + h4 = h[4], + h5 = h[5], + h6 = h[6], + h7 = h[7]; + + /* Rationale for placement of |0 : + * If a value can overflow is original 32 bits by a factor of more than a few + * million (2^23 ish), there is a possibility that it might overflow the + * 53-bit mantissa and lose precision. + * + * To avoid this, we clamp back to 32 bits by |'ing with 0 on any value that + * propagates around the loop, and on the hash state h[]. I don't believe + * that the clamps on h4 and on h0 are strictly necessary, but it's close + * (for h4 anyway), and better safe than sorry. + * + * The clamps on h[] are necessary for the output to be correct even in the + * common case and for short inputs. + */ + for (i = 0; i < 64; i++) { + // load up the input word for this round + if (i < 16) { + tmp = w[i]; + } else { + a = w[(i + 1) & 15]; + b = w[(i + 14) & 15]; + tmp = w[i & 15] = + (((a >>> 7) ^ (a >>> 18) ^ (a >>> 3) ^ (a << 25) ^ (a << 14)) + + ((b >>> 17) ^ (b >>> 19) ^ (b >>> 10) ^ (b << 15) ^ (b << 13)) + + w[i & 15] + + w[(i + 9) & 15]) | + 0; + } - function frac(x) { return (x-Math.floor(x)) * 0x100000000 | 0; } + tmp = + tmp + + h7 + + ((h4 >>> 6) ^ (h4 >>> 11) ^ (h4 >>> 25) ^ (h4 << 26) ^ (h4 << 21) ^ (h4 << 7)) + + (h6 ^ (h4 & (h5 ^ h6))) + + k[i]; // | 0; + + // shift register + h7 = h6; + h6 = h5; + h5 = h4; + h4 = (h3 + tmp) | 0; + h3 = h2; + h2 = h1; + h1 = h0; + + h0 = + (tmp + + ((h1 & h2) ^ (h3 & (h1 ^ h2))) + + ((h1 >>> 2) ^ (h1 >>> 13) ^ (h1 >>> 22) ^ (h1 << 30) ^ (h1 << 19) ^ (h1 << 10))) | + 0; + } + + h[0] = (h[0] + h0) | 0; + h[1] = (h[1] + h1) | 0; + h[2] = (h[2] + h2) | 0; + h[3] = (h[3] + h3) | 0; + h[4] = (h[4] + h4) | 0; + h[5] = (h[5] + h5) | 0; + h[6] = (h[6] + h6) | 0; + h[7] = (h[7] + h7) | 0; + }, + }; - for (; i<64; prime++) { - isPrime = true; - for (factor=2; factor*factor <= prime; factor++) { - if (prime % factor === 0) { - isPrime = false; - break; - } - } - if (isPrime) { - if (i<8) { - this._init[i] = frac(Math.pow(prime, 1/2)); - } - this._key[i] = frac(Math.pow(prime, 1/3)); - i++; - } - } - }, - - /** - * Perform one cycle of SHA-256. - * @param {Uint32Array|bitArray} w one block of words. - * @private - */ - _block:function (w) { - var i, tmp, a, b, - h = this._h, - k = this._key, - h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], - h4 = h[4], h5 = h[5], h6 = h[6], h7 = h[7]; - - /* Rationale for placement of |0 : - * If a value can overflow is original 32 bits by a factor of more than a few - * million (2^23 ish), there is a possibility that it might overflow the - * 53-bit mantissa and lose precision. - * - * To avoid this, we clamp back to 32 bits by |'ing with 0 on any value that - * propagates around the loop, and on the hash state h[]. I don't believe - * that the clamps on h4 and on h0 are strictly necessary, but it's close - * (for h4 anyway), and better safe than sorry. + /** @fileOverview HMAC implementation. * - * The clamps on h[] are necessary for the output to be correct even in the - * common case and for short inputs. - */ - for (i=0; i<64; i++) { - // load up the input word for this round - if (i<16) { - tmp = w[i]; - } else { - a = w[(i+1 ) & 15]; - b = w[(i+14) & 15]; - tmp = w[i&15] = ((a>>>7 ^ a>>>18 ^ a>>>3 ^ a<<25 ^ a<<14) + - (b>>>17 ^ b>>>19 ^ b>>>10 ^ b<<15 ^ b<<13) + - w[i&15] + w[(i+9) & 15]) | 0; - } - - tmp = (tmp + h7 + (h4>>>6 ^ h4>>>11 ^ h4>>>25 ^ h4<<26 ^ h4<<21 ^ h4<<7) + (h6 ^ h4&(h5^h6)) + k[i]); // | 0; - - // shift register - h7 = h6; h6 = h5; h5 = h4; - h4 = h3 + tmp | 0; - h3 = h2; h2 = h1; h1 = h0; - - h0 = (tmp + ((h1&h2) ^ (h3&(h1^h2))) + (h1>>>2 ^ h1>>>13 ^ h1>>>22 ^ h1<<30 ^ h1<<19 ^ h1<<10)) | 0; - } - - h[0] = h[0]+h0 | 0; - h[1] = h[1]+h1 | 0; - h[2] = h[2]+h2 | 0; - h[3] = h[3]+h3 | 0; - h[4] = h[4]+h4 | 0; - h[5] = h[5]+h5 | 0; - h[6] = h[6]+h6 | 0; - h[7] = h[7]+h7 | 0; - } - }; + * @author Emily Stark + * @author Mike Hamburg + * @author Dan Boneh + */ + /** HMAC with the specified hash function. + * @constructor + * @param {bitArray} key the key for HMAC. + * @param {Object} [Hash=sjcl.hash.sha256] The hash function to use. + */ + sjcl.misc.hmac = function (key, Hash) { + this._hash = Hash = Hash || sjcl.hash.sha256; + var exKey = [[], []], + i, + bs = Hash.prototype.blockSize / 32; + this._baseHash = [new Hash(), new Hash()]; - /** @fileOverview HMAC implementation. - * - * @author Emily Stark - * @author Mike Hamburg - * @author Dan Boneh - */ + if (key.length > bs) { + key = Hash.hash(key); + } - /** HMAC with the specified hash function. - * @constructor - * @param {bitArray} key the key for HMAC. - * @param {Object} [Hash=sjcl.hash.sha256] The hash function to use. - */ - sjcl.misc.hmac = function (key, Hash) { - this._hash = Hash = Hash || sjcl.hash.sha256; - var exKey = [[],[]], i, - bs = Hash.prototype.blockSize / 32; - this._baseHash = [new Hash(), new Hash()]; + for (i = 0; i < bs; i++) { + exKey[0][i] = key[i] ^ 0x36363636; + exKey[1][i] = key[i] ^ 0x5c5c5c5c; + } - if (key.length > bs) { - key = Hash.hash(key); - } - - for (i=0; i { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore copy[symbol] = value[symbol]; @@ -1528,15 +1584,17 @@ return copy; } else if (isJSONObject(value)) { // copy object properties - var _copy = _objectSpread({}, value); + const copy = { + ...value + }; // copy all symbols - Object.getOwnPropertySymbols(value).forEach(function (symbol) { + Object.getOwnPropertySymbols(value).forEach(symbol => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - _copy[symbol] = value[symbol]; + copy[symbol] = value[symbol]; }); - return _copy; + return copy; } else { return value; } @@ -1553,7 +1611,7 @@ // return original object unchanged when the new value is identical to the old one return object; } else { - var updatedObject = shallowClone(object); + const updatedObject = shallowClone(object); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore updatedObject[key] = value; @@ -1567,8 +1625,8 @@ * @return Returns the field when found, or undefined when the path doesn't exist */ function getIn(object, path) { - var value = object; - var i = 0; + let value = object; + let i = 0; while (i < path.length) { if (isJSONObject(value)) { value = value[path[i]]; @@ -1599,19 +1657,19 @@ * @return Returns a new, updated object or array */ function setIn(object, path, value) { - var createPath = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + let createPath = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; if (path.length === 0) { return value; } - var key = path[0]; + const key = path[0]; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - var updatedValue = setIn(object ? object[key] : undefined, path.slice(1), value, createPath); + const updatedValue = setIn(object ? object[key] : undefined, path.slice(1), value, createPath); if (isJSONObject(object) || isJSONArray(object)) { return applyProp(object, key, updatedValue); } else { if (createPath) { - var newObject = IS_INTEGER_REGEX.test(key) ? [] : {}; + const newObject = IS_INTEGER_REGEX.test(key) ? [] : {}; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore newObject[key] = updatedValue; @@ -1621,7 +1679,7 @@ } } } - var IS_INTEGER_REGEX = /^\d+$/; + const IS_INTEGER_REGEX = /^\d+$/; /** * helper function to replace a nested property in an object with a new value @@ -1629,17 +1687,17 @@ * * @return Returns a new, updated object or array */ - function updateIn(object, path, callback) { + function updateIn(object, path, transform) { if (path.length === 0) { - return callback(object); + return transform(object); } if (!isObjectOrArray(object)) { throw new Error('Path doesn\'t exist'); } - var key = path[0]; + const key = path[0]; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - var updatedValue = updateIn(object[key], path.slice(1), callback); + const updatedValue = updateIn(object[key], path.slice(1), transform); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return applyProp(object, key, updatedValue); @@ -1659,25 +1717,25 @@ throw new Error('Path does not exist'); } if (path.length === 1) { - var _key = path[0]; - if (!(_key in object)) { + const key = path[0]; + if (!(key in object)) { // key doesn't exist. return object unchanged return object; } else { - var updatedObject = shallowClone(object); + const updatedObject = shallowClone(object); if (isJSONArray(updatedObject)) { - updatedObject.splice(parseInt(_key), 1); + updatedObject.splice(parseInt(key), 1); } if (isJSONObject(updatedObject)) { - delete updatedObject[_key]; + delete updatedObject[key]; } return updatedObject; } } - var key = path[0]; + const key = path[0]; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - var updatedValue = deleteIn(object[key], path.slice(1)); + const updatedValue = deleteIn(object[key], path.slice(1)); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return applyProp(object, key, updatedValue); @@ -1690,13 +1748,13 @@ * insertAt({arr: [1,2,3]}, ['arr', '2'], 'inserted') // [1,2,'inserted',3] */ function insertAt(document, path, value) { - var parentPath = path.slice(0, path.length - 1); - var index = path[path.length - 1]; - return updateIn(document, parentPath, function (items) { + const parentPath = path.slice(0, path.length - 1); + const index = path[path.length - 1]; + return updateIn(document, parentPath, items => { if (!Array.isArray(items)) { throw new TypeError('Array expected at path ' + JSON.stringify(parentPath)); } - var updatedItems = shallowClone(items); + const updatedItems = shallowClone(items); updatedItems.splice(parseInt(index), 0, value); return updatedItems; }); @@ -1726,12 +1784,10 @@ * Parse a JSON Pointer */ function parseJSONPointer(pointer) { - var path = pointer.split('/'); + const path = pointer.split('/'); path.shift(); // remove the first empty entry - return path.map(function (p) { - return p.replace(/~1/g, '/').replace(/~0/g, '~'); - }); + return path.map(p => p.replace(/~1/g, '/').replace(/~0/g, '~')); } /** @@ -1754,31 +1810,11 @@ * instead, the patch is applied in an immutable way */ function immutableJSONPatch(document, operations, options) { - var updatedDocument = document; - for (var i = 0; i < operations.length; i++) { + let updatedDocument = document; + for (let i = 0; i < operations.length; i++) { validateJSONPatchOperation(operations[i]); - var operation = operations[i]; - - // TODO: test before - if (options && options.before) { - var result = options.before(updatedDocument, operation); - if (result !== undefined) { - if (result.document !== undefined) { - updatedDocument = result.document; - } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (result.json !== undefined) { - // TODO: deprecated since v5.0.0. Cleanup this warning some day - throw new Error('Deprecation warning: returned object property ".json" has been renamed to ".document"'); - } - if (result.operation !== undefined) { - operation = result.operation; - } - } - } - var previousDocument = updatedDocument; - var path = parsePath(updatedDocument, operation.path); + let operation = operations[i]; + const path = parsePath(updatedDocument, operation.path); if (operation.op === 'add') { updatedDocument = add(updatedDocument, path, operation.value); } else if (operation.op === 'remove') { @@ -1794,14 +1830,6 @@ } else { throw new Error('Unknown JSONPatch operation ' + JSON.stringify(operation)); } - - // TODO: test after - if (options && options.after) { - var _result = options.after(updatedDocument, operation, previousDocument); - if (_result !== undefined) { - updatedDocument = _result; - } - } } return updatedDocument; } @@ -1835,12 +1863,12 @@ * Copy a value */ function copy(document, path, from) { - var value = getIn(document, from); + const value = getIn(document, from); if (isArrayItem(document, path)) { return insertAt(document, path, value); } else { - var _value = getIn(document, from); - return setIn(document, path, _value); + const value = getIn(document, from); + return setIn(document, path, value); } } @@ -1848,10 +1876,10 @@ * Move a value */ function move(document, path, from) { - var value = getIn(document, from); + const value = getIn(document, from); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - var removedJson = deleteIn(document, from); + const removedJson = deleteIn(document, from); return isArrayItem(removedJson, path) ? insertAt(removedJson, path, value) : setIn(removedJson, path, value); } @@ -1861,21 +1889,21 @@ */ function test(document, path, value) { if (value === undefined) { - throw new Error("Test failed: no value provided (path: \"".concat(compileJSONPointer(path), "\")")); + throw new Error(`Test failed: no value provided (path: "${compileJSONPointer(path)}")`); } if (!existsIn(document, path)) { - throw new Error("Test failed: path not found (path: \"".concat(compileJSONPointer(path), "\")")); + throw new Error(`Test failed: path not found (path: "${compileJSONPointer(path)}")`); } - var actualValue = getIn(document, path); + const actualValue = getIn(document, path); if (!isEqual(actualValue, value)) { - throw new Error("Test failed, value differs (path: \"".concat(compileJSONPointer(path), "\")")); + throw new Error(`Test failed, value differs (path: "${compileJSONPointer(path)}")`); } } function isArrayItem(document, path) { if (path.length === 0) { return false; } - var parent = getIn(document, initial(path)); + const parent = getIn(document, initial(path)); return Array.isArray(parent); } @@ -1887,8 +1915,8 @@ if (last(path) !== '-') { return path; } - var parentPath = initial(path); - var parent = getIn(document, parentPath); + const parentPath = initial(path); + const parent = getIn(document, parentPath); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -1901,7 +1929,7 @@ */ function validateJSONPatchOperation(operation) { // TODO: write unit tests - var ops = ['add', 'remove', 'replace', 'copy', 'move', 'test']; + const ops = ['add', 'remove', 'replace', 'copy', 'move', 'test']; if (!ops.includes(operation.op)) { throw new Error('Unknown JSONPatch op ' + JSON.stringify(operation.op)); } @@ -1930,7 +1958,7 @@ * @param {string} propertyName * @param {import('./wrapper-utils').StrictPropertyDescriptor} descriptor - requires all descriptor options to be defined because we can't validate correctness based on TS types */ - function defineProperty (object, propertyName, descriptor) { + function defineProperty(object, propertyName, descriptor) { { objectDefineProperty(object, propertyName, descriptor); } @@ -1944,12 +1972,12 @@ * @param {*} origFn * @param {string} [mockValue] - when provided, .toString() will return this value */ - function wrapToString (newFn, origFn, mockValue) { + function wrapToString(newFn, origFn, mockValue) { if (typeof newFn !== 'function' || typeof origFn !== 'function') { - return newFn + return newFn; } - return new Proxy(newFn, { get: toStringGetTrap(origFn, mockValue) }) + return new Proxy(newFn, { get: toStringGetTrap(origFn, mockValue) }); } /** @@ -1959,45 +1987,45 @@ * @param {string} [mockValue] - when provided, .toString() will return this value * @returns { (target: any, prop: string, receiver: any) => any } */ - function toStringGetTrap (targetFn, mockValue) { + function toStringGetTrap(targetFn, mockValue) { // We wrap two levels deep to handle toString.toString() calls - return function get (target, prop, receiver) { + return function get(target, prop, receiver) { if (prop === 'toString') { const origToString = Reflect.get(targetFn, 'toString', targetFn); const toStringProxy = new Proxy(origToString, { - apply (target, thisArg, argumentsList) { + apply(target, thisArg, argumentsList) { // only mock toString() when called on the proxy itself. If the method is applied to some other object, it should behave as a normal toString() if (thisArg === receiver) { if (mockValue) { - return mockValue + return mockValue; } - return Reflect.apply(target, targetFn, argumentsList) + return Reflect.apply(target, targetFn, argumentsList); } else { - return Reflect.apply(target, thisArg, argumentsList) + return Reflect.apply(target, thisArg, argumentsList); } }, - get (target, prop, receiver) { + get(target, prop, receiver) { // handle toString.toString() result if (prop === 'toString') { const origToStringToString = Reflect.get(origToString, 'toString', origToString); const toStringToStringProxy = new Proxy(origToStringToString, { - apply (target, thisArg, argumentsList) { + apply(target, thisArg, argumentsList) { if (thisArg === toStringProxy) { - return Reflect.apply(target, origToString, argumentsList) + return Reflect.apply(target, origToString, argumentsList); } else { - return Reflect.apply(target, thisArg, argumentsList) + return Reflect.apply(target, thisArg, argumentsList); } - } + }, }); - return toStringToStringProxy + return toStringToStringProxy; } - return Reflect.get(target, prop, receiver) - } + return Reflect.get(target, prop, receiver); + }, }); - return toStringProxy + return toStringProxy; } - return Reflect.get(target, prop, receiver) - } + return Reflect.get(target, prop, receiver); + }; } /** @@ -2008,9 +2036,9 @@ * @param {typeof Object.defineProperty} definePropertyFn - function to use for defining the property * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ - function wrapProperty (object, propertyName, descriptor, definePropertyFn) { + function wrapProperty(object, propertyName, descriptor, definePropertyFn) { if (!object) { - return + return; } /** @type {StrictPropertyDescriptor} */ @@ -2018,21 +2046,22 @@ const origDescriptor = getOwnPropertyDescriptor(object, propertyName); if (!origDescriptor) { // this happens if the property is not implemented in the browser - return + return; } - if (('value' in origDescriptor && 'value' in descriptor) || + if ( + ('value' in origDescriptor && 'value' in descriptor) || ('get' in origDescriptor && 'get' in descriptor) || ('set' in origDescriptor && 'set' in descriptor) ) { definePropertyFn(object, propertyName, { ...origDescriptor, - ...descriptor + ...descriptor, }); - return origDescriptor + return origDescriptor; } else { // if the property is defined with get/set it must be wrapped with a get/set. If it's defined with a `value`, it must be wrapped with a `value` - throw new Error(`Property descriptor for ${propertyName} may only include the following keys: ${objectKeys(origDescriptor)}`) + throw new Error(`Property descriptor for ${propertyName} may only include the following keys: ${objectKeys(origDescriptor)}`); } } @@ -2044,9 +2073,9 @@ * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ - function wrapMethod (object, propertyName, wrapperFn, definePropertyFn) { + function wrapMethod(object, propertyName, wrapperFn, definePropertyFn) { if (!object) { - return + return; } /** @type {StrictPropertyDescriptor} */ @@ -2054,25 +2083,25 @@ const origDescriptor = getOwnPropertyDescriptor(object, propertyName); if (!origDescriptor) { // this happens if the property is not implemented in the browser - return + return; } // @ts-expect-error - we check for undefined below const origFn = origDescriptor.value; if (!origFn || typeof origFn !== 'function') { // method properties are expected to be defined with a `value` - throw new Error(`Property ${propertyName} does not look like a method`) + throw new Error(`Property ${propertyName} does not look like a method`); } const newFn = wrapToString(function () { - return wrapperFn.call(this, origFn, ...arguments) + return wrapperFn.call(this, origFn, ...arguments); }, origFn); definePropertyFn(object, propertyName, { ...origDescriptor, - value: newFn + value: newFn, }); - return origDescriptor + return origDescriptor; } /** @@ -2082,25 +2111,20 @@ * @param {DefineInterfaceOptions} options - options for defining the interface * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property */ - function shimInterface ( - interfaceName, - ImplClass, - options, - definePropertyFn - ) { + function shimInterface(interfaceName, ImplClass, options, definePropertyFn) { /** @type {DefineInterfaceOptions} */ const defaultOptions = { allowConstructorCall: false, disallowConstructor: false, constructorErrorMessage: 'Illegal constructor', - wrapToString: true + wrapToString: true, }; const fullOptions = { interfaceDescriptorOptions: { writable: true, enumerable: false, configurable: true, value: ImplClass }, ...defaultOptions, - ...options + ...options, }; // In some cases we can get away without a full proxy, but in many cases below we need it. @@ -2114,14 +2138,14 @@ if (fullOptions.allowConstructorCall) { // make the constructor function callable without new proxyHandler.apply = function (target, thisArg, argumentsList) { - return Reflect.construct(target, argumentsList, target) + return Reflect.construct(target, argumentsList, target); }; } // make the constructor function throw when called without new if (fullOptions.disallowConstructor) { proxyHandler.construct = function () { - throw new TypeError(fullOptions.constructorErrorMessage) + throw new TypeError(fullOptions.constructorErrorMessage); }; } @@ -2130,14 +2154,14 @@ for (const [prop, descriptor] of objectEntries(getOwnPropertyDescriptors(ImplClass.prototype))) { if (prop !== 'constructor' && descriptor.writable && typeof descriptor.value === 'function') { ImplClass.prototype[prop] = new Proxy(descriptor.value, { - get: toStringGetTrap(descriptor.value, `function ${prop}() { [native code] }`) + get: toStringGetTrap(descriptor.value, `function ${prop}() { [native code] }`), }); } } // wrap toString on the constructor function itself Object.assign(proxyHandler, { - get: toStringGetTrap(ImplClass, `function ${interfaceName}() { [native code] }`) + get: toStringGetTrap(ImplClass, `function ${interfaceName}() { [native code] }`), }); } @@ -2161,15 +2185,11 @@ value: interfaceName, configurable: true, enumerable: false, - writable: false + writable: false, }); // interfaces are exposed directly on the global object, not on its prototype - definePropertyFn( - globalThis, - interfaceName, - { ...fullOptions.interfaceDescriptorOptions, value: Interface } - ); + definePropertyFn(globalThis, interfaceName, { ...fullOptions.interfaceDescriptorOptions, value: Interface }); } /** @@ -2184,13 +2204,13 @@ * @param {boolean} readOnly - whether the property should be read-only * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property */ - function shimProperty (baseObject, propertyName, implInstance, readOnly, definePropertyFn) { + function shimProperty(baseObject, propertyName, implInstance, readOnly, definePropertyFn) { // @ts-expect-error - implInstance is a class instance const ImplClass = implInstance.constructor; // mask toString() and toString.toString() on the instance const proxiedInstance = new Proxy(implInstance, { - get: toStringGetTrap(implInstance, `[object ${ImplClass.name}]`) + get: toStringGetTrap(implInstance, `[object ${ImplClass.name}]`), }); /** @type {StrictPropertyDescriptor} */ @@ -2200,21 +2220,23 @@ // But there could be other cases, e.g. a property with both a getter and a setter. These could be defined with a raw defineProperty() call. // Important: make sure to cover each new shim with a test that verifies that all descriptors match the standard API. if (readOnly) { - const getter = function get () { return proxiedInstance }; + const getter = function get() { + return proxiedInstance; + }; const proxiedGetter = new Proxy(getter, { - get: toStringGetTrap(getter, `function get ${propertyName}() { [native code] }`) + get: toStringGetTrap(getter, `function get ${propertyName}() { [native code] }`), }); descriptor = { configurable: true, enumerable: true, - get: proxiedGetter + get: proxiedGetter, }; } else { descriptor = { configurable: true, enumerable: true, writable: true, - value: proxiedInstance + value: proxiedInstance, }; } @@ -2288,7 +2310,7 @@ * @param {import('../index.js').MessagingContext} messagingContext * @internal */ - constructor (config, messagingContext) { + constructor(config, messagingContext) { this.messagingContext = messagingContext; this.config = config; this.globals = { @@ -2297,11 +2319,11 @@ JSONstringify: window.JSON.stringify, Promise: window.Promise, Error: window.Error, - String: window.String + String: window.String, }; for (const [methodName, fn] of Object.entries(this.config.methods)) { if (typeof fn !== 'function') { - throw new Error('cannot create WindowsMessagingTransport, missing the method: ' + methodName) + throw new Error('cannot create WindowsMessagingTransport, missing the method: ' + methodName); } } } @@ -2309,7 +2331,7 @@ /** * @param {import('../index.js').NotificationMessage} msg */ - notify (msg) { + notify(msg) { const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); const notification = WindowsNotification.fromNotification(msg, data); this.config.methods.postMessage(notification); @@ -2320,7 +2342,7 @@ * @param {{signal?: AbortSignal}} opts * @return {Promise} */ - request (msg, opts = {}) { + request(msg, opts = {}) { // convert the message to window-specific naming const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); const outgoing = WindowsRequestMessage.fromRequest(msg, data); @@ -2330,19 +2352,17 @@ // compare incoming messages against the `msg.id` const comparator = (eventData) => { - return eventData.featureName === msg.featureName && - eventData.context === msg.context && - eventData.id === msg.id + return eventData.featureName === msg.featureName && eventData.context === msg.context && eventData.id === msg.id; }; /** * @param data * @return {data is import('../index.js').MessageResponse} */ - function isMessageResponse (data) { - if ('result' in data) return true - if ('error' in data) return true - return false + function isMessageResponse(data) { + if ('result' in data) return true; + if ('error' in data) return true; + return false; } // now wait for a matching message @@ -2353,11 +2373,11 @@ if (!isMessageResponse(value)) { console.warn('unknown response type', value); - return reject(new this.globals.Error('unknown response')) + return reject(new this.globals.Error('unknown response')); } if (value.result) { - return resolve(value.result) + return resolve(value.result); } const message = this.globals.String(value.error?.message || 'unknown error'); @@ -2366,28 +2386,30 @@ } catch (e) { reject(e); } - }) + }); } /** * @param {import('../index.js').Subscription} msg * @param {(value: unknown | undefined) => void} callback */ - subscribe (msg, callback) { + subscribe(msg, callback) { // compare incoming messages against the `msg.subscriptionName` const comparator = (eventData) => { - return eventData.featureName === msg.featureName && + return ( + eventData.featureName === msg.featureName && eventData.context === msg.context && eventData.subscriptionName === msg.subscriptionName + ); }; // only forward the 'params' from a SubscriptionEvent const cb = (eventData) => { - return callback(eventData.params) + return callback(eventData.params); }; // now listen for matching incoming messages. - return this._subscribe(comparator, {}, cb) + return this._subscribe(comparator, {}, cb); } /** @@ -2399,10 +2421,10 @@ * @param {(value: Incoming, unsubscribe: (()=>void)) => void} callback * @internal */ - _subscribe (comparator, options, callback) { + _subscribe(comparator, options, callback) { // if already aborted, reject immediately if (options?.signal?.aborted) { - throw new DOMException('Aborted', 'AbortError') + throw new DOMException('Aborted', 'AbortError'); } /** @type {(()=>void) | undefined} */ // eslint-disable-next-line prefer-const @@ -2415,15 +2437,15 @@ if (this.messagingContext.env === 'production') { if (event.origin !== null && event.origin !== undefined) { console.warn('ignoring because evt.origin is not `null` or `undefined`'); - return + return; } } if (!event.data) { console.warn('data absent from message'); - return + return; } if (comparator(event.data)) { - if (!teardown) throw new Error('unreachable') + if (!teardown) throw new Error('unreachable'); callback(event.data, teardown); } }; @@ -2431,24 +2453,24 @@ // what to do if this promise is aborted const abortHandler = () => { teardown?.(); - throw new DOMException('Aborted', 'AbortError') + throw new DOMException('Aborted', 'AbortError'); }; // console.log('DEBUG: handler setup', { config, comparator }) - // eslint-disable-next-line no-undef + this.config.methods.addEventListener('message', idHandler); options?.signal?.addEventListener('abort', abortHandler); teardown = () => { // console.log('DEBUG: handler teardown', { config, comparator }) - // eslint-disable-next-line no-undef + this.config.methods.removeEventListener('message', idHandler); options?.signal?.removeEventListener('abort', abortHandler); }; return () => { teardown?.(); - } + }; } } @@ -2477,7 +2499,7 @@ * @param {WindowsInteropMethods} params.methods * @internal */ - constructor (params) { + constructor(params) { /** * The methods required for communication */ @@ -2504,7 +2526,7 @@ * @param {Record} [params.Data] * @internal */ - constructor (params) { + constructor(params) { /** * Alias for: {@link NotificationMessage.context} */ @@ -2528,15 +2550,15 @@ * @param {NotificationMessage} notification * @returns {WindowsNotification} */ - static fromNotification (notification, data) { + static fromNotification(notification, data) { /** @type {WindowsNotification} */ const output = { Data: data, Feature: notification.context, SubFeatureName: notification.featureName, - Name: notification.method + Name: notification.method, }; - return output + return output; } } @@ -2555,7 +2577,7 @@ * @param {string} [params.Id] * @internal */ - constructor (params) { + constructor(params) { this.Feature = params.Feature; this.SubFeatureName = params.SubFeatureName; this.Name = params.Name; @@ -2569,16 +2591,16 @@ * @param {Record} data * @returns {WindowsRequestMessage} */ - static fromRequest (msg, data) { + static fromRequest(msg, data) { /** @type {WindowsRequestMessage} */ const output = { Data: data, Feature: msg.context, SubFeatureName: msg.featureName, Name: msg.method, - Id: msg.id + Id: msg.id, }; - return output + return output; } } @@ -2614,7 +2636,7 @@ * @param {Record} [params.params] * @internal */ - constructor (params) { + constructor(params) { /** * The global context for this message. For example, something like `contentScopeScripts` or `specialPages` * @type {string} @@ -2653,7 +2675,7 @@ * @param {Record} [params.params] * @internal */ - constructor (params) { + constructor(params) { /** * The global context for this message. For example, something like `contentScopeScripts` or `specialPages` */ @@ -2681,7 +2703,7 @@ * @param {string} params.subscriptionName * @internal */ - constructor (params) { + constructor(params) { this.context = params.context; this.featureName = params.featureName; this.subscriptionName = params.subscriptionName; @@ -2693,18 +2715,16 @@ * @param {Record} data * @return {data is MessageResponse} */ - function isResponseFor (request, data) { + function isResponseFor(request, data) { if ('result' in data) { - return data.featureName === request.featureName && - data.context === request.context && - data.id === request.id + return data.featureName === request.featureName && data.context === request.context && data.id === request.id; } if ('error' in data) { if ('message' in data.error) { - return true + return true; } } - return false + return false; } /** @@ -2712,14 +2732,12 @@ * @param {Record} data * @return {data is SubscriptionEvent} */ - function isSubscriptionEventFor (sub, data) { + function isSubscriptionEventFor(sub, data) { if ('subscriptionName' in data) { - return data.featureName === sub.featureName && - data.context === sub.context && - data.subscriptionName === sub.subscriptionName + return data.featureName === sub.featureName && data.context === sub.context && data.subscriptionName === sub.subscriptionName; } - return false + return false; } /** @@ -2783,7 +2801,7 @@ * @param {WebkitMessagingConfig} config * @param {import('../index.js').MessagingContext} messagingContext */ - constructor (config, messagingContext) { + constructor(config, messagingContext) { this.messagingContext = messagingContext; this.config = config; this.globals = captureGlobals(); @@ -2798,25 +2816,25 @@ * @param {*} data * @internal */ - wkSend (handler, data = {}) { + wkSend(handler, data = {}) { if (!(handler in this.globals.window.webkit.messageHandlers)) { - throw new MissingHandler(`Missing webkit handler: '${handler}'`, handler) + throw new MissingHandler(`Missing webkit handler: '${handler}'`, handler); } if (!this.config.hasModernWebkitAPI) { const outgoing = { ...data, messageHandling: { ...data.messageHandling, - secret: this.config.secret - } + secret: this.config.secret, + }, }; if (!(handler in this.globals.capturedWebkitHandlers)) { - throw new MissingHandler(`cannot continue, method ${handler} not captured on macos < 11`, handler) + throw new MissingHandler(`cannot continue, method ${handler} not captured on macos < 11`, handler); } else { - return this.globals.capturedWebkitHandlers[handler](outgoing) + return this.globals.capturedWebkitHandlers[handler](outgoing); } } - return this.globals.window.webkit.messageHandlers[handler].postMessage?.(data) + return this.globals.window.webkit.messageHandlers[handler].postMessage?.(data); } /** @@ -2826,10 +2844,10 @@ * @returns {Promise<*>} * @internal */ - async wkSendAndWait (handler, data) { + async wkSendAndWait(handler, data) { if (this.config.hasModernWebkitAPI) { const response = await this.wkSend(handler, data); - return this.globals.JSONparse(response || '{}') + return this.globals.JSONparse(response || '{}'); } try { @@ -2837,10 +2855,7 @@ const key = await this.createRandKey(); const iv = this.createRandIv(); - const { - ciphertext, - tag - } = await new this.globals.Promise((/** @type {any} */ resolve) => { + const { ciphertext, tag } = await new this.globals.Promise((/** @type {any} */ resolve) => { this.generateRandomMethod(randMethodName, resolve); // @ts-expect-error - this is a carve-out for catalina that will be removed soon @@ -2848,22 +2863,22 @@ methodName: randMethodName, secret: this.config.secret, key: this.globals.Arrayfrom(key), - iv: this.globals.Arrayfrom(iv) + iv: this.globals.Arrayfrom(iv), }); this.wkSend(handler, data); }); const cipher = new this.globals.Uint8Array([...ciphertext, ...tag]); const decrypted = await this.decrypt(cipher, key, iv); - return this.globals.JSONparse(decrypted || '{}') + return this.globals.JSONparse(decrypted || '{}'); } catch (e) { // re-throw when the error is just a 'MissingHandler' if (e instanceof MissingHandler) { - throw e + throw e; } else { console.error('decryption failed', e); console.error(e); - return { error: e } + return { error: e }; } } } @@ -2871,27 +2886,27 @@ /** * @param {import('../index.js').NotificationMessage} msg */ - notify (msg) { + notify(msg) { this.wkSend(msg.context, msg); } /** * @param {import('../index.js').RequestMessage} msg */ - async request (msg) { + async request(msg) { const data = await this.wkSendAndWait(msg.context, msg); if (isResponseFor(msg, data)) { if (data.result) { - return data.result || {} + return data.result || {}; } // forward the error if one was given explicity if (data.error) { - throw new Error(data.error.message) + throw new Error(data.error.message); } } - throw new Error('an unknown error occurred') + throw new Error('an unknown error occurred'); } /** @@ -2901,7 +2916,7 @@ * @param {Function} callback * @internal */ - generateRandomMethod (randomMethodName, callback) { + generateRandomMethod(randomMethodName, callback) { this.globals.ObjectDefineProperty(this.globals.window, randomMethodName, { enumerable: false, // configurable, To allow for deletion later @@ -2911,10 +2926,9 @@ * @param {any[]} args */ value: (...args) => { - // eslint-disable-next-line n/no-callback-literal callback(...args); delete this.globals.window[randomMethodName]; - } + }, }); } @@ -2922,16 +2936,16 @@ * @internal * @return {string} */ - randomString () { - return '' + this.globals.getRandomValues(new this.globals.Uint32Array(1))[0] + randomString() { + return '' + this.globals.getRandomValues(new this.globals.Uint32Array(1))[0]; } /** * @internal * @return {string} */ - createRandMethodName () { - return '_' + this.randomString() + createRandMethodName() { + return '_' + this.randomString(); } /** @@ -2940,25 +2954,25 @@ */ algoObj = { name: 'AES-GCM', - length: 256 - } + length: 256, + }; /** * @returns {Promise} * @internal */ - async createRandKey () { + async createRandKey() { const key = await this.globals.generateKey(this.algoObj, true, ['encrypt', 'decrypt']); const exportedKey = await this.globals.exportKey('raw', key); - return new this.globals.Uint8Array(exportedKey) + return new this.globals.Uint8Array(exportedKey); } /** * @returns {Uint8Array} * @internal */ - createRandIv () { - return this.globals.getRandomValues(new this.globals.Uint8Array(12)) + createRandIv() { + return this.globals.getRandomValues(new this.globals.Uint8Array(12)); } /** @@ -2968,17 +2982,17 @@ * @returns {Promise} * @internal */ - async decrypt (ciphertext, key, iv) { + async decrypt(ciphertext, key, iv) { const cryptoKey = await this.globals.importKey('raw', key, 'AES-GCM', false, ['decrypt']); const algo = { name: 'AES-GCM', - iv + iv, }; const decrypted = await this.globals.decrypt(algo, cryptoKey, ciphertext); const dec = new this.globals.TextDecoder(); - return dec.decode(decrypted) + return dec.decode(decrypted); } /** @@ -2987,9 +3001,9 @@ * * @param {string[]} handlerNames */ - captureWebkitHandlers (handlerNames) { + captureWebkitHandlers(handlerNames) { const handlers = window.webkit.messageHandlers; - if (!handlers) throw new MissingHandler('window.webkit.messageHandlers was absent', 'all') + if (!handlers) throw new MissingHandler('window.webkit.messageHandlers was absent', 'all'); for (const webkitMessageHandlerName of handlerNames) { if (typeof handlers[webkitMessageHandlerName]?.postMessage === 'function') { /** @@ -3008,10 +3022,10 @@ * @param {import('../index.js').Subscription} msg * @param {(value: unknown) => void} callback */ - subscribe (msg, callback) { + subscribe(msg, callback) { // for now, bail if there's already a handler setup for this subscription if (msg.subscriptionName in this.globals.window) { - throw new this.globals.Error(`A subscription with the name ${msg.subscriptionName} already exists`) + throw new this.globals.Error(`A subscription with the name ${msg.subscriptionName} already exists`); } this.globals.ObjectDefineProperty(this.globals.window, msg.subscriptionName, { enumerable: false, @@ -3023,11 +3037,11 @@ } else { console.warn('Received a message that did not match the subscription', data); } - } + }, }); return () => { this.globals.ReflectDeleteProperty(this.globals.window, msg.subscriptionName); - } + }; } } @@ -3048,7 +3062,7 @@ * @param {string} params.secret * @internal */ - constructor (params) { + constructor(params) { /** * Whether or not the current WebKit Platform supports secure messaging * by default (eg: macOS 11+) @@ -3090,7 +3104,7 @@ * @param {number[]} params.key * @param {number[]} params.iv */ - constructor (params) { + constructor(params) { /** * The method that's been appended to `window` to be called later */ @@ -3114,7 +3128,7 @@ * Capture some globals used for messaging handling to prevent page * scripts from tampering with this */ - function captureGlobals () { + function captureGlobals() { // Create base with null prototype const globals = { window, @@ -3133,7 +3147,7 @@ ObjectDefineProperty: window.Object.defineProperty, addEventListener: window.addEventListener.bind(window), /** @type {Record} */ - capturedWebkitHandlers: {} + capturedWebkitHandlers: {}, }; if (isSecureContext) { // skip for HTTP content since window.crypto.subtle is unavailable @@ -3143,7 +3157,7 @@ globals.encrypt = window.crypto.subtle.encrypt.bind(window.crypto.subtle); globals.decrypt = window.crypto.subtle.decrypt.bind(window.crypto.subtle); } - return globals + return globals; } /** @@ -3175,7 +3189,7 @@ * @param {MessagingContext} messagingContext * @internal */ - constructor (config, messagingContext) { + constructor(config, messagingContext) { this.messagingContext = messagingContext; this.config = config; } @@ -3183,7 +3197,7 @@ /** * @param {NotificationMessage} msg */ - notify (msg) { + notify(msg) { try { this.config.sendMessageThrows?.(JSON.stringify(msg)); } catch (e) { @@ -3195,7 +3209,7 @@ * @param {RequestMessage} msg * @return {Promise} */ - request (msg) { + request(msg) { return new Promise((resolve, reject) => { // subscribe early const unsub = this.config.subscribe(msg.id, handler); @@ -3207,33 +3221,33 @@ reject(new Error('request failed to send: ' + e.message || 'unknown error')); } - function handler (data) { + function handler(data) { if (isResponseFor(msg, data)) { // success case, forward .result only if (data.result) { resolve(data.result || {}); - return unsub() + return unsub(); } // error case, forward the error as a regular promise rejection if (data.error) { reject(new Error(data.error.message)); - return unsub() + return unsub(); } // getting here is undefined behavior unsub(); - throw new Error('unreachable: must have `result` or `error` key by this point') + throw new Error('unreachable: must have `result` or `error` key by this point'); } } - }) + }); } /** * @param {Subscription} msg * @param {(value: unknown | undefined) => void} callback */ - subscribe (msg, callback) { + subscribe(msg, callback) { const unsub = this.config.subscribe(msg.subscriptionName, (data) => { if (isSubscriptionEventFor(msg, data)) { callback(data.params || {}); @@ -3241,7 +3255,7 @@ }); return () => { unsub(); - } + }; } } @@ -3320,7 +3334,7 @@ */ class AndroidMessagingConfig { /** @type {(json: string, secret: string) => void} */ - _capturedHandler + _capturedHandler; /** * @param {object} params * @param {Record} params.target @@ -3332,7 +3346,7 @@ * @param {string} params.messageCallback - the name of the callback that the native * side will use to send messages back to the javascript side */ - constructor (params) { + constructor(params) { this.target = params.target; this.debug = params.debug; this.javascriptInterface = params.javascriptInterface; @@ -3366,7 +3380,7 @@ * @throws * @internal */ - sendMessageThrows (json) { + sendMessageThrows(json) { this._capturedHandler(json, this.messageSecret); } @@ -3383,11 +3397,11 @@ * @returns {() => void} * @internal */ - subscribe (id, callback) { + subscribe(id, callback) { this.listeners.set(id, callback); return () => { this.listeners.delete(id); - } + }; } /** @@ -3399,10 +3413,10 @@ * @param {MessageResponse | SubscriptionEvent} payload * @internal */ - _dispatch (payload) { + _dispatch(payload) { // do nothing if the response is empty // this prevents the next `in` checks from throwing in test/debug scenarios - if (!payload) return this._log('no response') + if (!payload) return this._log('no response'); // if the payload has an 'id' field, then it's a message response if ('id' in payload) { @@ -3428,9 +3442,9 @@ * @param {(...args: any[]) => any} fn * @param {string} [context] */ - _tryCatch (fn, context = 'none') { + _tryCatch(fn, context = 'none') { try { - return fn() + return fn(); } catch (e) { if (this.debug) { console.error('AndroidMessagingConfig error:', context); @@ -3442,7 +3456,7 @@ /** * @param {...any} args */ - _log (...args) { + _log(...args) { if (this.debug) { console.log('AndroidMessagingConfig', ...args); } @@ -3451,7 +3465,7 @@ /** * Capture the global handler and remove it from the global object. */ - _captureGlobalHandler () { + _captureGlobalHandler() { const { target, javascriptInterface } = this; if (Object.prototype.hasOwnProperty.call(target, javascriptInterface)) { @@ -3468,7 +3482,7 @@ * Assign the incoming handler method to the global object. * This is the method that Android will call to deliver messages. */ - _assignHandlerMethod () { + _assignHandlerMethod() { /** * @type {(secret: string, response: MessageResponse | SubscriptionEvent) => void} */ @@ -3479,7 +3493,7 @@ }; Object.defineProperty(this.target, this.messageCallback, { - value: responseHandler + value: responseHandler, }); } } @@ -3518,7 +3532,7 @@ * @param {"production" | "development"} params.env * @internal */ - constructor (params) { + constructor(params) { this.context = params.context; this.featureName = params.featureName; this.env = params.env; @@ -3537,7 +3551,7 @@ * @param {MessagingContext} messagingContext * @param {MessagingConfig} config */ - constructor (messagingContext, config) { + constructor(messagingContext, config) { this.messagingContext = messagingContext; this.transport = getTransport(config, this.messagingContext); } @@ -3555,12 +3569,12 @@ * @param {string} name * @param {Record} [data] */ - notify (name, data = {}) { + notify(name, data = {}) { const message = new NotificationMessage({ context: this.messagingContext.context, featureName: this.messagingContext.featureName, method: name, - params: data + params: data, }); this.transport.notify(message); } @@ -3579,16 +3593,16 @@ * @param {Record} [data] * @return {Promise} */ - request (name, data = {}) { + request(name, data = {}) { const id = globalThis?.crypto?.randomUUID?.() || name + '.response'; const message = new RequestMessage({ context: this.messagingContext.context, featureName: this.messagingContext.featureName, method: name, params: data, - id + id, }); - return this.transport.request(message) + return this.transport.request(message); } /** @@ -3596,13 +3610,13 @@ * @param {(value: unknown) => void} callback * @return {() => void} */ - subscribe (name, callback) { + subscribe(name, callback) { const msg = new Subscription({ context: this.messagingContext.context, featureName: this.messagingContext.featureName, - subscriptionName: name + subscriptionName: name, }); - return this.transport.subscribe(msg, callback) + return this.transport.subscribe(msg, callback); } } @@ -3616,7 +3630,7 @@ /** * @param {MessagingTransport} impl */ - constructor (impl) { + constructor(impl) { this.impl = impl; } } @@ -3629,21 +3643,21 @@ * @param {TestTransportConfig} config * @param {MessagingContext} messagingContext */ - constructor (config, messagingContext) { + constructor(config, messagingContext) { this.config = config; this.messagingContext = messagingContext; } - notify (msg) { - return this.config.impl.notify(msg) + notify(msg) { + return this.config.impl.notify(msg); } - request (msg) { - return this.config.impl.request(msg) + request(msg) { + return this.config.impl.request(msg); } - subscribe (msg, callback) { - return this.config.impl.subscribe(msg, callback) + subscribe(msg, callback) { + return this.config.impl.subscribe(msg, callback); } } @@ -3652,20 +3666,20 @@ * @param {MessagingContext} messagingContext * @returns {MessagingTransport} */ - function getTransport (config, messagingContext) { + function getTransport(config, messagingContext) { if (config instanceof WebkitMessagingConfig) { - return new WebkitMessagingTransport(config, messagingContext) + return new WebkitMessagingTransport(config, messagingContext); } if (config instanceof WindowsMessagingConfig) { - return new WindowsMessagingTransport(config, messagingContext) + return new WindowsMessagingTransport(config, messagingContext); } if (config instanceof AndroidMessagingConfig) { - return new AndroidMessagingTransport(config, messagingContext) + return new AndroidMessagingTransport(config, messagingContext); } if (config instanceof TestTransportConfig) { - return new TestTransport(config, messagingContext) + return new TestTransport(config, messagingContext); } - throw new Error('unreachable') + throw new Error('unreachable'); } /** @@ -3676,7 +3690,7 @@ * @param {string} message * @param {string} handlerName */ - constructor (message, handlerName) { + constructor(message, handlerName) { super(message); this.handlerName = handlerName; } @@ -3690,9 +3704,9 @@ /** * @deprecated - A temporary constructor for the extension to make the messaging config */ - function extensionConstructMessagingConfig () { + function extensionConstructMessagingConfig() { const messagingTransport = new SendMessageMessagingTransport(); - return new TestTransportConfig(messagingTransport) + return new TestTransportConfig(messagingTransport); } /** @@ -3708,9 +3722,9 @@ * Queue of callbacks to be called with messages sent from the Platform. * This is used to connect requests with responses and to trigger subscriptions callbacks. */ - _queue = new Set() + _queue = new Set(); - constructor () { + constructor() { this.globals = { window: globalThis, globalThis, @@ -3718,7 +3732,7 @@ JSONstringify: globalThis.JSON.stringify, Promise: globalThis.Promise, Error: globalThis.Error, - String: globalThis.String + String: globalThis.String, }; } @@ -3727,14 +3741,14 @@ * with callback functions in the _queue. * @param {any} response */ - onResponse (response) { + onResponse(response) { this._queue.forEach((subscription) => subscription(response)); } /** * @param {import('@duckduckgo/messaging').NotificationMessage} msg */ - notify (msg) { + notify(msg) { let params = msg.params; // Unwrap 'setYoutubePreviewsEnabled' params to match expected payload @@ -3755,9 +3769,9 @@ * @param {import('@duckduckgo/messaging').RequestMessage} req * @return {Promise} */ - request (req) { + request(req) { let comparator = (eventData) => { - return eventData.responseMessageType === req.method + return eventData.responseMessageType === req.method; }; let params = req.params; @@ -3770,7 +3784,7 @@ eventData.responseMessageType === req.method && eventData.response && eventData.response.videoURL === req.params?.videoURL - ) + ); }; params = req.params?.videoURL; } @@ -3781,29 +3795,26 @@ this._subscribe(comparator, (msgRes, unsubscribe) => { unsubscribe(); - return resolve(msgRes.response) + return resolve(msgRes.response); }); - }) + }); } /** * @param {import('@duckduckgo/messaging').Subscription} msg * @param {(value: unknown | undefined) => void} callback */ - subscribe (msg, callback) { + subscribe(msg, callback) { const comparator = (eventData) => { - return ( - eventData.messageType === msg.subscriptionName || - eventData.responseMessageType === msg.subscriptionName - ) + return eventData.messageType === msg.subscriptionName || eventData.responseMessageType === msg.subscriptionName; }; // only forward the 'params' ('response' in current format), to match expected // callback from a SubscriptionEvent const cb = (eventData) => { - return callback(eventData.response) + return callback(eventData.response); }; - return this._subscribe(comparator, cb) + return this._subscribe(comparator, cb); } /** @@ -3811,7 +3822,7 @@ * @param {(value: any, unsubscribe: (()=>void)) => void} callback * @internal */ - _subscribe (comparator, callback) { + _subscribe(comparator, callback) { /** @type {(()=>void) | undefined} */ // eslint-disable-next-line prefer-const let teardown; @@ -3822,10 +3833,10 @@ const idHandler = (event) => { if (!event) { console.warn('no message available'); - return + return; } if (comparator(event)) { - if (!teardown) throw new this.globals.Error('unreachable') + if (!teardown) throw new this.globals.Error('unreachable'); callback(event, teardown); } }; @@ -3837,7 +3848,7 @@ return () => { teardown?.(); - } + }; } } @@ -3857,91 +3868,93 @@ class ContentFeature { /** @type {import('./utils.js').RemoteConfig | undefined} */ - #bundledConfig + #bundledConfig; /** @type {object | undefined} */ - #trackerLookup + #trackerLookup; /** @type {boolean | undefined} */ - #documentOriginIsTracker + #documentOriginIsTracker; /** @type {Record | undefined} */ - #bundledfeatureSettings + // eslint-disable-next-line no-unused-private-class-members + #bundledfeatureSettings; /** @type {import('../../messaging').Messaging} */ - #messaging + // eslint-disable-next-line no-unused-private-class-members + #messaging; /** @type {boolean} */ - #isDebugFlagSet = false + #isDebugFlagSet = false; /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record, assets?: AssetConfig | undefined, site: Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */ - #args + #args; - constructor (featureName) { + constructor(featureName) { this.name = featureName; this.#args = null; this.monitor = new PerformanceMonitor(); } - get isDebug () { - return this.#args?.debug || false + get isDebug() { + return this.#args?.debug || false; } - get desktopModeEnabled () { - return this.#args?.desktopModeEnabled || false + get desktopModeEnabled() { + return this.#args?.desktopModeEnabled || false; } - get forcedZoomEnabled () { - return this.#args?.forcedZoomEnabled || false + get forcedZoomEnabled() { + return this.#args?.forcedZoomEnabled || false; } /** * @param {import('./utils').Platform} platform */ - set platform (platform) { + set platform(platform) { this._platform = platform; } - get platform () { + get platform() { // @ts-expect-error - Type 'Platform | undefined' is not assignable to type 'Platform' - return this._platform + return this._platform; } /** * @type {AssetConfig | undefined} */ - get assetConfig () { - return this.#args?.assets + get assetConfig() { + return this.#args?.assets; } /** * @returns {boolean} */ - get documentOriginIsTracker () { - return !!this.#documentOriginIsTracker + get documentOriginIsTracker() { + return !!this.#documentOriginIsTracker; } /** * @returns {object} **/ - get trackerLookup () { - return this.#trackerLookup || {} + get trackerLookup() { + return this.#trackerLookup || {}; } /** * @returns {import('./utils.js').RemoteConfig | undefined} **/ - get bundledConfig () { - return this.#bundledConfig + get bundledConfig() { + return this.#bundledConfig; } /** * @deprecated as we should make this internal to the class and not used externally * @return {MessagingContext} */ - _createMessagingContext () { + _createMessagingContext() { const contextName = 'contentScopeScripts'; return new MessagingContext({ context: contextName, env: this.isDebug ? 'development' : 'production', - featureName: this.name - }) + featureName: this.name, + }); } /** @@ -3949,16 +3962,16 @@ * * @return {import('@duckduckgo/messaging').Messaging} */ - get messaging () { - if (this._messaging) return this._messaging + get messaging() { + if (this._messaging) return this._messaging; const messagingContext = this._createMessagingContext(); let messagingConfig = this.#args?.messagingConfig; if (!messagingConfig) { - if (this.platform?.name !== 'extension') throw new Error('Only extension messaging supported, all others should be passed in') + if (this.platform?.name !== 'extension') throw new Error('Only extension messaging supported, all others should be passed in'); messagingConfig = extensionConstructMessagingConfig(); } this._messaging = new Messaging(messagingContext, messagingConfig); - return this._messaging + return this._messaging; } /** @@ -3970,9 +3983,9 @@ * @param {any} defaultValue - The default value to use if the config setting is not set * @returns The value of the config setting or the default value */ - getFeatureAttr (attrName, defaultValue) { + getFeatureAttr(attrName, defaultValue) { const configSetting = this.getFeatureSetting(attrName); - return processAttr(configSetting, defaultValue) + return processAttr(configSetting, defaultValue); } /** @@ -3981,17 +3994,17 @@ * @param {string} [featureName] * @returns {any} */ - getFeatureSetting (featureKeyName, featureName) { + getFeatureSetting(featureKeyName, featureName) { let result = this._getFeatureSettings(featureName); if (featureKeyName === 'domains') { - throw new Error('domains is a reserved feature setting key name') + throw new Error('domains is a reserved feature setting key name'); } const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => { - return a.domain.length - b.domain.length + return a.domain.length - b.domain.length; }); for (const match of domainMatch) { if (match.patchSettings === undefined) { - continue + continue; } try { result = immutableJSONPatch(result, match.patchSettings); @@ -3999,7 +4012,7 @@ console.error('Error applying patch settings', e); } } - return result?.[featureKeyName] + return result?.[featureKeyName]; } /** @@ -4007,9 +4020,9 @@ * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature * @returns {any} */ - _getFeatureSettings (featureName) { + _getFeatureSettings(featureName) { const camelFeatureName = featureName || camelcase(this.name); - return this.#args?.featureSettings?.[camelFeatureName] + return this.#args?.featureSettings?.[camelFeatureName]; } /** @@ -4019,12 +4032,12 @@ * @param {string} [featureName] * @returns {boolean} */ - getFeatureSettingEnabled (featureKeyName, featureName) { + getFeatureSettingEnabled(featureKeyName, featureName) { const result = this.getFeatureSetting(featureKeyName, featureName); if (typeof result === 'object') { - return result.state === 'enabled' + return result.state === 'enabled'; } - return result === 'enabled' + return result === 'enabled'; } /** @@ -4032,25 +4045,23 @@ * @param {string} featureKeyName * @return {any[]} */ - matchDomainFeatureSetting (featureKeyName) { + matchDomainFeatureSetting(featureKeyName) { const domain = this.#args?.site.domain; - if (!domain) return [] + if (!domain) return []; const domains = this._getFeatureSettings()?.[featureKeyName] || []; return domains.filter((rule) => { if (Array.isArray(rule.domain)) { return rule.domain.some((domainRule) => { - return matchHostname(domain, domainRule) - }) + return matchHostname(domain, domainRule); + }); } - return matchHostname(domain, rule.domain) - }) + return matchHostname(domain, rule.domain); + }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function - init (args) { - } + init(args) {} - callInit (args) { + callInit(args) { const mark = this.monitor.mark(this.name + 'CallInit'); this.#args = args; this.platform = args.platform; @@ -4059,9 +4070,7 @@ this.measure(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function - load (args) { - } + load(args) {} /** * This is a wrapper around `this.messaging.notify` that applies the @@ -4071,7 +4080,7 @@ * * @type {import("@duckduckgo/messaging").Messaging['notify']} */ - notify (...args) { + notify(...args) { const [name, params] = args; this.messaging.notify(name, params); } @@ -4084,9 +4093,9 @@ * * @type {import("@duckduckgo/messaging").Messaging['request']} */ - request (...args) { + request(...args) { const [name, params] = args; - return this.messaging.request(name, params) + return this.messaging.request(name, params); } /** @@ -4097,15 +4106,15 @@ * * @type {import("@duckduckgo/messaging").Messaging['subscribe']} */ - subscribe (...args) { + subscribe(...args) { const [name, cb] = args; - return this.messaging.subscribe(name, cb) + return this.messaging.subscribe(name, cb); } /** * @param {import('./content-scope-features.js').LoadArgs} args */ - callLoad (args) { + callLoad(args) { const mark = this.monitor.mark(this.name + 'CallLoad'); this.#args = args; this.platform = args.platform; @@ -4122,24 +4131,22 @@ mark.end(); } - measure () { + measure() { if (this.#args?.debug) { this.monitor.measureAll(); } } - // eslint-disable-next-line @typescript-eslint/no-empty-function - update () { - } + update() {} /** * Register a flag that will be added to page breakage reports */ - addDebugFlag () { - if (this.#isDebugFlagSet) return + addDebugFlag() { + if (this.#isDebugFlagSet) return; this.#isDebugFlagSet = true; this.messaging?.notify('addDebugFlag', { - flag: this.name + flag: this.name, }); } @@ -4150,7 +4157,7 @@ * @param {string} propertyName * @param {import('./wrapper-utils').StrictPropertyDescriptor} descriptor - requires all descriptor options to be defined because we can't validate correctness based on TS types */ - defineProperty (object, propertyName, descriptor) { + defineProperty(object, propertyName, descriptor) { // make sure to send a debug flag when the property is used // NOTE: properties passing data in `value` would not be caught by this ['value', 'get', 'set'].forEach((k) => { @@ -4158,16 +4165,16 @@ if (typeof descriptorProp === 'function') { const addDebugFlag = this.addDebugFlag.bind(this); const wrapper = new Proxy$1(descriptorProp, { - apply (target, thisArg, argumentsList) { + apply(target, thisArg, argumentsList) { addDebugFlag(); - return Reflect$1.apply(descriptorProp, thisArg, argumentsList) - } + return Reflect$1.apply(descriptorProp, thisArg, argumentsList); + }, }); descriptor[k] = wrapToString(wrapper, descriptorProp); } }); - return defineProperty(object, propertyName, descriptor) + return defineProperty(object, propertyName, descriptor); } /** @@ -4177,8 +4184,8 @@ * @param {Partial} descriptor * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ - wrapProperty (object, propertyName, descriptor) { - return wrapProperty(object, propertyName, descriptor, this.defineProperty.bind(this)) + wrapProperty(object, propertyName, descriptor) { + return wrapProperty(object, propertyName, descriptor, this.defineProperty.bind(this)); } /** @@ -4188,8 +4195,8 @@ * @param {(originalFn, ...args) => any } wrapperFn - wrapper function receives the original function as the first argument * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ - wrapMethod (object, propertyName, wrapperFn) { - return wrapMethod(object, propertyName, wrapperFn, this.defineProperty.bind(this)) + wrapMethod(object, propertyName, wrapperFn) { + return wrapMethod(object, propertyName, wrapperFn, this.defineProperty.bind(this)); } /** @@ -4198,12 +4205,8 @@ * @param {typeof globalThis[StandardInterfaceName]} ImplClass - the class to use as the shim implementation * @param {import('./wrapper-utils').DefineInterfaceOptions} options */ - shimInterface ( - interfaceName, - ImplClass, - options - ) { - return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this)) + shimInterface(interfaceName, ImplClass, options) { + return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this)); } /** @@ -4217,18 +4220,18 @@ * @param {Base[K]} implInstance - instance to use as the shim (e.g. new MyMediaSession()) * @param {boolean} [readOnly] - whether the property should be read-only (default: false) */ - shimProperty (instanceHost, instanceProp, implInstance, readOnly = false) { - return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this)) + shimProperty(instanceHost, instanceProp, implInstance, readOnly = false) { + return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this)); } } class FingerprintingAudio extends ContentFeature { - init (args) { + init(args) { const { sessionKey, site } = args; const domainKey = site.domain; // In place modify array data to remove fingerprinting - function transformArrayData (channelData, domainKey, sessionKey, thisArg) { + function transformArrayData(channelData, domainKey, sessionKey, thisArg) { let { audioKey } = getCachedResponse(thisArg, args); if (!audioKey) { let cdSum = 0; @@ -4237,7 +4240,7 @@ } // If the buffer is blank, skip adding data if (cdSum === 0) { - return + return; } audioKey = getDataKeySync(sessionKey, domainKey, cdSum); setCache(thisArg, args, audioKey); @@ -4254,77 +4257,78 @@ } const copyFromChannelProxy = new DDGProxy(this, AudioBuffer.prototype, 'copyFromChannel', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { const [source, channelNumber, startInChannel] = args; // This is implemented in a different way to canvas purely because calling the function copied the original value, which is not ideal - if (// If channelNumber is longer than arrayBuffer number of channels then call the default method to throw + if ( + // If channelNumber is longer than arrayBuffer number of channels then call the default method to throw // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' channelNumber > thisArg.numberOfChannels || // If startInChannel is longer than the arrayBuffer length then call the default method to throw // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' - startInChannel > thisArg.length) { + startInChannel > thisArg.length + ) { // The normal return value - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } try { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' // Call the protected getChannelData we implement, slice from the startInChannel value and assign to the source array - thisArg.getChannelData(channelNumber).slice(startInChannel).forEach((val, index) => { - source[index] = val; - }); + thisArg + .getChannelData(channelNumber) + .slice(startInChannel) + .forEach((val, index) => { + source[index] = val; + }); } catch { - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } - } + }, }); copyFromChannelProxy.overload(); const cacheExpiry = 60; const cacheData = new WeakMap(); - function getCachedResponse (thisArg, args) { + function getCachedResponse(thisArg, args) { const data = cacheData.get(thisArg); const timeNow = Date.now(); - if (data && - data.args === JSON.stringify(args) && - data.expires > timeNow) { + if (data && data.args === JSON.stringify(args) && data.expires > timeNow) { data.expires = timeNow + cacheExpiry; cacheData.set(thisArg, data); - return data + return data; } - return { audioKey: null } + return { audioKey: null }; } - function setCache (thisArg, args, audioKey) { + function setCache(thisArg, args, audioKey) { cacheData.set(thisArg, { args: JSON.stringify(args), expires: Date.now() + cacheExpiry, audioKey }); } const getChannelDataProxy = new DDGProxy(this, AudioBuffer.prototype, 'getChannelData', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // The normal return value const channelData = DDGReflect.apply(target, thisArg, args); // Anything we do here should be caught and ignored silently try { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f transformArrayData(channelData, domainKey, sessionKey, thisArg, args); - } catch { - } - return channelData - } + } catch {} + return channelData; + }, }); getChannelDataProxy.overload(); const audioMethods = ['getByteTimeDomainData', 'getFloatTimeDomainData', 'getByteFrequencyData', 'getFloatFrequencyData']; for (const methodName of audioMethods) { const proxy = new DDGProxy(this, AnalyserNode.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { DDGReflect.apply(target, thisArg, args); // Anything we do here should be caught and ignored silently try { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f transformArrayData(args[0], domainKey, sessionKey, thisArg, args); - } catch { - } - } + } catch {} + }, }); proxy.overload(); } @@ -4337,7 +4341,7 @@ * as well as prevent any script from listening to events. */ class FingerprintingBattery extends ContentFeature { - init () { + init() { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f if (globalThis.navigator.getBattery) { const BatteryManager = globalThis.BatteryManager; @@ -4346,7 +4350,7 @@ charging: true, chargingTime: 0, dischargingTime: Infinity, - level: 1 + level: 1, }; const eventProperties = ['onchargingchange', 'onchargingtimechange', 'ondischargingtimechange', 'onlevelchange']; @@ -4356,704 +4360,708 @@ enumerable: true, configurable: true, get: () => { - return val - } + return val; + }, }); - } catch (e) { } + } catch (e) {} } for (const eventProp of eventProperties) { try { this.defineProperty(BatteryManager.prototype, eventProp, { enumerable: true, configurable: true, - set: x => x, // noop + set: (x) => x, // noop get: () => { - return null - } + return null; + }, }); - } catch (e) { } + } catch (e) {} } } } } - var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; - function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var alea$1 = {exports: {}}; - alea$1.exports; - - (function (module) { - // A port of an algorithm by Johannes Baagøe , 2010 - // http://baagoe.com/en/RandomMusings/javascript/ - // https://github.com/nquinlan/better-random-numbers-for-javascript-mirror - // Original work is under MIT license - - - // Copyright (C) 2010 by Johannes Baagøe - // - // Permission is hereby granted, free of charge, to any person obtaining a copy - // of this software and associated documentation files (the "Software"), to deal - // in the Software without restriction, including without limitation the rights - // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - // copies of the Software, and to permit persons to whom the Software is - // furnished to do so, subject to the following conditions: - // - // The above copyright notice and this permission notice shall be included in - // all copies or substantial portions of the Software. - // - // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - // THE SOFTWARE. - - - - (function(global, module, define) { - - function Alea(seed) { - var me = this, mash = Mash(); - - me.next = function() { - var t = 2091639 * me.s0 + me.c * 2.3283064365386963e-10; // 2^-32 - me.s0 = me.s1; - me.s1 = me.s2; - return me.s2 = t - (me.c = t | 0); - }; - - // Apply the seeding algorithm from Baagoe. - me.c = 1; - me.s0 = mash(' '); - me.s1 = mash(' '); - me.s2 = mash(' '); - me.s0 -= mash(seed); - if (me.s0 < 0) { me.s0 += 1; } - me.s1 -= mash(seed); - if (me.s1 < 0) { me.s1 += 1; } - me.s2 -= mash(seed); - if (me.s2 < 0) { me.s2 += 1; } - mash = null; - } - - function copy(f, t) { - t.c = f.c; - t.s0 = f.s0; - t.s1 = f.s1; - t.s2 = f.s2; - return t; - } - - function impl(seed, opts) { - var xg = new Alea(seed), - state = opts && opts.state, - prng = xg.next; - prng.int32 = function() { return (xg.next() * 0x100000000) | 0; }; - prng.double = function() { - return prng() + (prng() * 0x200000 | 0) * 1.1102230246251565e-16; // 2^-53 - }; - prng.quick = prng; - if (state) { - if (typeof(state) == 'object') copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - function Mash() { - var n = 0xefc8249d; - - var mash = function(data) { - data = String(data); - for (var i = 0; i < data.length; i++) { - n += data.charCodeAt(i); - var h = 0.02519603282416938 * n; - n = h >>> 0; - h -= n; - h *= n; - n = h >>> 0; - h -= n; - n += h * 0x100000000; // 2^32 - } - return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 - }; - - return mash; - } - - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.alea = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (alea$1)); - - var aleaExports = alea$1.exports; + var alea = alea$1.exports; + + var hasRequiredAlea; + + function requireAlea () { + if (hasRequiredAlea) return alea$1.exports; + hasRequiredAlea = 1; + (function (module) { + // A port of an algorithm by Johannes Baagøe , 2010 + // http://baagoe.com/en/RandomMusings/javascript/ + // https://github.com/nquinlan/better-random-numbers-for-javascript-mirror + // Original work is under MIT license - + + // Copyright (C) 2010 by Johannes Baagøe + // + // Permission is hereby granted, free of charge, to any person obtaining a copy + // of this software and associated documentation files (the "Software"), to deal + // in the Software without restriction, including without limitation the rights + // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + // copies of the Software, and to permit persons to whom the Software is + // furnished to do so, subject to the following conditions: + // + // The above copyright notice and this permission notice shall be included in + // all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + // THE SOFTWARE. + + + + (function(global, module, define) { + + function Alea(seed) { + var me = this, mash = Mash(); + + me.next = function() { + var t = 2091639 * me.s0 + me.c * 2.3283064365386963e-10; // 2^-32 + me.s0 = me.s1; + me.s1 = me.s2; + return me.s2 = t - (me.c = t | 0); + }; + + // Apply the seeding algorithm from Baagoe. + me.c = 1; + me.s0 = mash(' '); + me.s1 = mash(' '); + me.s2 = mash(' '); + me.s0 -= mash(seed); + if (me.s0 < 0) { me.s0 += 1; } + me.s1 -= mash(seed); + if (me.s1 < 0) { me.s1 += 1; } + me.s2 -= mash(seed); + if (me.s2 < 0) { me.s2 += 1; } + mash = null; + } + + function copy(f, t) { + t.c = f.c; + t.s0 = f.s0; + t.s1 = f.s1; + t.s2 = f.s2; + return t; + } + + function impl(seed, opts) { + var xg = new Alea(seed), + state = opts && opts.state, + prng = xg.next; + prng.int32 = function() { return (xg.next() * 0x100000000) | 0; }; + prng.double = function() { + return prng() + (prng() * 0x200000 | 0) * 1.1102230246251565e-16; // 2^-53 + }; + prng.quick = prng; + if (state) { + if (typeof(state) == 'object') copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + function Mash() { + var n = 0xefc8249d; + + var mash = function(data) { + data = String(data); + for (var i = 0; i < data.length; i++) { + n += data.charCodeAt(i); + var h = 0.02519603282416938 * n; + n = h >>> 0; + h -= n; + h *= n; + n = h >>> 0; + h -= n; + n += h * 0x100000000; // 2^32 + } + return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 + }; + + return mash; + } + + + if (module && module.exports) { + module.exports = impl; + } else { + this.alea = impl; + } + + })( + alea, + module); + } (alea$1)); + return alea$1.exports; + } var xor128$1 = {exports: {}}; - xor128$1.exports; - - (function (module) { - // A Javascript implementaion of the "xor128" prng algorithm by - // George Marsaglia. See http://www.jstatsoft.org/v08/i14/paper - - (function(global, module, define) { - - function XorGen(seed) { - var me = this, strseed = ''; - - me.x = 0; - me.y = 0; - me.z = 0; - me.w = 0; - - // Set up generator function. - me.next = function() { - var t = me.x ^ (me.x << 11); - me.x = me.y; - me.y = me.z; - me.z = me.w; - return me.w ^= (me.w >>> 19) ^ t ^ (t >>> 8); - }; - - if (seed === (seed | 0)) { - // Integer seed. - me.x = seed; - } else { - // String seed. - strseed += seed; - } - - // Mix in string seed, then discard an initial batch of 64 values. - for (var k = 0; k < strseed.length + 64; k++) { - me.x ^= strseed.charCodeAt(k) | 0; - me.next(); - } - } - - function copy(f, t) { - t.x = f.x; - t.y = f.y; - t.z = f.z; - t.w = f.w; - return t; - } - - function impl(seed, opts) { - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (typeof(state) == 'object') copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.xor128 = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (xor128$1)); - - var xor128Exports = xor128$1.exports; + var xor128 = xor128$1.exports; + + var hasRequiredXor128; + + function requireXor128 () { + if (hasRequiredXor128) return xor128$1.exports; + hasRequiredXor128 = 1; + (function (module) { + // A Javascript implementaion of the "xor128" prng algorithm by + // George Marsaglia. See http://www.jstatsoft.org/v08/i14/paper + + (function(global, module, define) { + + function XorGen(seed) { + var me = this, strseed = ''; + + me.x = 0; + me.y = 0; + me.z = 0; + me.w = 0; + + // Set up generator function. + me.next = function() { + var t = me.x ^ (me.x << 11); + me.x = me.y; + me.y = me.z; + me.z = me.w; + return me.w ^= (me.w >>> 19) ^ t ^ (t >>> 8); + }; + + if (seed === (seed | 0)) { + // Integer seed. + me.x = seed; + } else { + // String seed. + strseed += seed; + } + + // Mix in string seed, then discard an initial batch of 64 values. + for (var k = 0; k < strseed.length + 64; k++) { + me.x ^= strseed.charCodeAt(k) | 0; + me.next(); + } + } + + function copy(f, t) { + t.x = f.x; + t.y = f.y; + t.z = f.z; + t.w = f.w; + return t; + } + + function impl(seed, opts) { + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (typeof(state) == 'object') copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.xor128 = impl; + } + + })( + xor128, + module); + } (xor128$1)); + return xor128$1.exports; + } var xorwow$1 = {exports: {}}; - xorwow$1.exports; - - (function (module) { - // A Javascript implementaion of the "xorwow" prng algorithm by - // George Marsaglia. See http://www.jstatsoft.org/v08/i14/paper - - (function(global, module, define) { - - function XorGen(seed) { - var me = this, strseed = ''; - - // Set up generator function. - me.next = function() { - var t = (me.x ^ (me.x >>> 2)); - me.x = me.y; me.y = me.z; me.z = me.w; me.w = me.v; - return (me.d = (me.d + 362437 | 0)) + - (me.v = (me.v ^ (me.v << 4)) ^ (t ^ (t << 1))) | 0; - }; - - me.x = 0; - me.y = 0; - me.z = 0; - me.w = 0; - me.v = 0; - - if (seed === (seed | 0)) { - // Integer seed. - me.x = seed; - } else { - // String seed. - strseed += seed; - } - - // Mix in string seed, then discard an initial batch of 64 values. - for (var k = 0; k < strseed.length + 64; k++) { - me.x ^= strseed.charCodeAt(k) | 0; - if (k == strseed.length) { - me.d = me.x << 10 ^ me.x >>> 4; - } - me.next(); - } - } - - function copy(f, t) { - t.x = f.x; - t.y = f.y; - t.z = f.z; - t.w = f.w; - t.v = f.v; - t.d = f.d; - return t; - } - - function impl(seed, opts) { - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (typeof(state) == 'object') copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.xorwow = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (xorwow$1)); - - var xorwowExports = xorwow$1.exports; + var xorwow = xorwow$1.exports; + + var hasRequiredXorwow; + + function requireXorwow () { + if (hasRequiredXorwow) return xorwow$1.exports; + hasRequiredXorwow = 1; + (function (module) { + // A Javascript implementaion of the "xorwow" prng algorithm by + // George Marsaglia. See http://www.jstatsoft.org/v08/i14/paper + + (function(global, module, define) { + + function XorGen(seed) { + var me = this, strseed = ''; + + // Set up generator function. + me.next = function() { + var t = (me.x ^ (me.x >>> 2)); + me.x = me.y; me.y = me.z; me.z = me.w; me.w = me.v; + return (me.d = (me.d + 362437 | 0)) + + (me.v = (me.v ^ (me.v << 4)) ^ (t ^ (t << 1))) | 0; + }; + + me.x = 0; + me.y = 0; + me.z = 0; + me.w = 0; + me.v = 0; + + if (seed === (seed | 0)) { + // Integer seed. + me.x = seed; + } else { + // String seed. + strseed += seed; + } + + // Mix in string seed, then discard an initial batch of 64 values. + for (var k = 0; k < strseed.length + 64; k++) { + me.x ^= strseed.charCodeAt(k) | 0; + if (k == strseed.length) { + me.d = me.x << 10 ^ me.x >>> 4; + } + me.next(); + } + } + + function copy(f, t) { + t.x = f.x; + t.y = f.y; + t.z = f.z; + t.w = f.w; + t.v = f.v; + t.d = f.d; + return t; + } + + function impl(seed, opts) { + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (typeof(state) == 'object') copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.xorwow = impl; + } + + })( + xorwow, + module); + } (xorwow$1)); + return xorwow$1.exports; + } var xorshift7$1 = {exports: {}}; - xorshift7$1.exports; - - (function (module) { - // A Javascript implementaion of the "xorshift7" algorithm by - // François Panneton and Pierre L'ecuyer: - // "On the Xorgshift Random Number Generators" - // http://saluc.engr.uconn.edu/refs/crypto/rng/panneton05onthexorshift.pdf - - (function(global, module, define) { - - function XorGen(seed) { - var me = this; - - // Set up generator function. - me.next = function() { - // Update xor generator. - var X = me.x, i = me.i, t, v; - t = X[i]; t ^= (t >>> 7); v = t ^ (t << 24); - t = X[(i + 1) & 7]; v ^= t ^ (t >>> 10); - t = X[(i + 3) & 7]; v ^= t ^ (t >>> 3); - t = X[(i + 4) & 7]; v ^= t ^ (t << 7); - t = X[(i + 7) & 7]; t = t ^ (t << 13); v ^= t ^ (t << 9); - X[i] = v; - me.i = (i + 1) & 7; - return v; - }; - - function init(me, seed) { - var j, X = []; - - if (seed === (seed | 0)) { - // Seed state array using a 32-bit integer. - X[0] = seed; - } else { - // Seed state using a string. - seed = '' + seed; - for (j = 0; j < seed.length; ++j) { - X[j & 7] = (X[j & 7] << 15) ^ - (seed.charCodeAt(j) + X[(j + 1) & 7] << 13); - } - } - // Enforce an array length of 8, not all zeroes. - while (X.length < 8) X.push(0); - for (j = 0; j < 8 && X[j] === 0; ++j); - if (j == 8) X[7] = -1; else X[j]; - - me.x = X; - me.i = 0; - - // Discard an initial 256 values. - for (j = 256; j > 0; --j) { - me.next(); - } - } - - init(me, seed); - } - - function copy(f, t) { - t.x = f.x.slice(); - t.i = f.i; - return t; - } - - function impl(seed, opts) { - if (seed == null) seed = +(new Date); - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (state.x) copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.xorshift7 = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (xorshift7$1)); - - var xorshift7Exports = xorshift7$1.exports; + var xorshift7 = xorshift7$1.exports; + + var hasRequiredXorshift7; + + function requireXorshift7 () { + if (hasRequiredXorshift7) return xorshift7$1.exports; + hasRequiredXorshift7 = 1; + (function (module) { + // A Javascript implementaion of the "xorshift7" algorithm by + // François Panneton and Pierre L'ecuyer: + // "On the Xorgshift Random Number Generators" + // http://saluc.engr.uconn.edu/refs/crypto/rng/panneton05onthexorshift.pdf + + (function(global, module, define) { + + function XorGen(seed) { + var me = this; + + // Set up generator function. + me.next = function() { + // Update xor generator. + var X = me.x, i = me.i, t, v; + t = X[i]; t ^= (t >>> 7); v = t ^ (t << 24); + t = X[(i + 1) & 7]; v ^= t ^ (t >>> 10); + t = X[(i + 3) & 7]; v ^= t ^ (t >>> 3); + t = X[(i + 4) & 7]; v ^= t ^ (t << 7); + t = X[(i + 7) & 7]; t = t ^ (t << 13); v ^= t ^ (t << 9); + X[i] = v; + me.i = (i + 1) & 7; + return v; + }; + + function init(me, seed) { + var j, X = []; + + if (seed === (seed | 0)) { + // Seed state array using a 32-bit integer. + X[0] = seed; + } else { + // Seed state using a string. + seed = '' + seed; + for (j = 0; j < seed.length; ++j) { + X[j & 7] = (X[j & 7] << 15) ^ + (seed.charCodeAt(j) + X[(j + 1) & 7] << 13); + } + } + // Enforce an array length of 8, not all zeroes. + while (X.length < 8) X.push(0); + for (j = 0; j < 8 && X[j] === 0; ++j); + if (j == 8) X[7] = -1; else X[j]; + + me.x = X; + me.i = 0; + + // Discard an initial 256 values. + for (j = 256; j > 0; --j) { + me.next(); + } + } + + init(me, seed); + } + + function copy(f, t) { + t.x = f.x.slice(); + t.i = f.i; + return t; + } + + function impl(seed, opts) { + if (seed == null) seed = +(new Date); + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (state.x) copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.xorshift7 = impl; + } + + })( + xorshift7, + module); + } (xorshift7$1)); + return xorshift7$1.exports; + } var xor4096$1 = {exports: {}}; - xor4096$1.exports; - - (function (module) { - // A Javascript implementaion of Richard Brent's Xorgens xor4096 algorithm. - // - // This fast non-cryptographic random number generator is designed for - // use in Monte-Carlo algorithms. It combines a long-period xorshift - // generator with a Weyl generator, and it passes all common batteries - // of stasticial tests for randomness while consuming only a few nanoseconds - // for each prng generated. For background on the generator, see Brent's - // paper: "Some long-period random number generators using shifts and xors." - // http://arxiv.org/pdf/1004.3115v1.pdf - // - // Usage: - // - // var xor4096 = require('xor4096'); - // random = xor4096(1); // Seed with int32 or string. - // assert.equal(random(), 0.1520436450538547); // (0, 1) range, 53 bits. - // assert.equal(random.int32(), 1806534897); // signed int32, 32 bits. - // - // For nonzero numeric keys, this impelementation provides a sequence - // identical to that by Brent's xorgens 3 implementaion in C. This - // implementation also provides for initalizing the generator with - // string seeds, or for saving and restoring the state of the generator. - // - // On Chrome, this prng benchmarks about 2.1 times slower than - // Javascript's built-in Math.random(). - - (function(global, module, define) { - - function XorGen(seed) { - var me = this; - - // Set up generator function. - me.next = function() { - var w = me.w, - X = me.X, i = me.i, t, v; - // Update Weyl generator. - me.w = w = (w + 0x61c88647) | 0; - // Update xor generator. - v = X[(i + 34) & 127]; - t = X[i = ((i + 1) & 127)]; - v ^= v << 13; - t ^= t << 17; - v ^= v >>> 15; - t ^= t >>> 12; - // Update Xor generator array state. - v = X[i] = v ^ t; - me.i = i; - // Result is the combination. - return (v + (w ^ (w >>> 16))) | 0; - }; - - function init(me, seed) { - var t, v, i, j, w, X = [], limit = 128; - if (seed === (seed | 0)) { - // Numeric seeds initialize v, which is used to generates X. - v = seed; - seed = null; - } else { - // String seeds are mixed into v and X one character at a time. - seed = seed + '\0'; - v = 0; - limit = Math.max(limit, seed.length); - } - // Initialize circular array and weyl value. - for (i = 0, j = -32; j < limit; ++j) { - // Put the unicode characters into the array, and shuffle them. - if (seed) v ^= seed.charCodeAt((j + 32) % seed.length); - // After 32 shuffles, take v as the starting w value. - if (j === 0) w = v; - v ^= v << 10; - v ^= v >>> 15; - v ^= v << 4; - v ^= v >>> 13; - if (j >= 0) { - w = (w + 0x61c88647) | 0; // Weyl. - t = (X[j & 127] ^= (v + w)); // Combine xor and weyl to init array. - i = (0 == t) ? i + 1 : 0; // Count zeroes. - } - } - // We have detected all zeroes; make the key nonzero. - if (i >= 128) { - X[(seed && seed.length || 0) & 127] = -1; - } - // Run the generator 512 times to further mix the state before using it. - // Factoring this as a function slows the main generator, so it is just - // unrolled here. The weyl generator is not advanced while warming up. - i = 127; - for (j = 4 * 128; j > 0; --j) { - v = X[(i + 34) & 127]; - t = X[i = ((i + 1) & 127)]; - v ^= v << 13; - t ^= t << 17; - v ^= v >>> 15; - t ^= t >>> 12; - X[i] = v ^ t; - } - // Storing state as object members is faster than using closure variables. - me.w = w; - me.X = X; - me.i = i; - } - - init(me, seed); - } - - function copy(f, t) { - t.i = f.i; - t.w = f.w; - t.X = f.X.slice(); - return t; - } - function impl(seed, opts) { - if (seed == null) seed = +(new Date); - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (state.X) copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.xor4096 = impl; - } - - })( - commonjsGlobal, // window object or global - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (xor4096$1)); - - var xor4096Exports = xor4096$1.exports; + var xor4096 = xor4096$1.exports; + + var hasRequiredXor4096; + + function requireXor4096 () { + if (hasRequiredXor4096) return xor4096$1.exports; + hasRequiredXor4096 = 1; + (function (module) { + // A Javascript implementaion of Richard Brent's Xorgens xor4096 algorithm. + // + // This fast non-cryptographic random number generator is designed for + // use in Monte-Carlo algorithms. It combines a long-period xorshift + // generator with a Weyl generator, and it passes all common batteries + // of stasticial tests for randomness while consuming only a few nanoseconds + // for each prng generated. For background on the generator, see Brent's + // paper: "Some long-period random number generators using shifts and xors." + // http://arxiv.org/pdf/1004.3115v1.pdf + // + // Usage: + // + // var xor4096 = require('xor4096'); + // random = xor4096(1); // Seed with int32 or string. + // assert.equal(random(), 0.1520436450538547); // (0, 1) range, 53 bits. + // assert.equal(random.int32(), 1806534897); // signed int32, 32 bits. + // + // For nonzero numeric keys, this impelementation provides a sequence + // identical to that by Brent's xorgens 3 implementaion in C. This + // implementation also provides for initalizing the generator with + // string seeds, or for saving and restoring the state of the generator. + // + // On Chrome, this prng benchmarks about 2.1 times slower than + // Javascript's built-in Math.random(). + + (function(global, module, define) { + + function XorGen(seed) { + var me = this; + + // Set up generator function. + me.next = function() { + var w = me.w, + X = me.X, i = me.i, t, v; + // Update Weyl generator. + me.w = w = (w + 0x61c88647) | 0; + // Update xor generator. + v = X[(i + 34) & 127]; + t = X[i = ((i + 1) & 127)]; + v ^= v << 13; + t ^= t << 17; + v ^= v >>> 15; + t ^= t >>> 12; + // Update Xor generator array state. + v = X[i] = v ^ t; + me.i = i; + // Result is the combination. + return (v + (w ^ (w >>> 16))) | 0; + }; + + function init(me, seed) { + var t, v, i, j, w, X = [], limit = 128; + if (seed === (seed | 0)) { + // Numeric seeds initialize v, which is used to generates X. + v = seed; + seed = null; + } else { + // String seeds are mixed into v and X one character at a time. + seed = seed + '\0'; + v = 0; + limit = Math.max(limit, seed.length); + } + // Initialize circular array and weyl value. + for (i = 0, j = -32; j < limit; ++j) { + // Put the unicode characters into the array, and shuffle them. + if (seed) v ^= seed.charCodeAt((j + 32) % seed.length); + // After 32 shuffles, take v as the starting w value. + if (j === 0) w = v; + v ^= v << 10; + v ^= v >>> 15; + v ^= v << 4; + v ^= v >>> 13; + if (j >= 0) { + w = (w + 0x61c88647) | 0; // Weyl. + t = (X[j & 127] ^= (v + w)); // Combine xor and weyl to init array. + i = (0 == t) ? i + 1 : 0; // Count zeroes. + } + } + // We have detected all zeroes; make the key nonzero. + if (i >= 128) { + X[(seed && seed.length || 0) & 127] = -1; + } + // Run the generator 512 times to further mix the state before using it. + // Factoring this as a function slows the main generator, so it is just + // unrolled here. The weyl generator is not advanced while warming up. + i = 127; + for (j = 4 * 128; j > 0; --j) { + v = X[(i + 34) & 127]; + t = X[i = ((i + 1) & 127)]; + v ^= v << 13; + t ^= t << 17; + v ^= v >>> 15; + t ^= t >>> 12; + X[i] = v ^ t; + } + // Storing state as object members is faster than using closure variables. + me.w = w; + me.X = X; + me.i = i; + } + + init(me, seed); + } + + function copy(f, t) { + t.i = f.i; + t.w = f.w; + t.X = f.X.slice(); + return t; + } + function impl(seed, opts) { + if (seed == null) seed = +(new Date); + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (state.X) copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.xor4096 = impl; + } + + })( + xor4096, // window object or global + module); + } (xor4096$1)); + return xor4096$1.exports; + } var tychei$1 = {exports: {}}; - tychei$1.exports; - - (function (module) { - // A Javascript implementaion of the "Tyche-i" prng algorithm by - // Samuel Neves and Filipe Araujo. - // See https://eden.dei.uc.pt/~sneves/pubs/2011-snfa2.pdf - - (function(global, module, define) { - - function XorGen(seed) { - var me = this, strseed = ''; - - // Set up generator function. - me.next = function() { - var b = me.b, c = me.c, d = me.d, a = me.a; - b = (b << 25) ^ (b >>> 7) ^ c; - c = (c - d) | 0; - d = (d << 24) ^ (d >>> 8) ^ a; - a = (a - b) | 0; - me.b = b = (b << 20) ^ (b >>> 12) ^ c; - me.c = c = (c - d) | 0; - me.d = (d << 16) ^ (c >>> 16) ^ a; - return me.a = (a - b) | 0; - }; - - /* The following is non-inverted tyche, which has better internal - * bit diffusion, but which is about 25% slower than tyche-i in JS. - me.next = function() { - var a = me.a, b = me.b, c = me.c, d = me.d; - a = (me.a + me.b | 0) >>> 0; - d = me.d ^ a; d = d << 16 ^ d >>> 16; - c = me.c + d | 0; - b = me.b ^ c; b = b << 12 ^ d >>> 20; - me.a = a = a + b | 0; - d = d ^ a; me.d = d = d << 8 ^ d >>> 24; - me.c = c = c + d | 0; - b = b ^ c; - return me.b = (b << 7 ^ b >>> 25); - } - */ - - me.a = 0; - me.b = 0; - me.c = 2654435769 | 0; - me.d = 1367130551; - - if (seed === Math.floor(seed)) { - // Integer seed. - me.a = (seed / 0x100000000) | 0; - me.b = seed | 0; - } else { - // String seed. - strseed += seed; - } - - // Mix in string seed, then discard an initial batch of 64 values. - for (var k = 0; k < strseed.length + 20; k++) { - me.b ^= strseed.charCodeAt(k) | 0; - me.next(); - } - } - - function copy(f, t) { - t.a = f.a; - t.b = f.b; - t.c = f.c; - t.d = f.d; - return t; - } - function impl(seed, opts) { - var xg = new XorGen(seed), - state = opts && opts.state, - prng = function() { return (xg.next() >>> 0) / 0x100000000; }; - prng.double = function() { - do { - var top = xg.next() >>> 11, - bot = (xg.next() >>> 0) / 0x100000000, - result = (top + bot) / (1 << 21); - } while (result === 0); - return result; - }; - prng.int32 = xg.next; - prng.quick = prng; - if (state) { - if (typeof(state) == 'object') copy(state, xg); - prng.state = function() { return copy(xg, {}); }; - } - return prng; - } - - if (module && module.exports) { - module.exports = impl; - } else if (define && define.amd) { - define(function() { return impl; }); - } else { - this.tychei = impl; - } - - })( - commonjsGlobal, - module, // present in node.js - (typeof undefined) == 'function' // present with an AMD loader - ); - } (tychei$1)); - - var tycheiExports = tychei$1.exports; - - var seedrandom$1 = {exports: {}}; + var tychei = tychei$1.exports; + + var hasRequiredTychei; + + function requireTychei () { + if (hasRequiredTychei) return tychei$1.exports; + hasRequiredTychei = 1; + (function (module) { + // A Javascript implementaion of the "Tyche-i" prng algorithm by + // Samuel Neves and Filipe Araujo. + // See https://eden.dei.uc.pt/~sneves/pubs/2011-snfa2.pdf + + (function(global, module, define) { + + function XorGen(seed) { + var me = this, strseed = ''; + + // Set up generator function. + me.next = function() { + var b = me.b, c = me.c, d = me.d, a = me.a; + b = (b << 25) ^ (b >>> 7) ^ c; + c = (c - d) | 0; + d = (d << 24) ^ (d >>> 8) ^ a; + a = (a - b) | 0; + me.b = b = (b << 20) ^ (b >>> 12) ^ c; + me.c = c = (c - d) | 0; + me.d = (d << 16) ^ (c >>> 16) ^ a; + return me.a = (a - b) | 0; + }; + + /* The following is non-inverted tyche, which has better internal + * bit diffusion, but which is about 25% slower than tyche-i in JS. + me.next = function() { + var a = me.a, b = me.b, c = me.c, d = me.d; + a = (me.a + me.b | 0) >>> 0; + d = me.d ^ a; d = d << 16 ^ d >>> 16; + c = me.c + d | 0; + b = me.b ^ c; b = b << 12 ^ d >>> 20; + me.a = a = a + b | 0; + d = d ^ a; me.d = d = d << 8 ^ d >>> 24; + me.c = c = c + d | 0; + b = b ^ c; + return me.b = (b << 7 ^ b >>> 25); + } + */ + + me.a = 0; + me.b = 0; + me.c = 2654435769 | 0; + me.d = 1367130551; + + if (seed === Math.floor(seed)) { + // Integer seed. + me.a = (seed / 0x100000000) | 0; + me.b = seed | 0; + } else { + // String seed. + strseed += seed; + } + + // Mix in string seed, then discard an initial batch of 64 values. + for (var k = 0; k < strseed.length + 20; k++) { + me.b ^= strseed.charCodeAt(k) | 0; + me.next(); + } + } + + function copy(f, t) { + t.a = f.a; + t.b = f.b; + t.c = f.c; + t.d = f.d; + return t; + } + function impl(seed, opts) { + var xg = new XorGen(seed), + state = opts && opts.state, + prng = function() { return (xg.next() >>> 0) / 0x100000000; }; + prng.double = function() { + do { + var top = xg.next() >>> 11, + bot = (xg.next() >>> 0) / 0x100000000, + result = (top + bot) / (1 << 21); + } while (result === 0); + return result; + }; + prng.int32 = xg.next; + prng.quick = prng; + if (state) { + if (typeof(state) == 'object') copy(state, xg); + prng.state = function() { return copy(xg, {}); }; + } + return prng; + } + + if (module && module.exports) { + module.exports = impl; + } else { + this.tychei = impl; + } + + })( + tychei, + module); + } (tychei$1)); + return tychei$1.exports; + } + + var seedrandom$2 = {exports: {}}; /* Copyright 2019 David Bau. @@ -5078,300 +5086,315 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - - (function (module) { - (function (global, pool, math) { - // - // The following constants are related to IEEE 754 limits. - // - - var width = 256, // each RC4 output is 0 <= x < 256 - chunks = 6, // at least six RC4 outputs for each double - digits = 52, // there are 52 significant digits in a double - rngname = 'random', // rngname: name for Math.random and Math.seedrandom - startdenom = math.pow(width, chunks), - significance = math.pow(2, digits), - overflow = significance * 2, - mask = width - 1, - nodecrypto; // node.js crypto module, initialized at the bottom. - - // - // seedrandom() - // This is the seedrandom function described above. - // - function seedrandom(seed, options, callback) { - var key = []; - options = (options == true) ? { entropy: true } : (options || {}); - - // Flatten the seed string or build one from local entropy if needed. - var shortseed = mixkey(flatten( - options.entropy ? [seed, tostring(pool)] : - (seed == null) ? autoseed() : seed, 3), key); - - // Use the seed to initialize an ARC4 generator. - var arc4 = new ARC4(key); - - // This function returns a random double in [0, 1) that contains - // randomness in every bit of the mantissa of the IEEE 754 value. - var prng = function() { - var n = arc4.g(chunks), // Start with a numerator n < 2 ^ 48 - d = startdenom, // and denominator d = 2 ^ 48. - x = 0; // and no 'extra last byte'. - while (n < significance) { // Fill up all significant digits by - n = (n + x) * width; // shifting numerator and - d *= width; // denominator and generating a - x = arc4.g(1); // new least-significant-byte. - } - while (n >= overflow) { // To avoid rounding up, before adding - n /= 2; // last byte, shift everything - d /= 2; // right using integer math until - x >>>= 1; // we have exactly the desired bits. - } - return (n + x) / d; // Form the number within [0, 1). - }; - - prng.int32 = function() { return arc4.g(4) | 0; }; - prng.quick = function() { return arc4.g(4) / 0x100000000; }; - prng.double = prng; - - // Mix the randomness into accumulated entropy. - mixkey(tostring(arc4.S), pool); - - // Calling convention: what to return as a function of prng, seed, is_math. - return (options.pass || callback || - function(prng, seed, is_math_call, state) { - if (state) { - // Load the arc4 state from the given state if it has an S array. - if (state.S) { copy(state, arc4); } - // Only provide the .state method if requested via options.state. - prng.state = function() { return copy(arc4, {}); }; - } - - // If called as a method of Math (Math.seedrandom()), mutate - // Math.random because that is how seedrandom.js has worked since v1.0. - if (is_math_call) { math[rngname] = prng; return seed; } - - // Otherwise, it is a newer calling convention, so return the - // prng directly. - else return prng; - })( - prng, - shortseed, - 'global' in options ? options.global : (this == math), - options.state); - } - - // - // ARC4 - // - // An ARC4 implementation. The constructor takes a key in the form of - // an array of at most (width) integers that should be 0 <= x < (width). - // - // The g(count) method returns a pseudorandom integer that concatenates - // the next (count) outputs from ARC4. Its return value is a number x - // that is in the range 0 <= x < (width ^ count). - // - function ARC4(key) { - var t, keylen = key.length, - me = this, i = 0, j = me.i = me.j = 0, s = me.S = []; - - // The empty key [] is treated as [0]. - if (!keylen) { key = [keylen++]; } - - // Set up S using the standard key scheduling algorithm. - while (i < width) { - s[i] = i++; - } - for (i = 0; i < width; i++) { - s[i] = s[j = mask & (j + key[i % keylen] + (t = s[i]))]; - s[j] = t; - } - - // The "g" method returns the next (count) outputs as one number. - (me.g = function(count) { - // Using instance members instead of closure state nearly doubles speed. - var t, r = 0, - i = me.i, j = me.j, s = me.S; - while (count--) { - t = s[i = mask & (i + 1)]; - r = r * width + s[mask & ((s[i] = s[j = mask & (j + t)]) + (s[j] = t))]; - } - me.i = i; me.j = j; - return r; - // For robust unpredictability, the function call below automatically - // discards an initial batch of values. This is called RC4-drop[256]. - // See http://google.com/search?q=rsa+fluhrer+response&btnI - })(width); - } - - // - // copy() - // Copies internal state of ARC4 to or from a plain object. + var seedrandom$1 = seedrandom$2.exports; + + var hasRequiredSeedrandom$1; + + function requireSeedrandom$1 () { + if (hasRequiredSeedrandom$1) return seedrandom$2.exports; + hasRequiredSeedrandom$1 = 1; + (function (module) { + (function (global, pool, math) { + // + // The following constants are related to IEEE 754 limits. + // + + var width = 256, // each RC4 output is 0 <= x < 256 + chunks = 6, // at least six RC4 outputs for each double + digits = 52, // there are 52 significant digits in a double + rngname = 'random', // rngname: name for Math.random and Math.seedrandom + startdenom = math.pow(width, chunks), + significance = math.pow(2, digits), + overflow = significance * 2, + mask = width - 1, + nodecrypto; // node.js crypto module, initialized at the bottom. + + // + // seedrandom() + // This is the seedrandom function described above. + // + function seedrandom(seed, options, callback) { + var key = []; + options = (options == true) ? { entropy: true } : (options || {}); + + // Flatten the seed string or build one from local entropy if needed. + var shortseed = mixkey(flatten( + options.entropy ? [seed, tostring(pool)] : + (seed == null) ? autoseed() : seed, 3), key); + + // Use the seed to initialize an ARC4 generator. + var arc4 = new ARC4(key); + + // This function returns a random double in [0, 1) that contains + // randomness in every bit of the mantissa of the IEEE 754 value. + var prng = function() { + var n = arc4.g(chunks), // Start with a numerator n < 2 ^ 48 + d = startdenom, // and denominator d = 2 ^ 48. + x = 0; // and no 'extra last byte'. + while (n < significance) { // Fill up all significant digits by + n = (n + x) * width; // shifting numerator and + d *= width; // denominator and generating a + x = arc4.g(1); // new least-significant-byte. + } + while (n >= overflow) { // To avoid rounding up, before adding + n /= 2; // last byte, shift everything + d /= 2; // right using integer math until + x >>>= 1; // we have exactly the desired bits. + } + return (n + x) / d; // Form the number within [0, 1). + }; + + prng.int32 = function() { return arc4.g(4) | 0; }; + prng.quick = function() { return arc4.g(4) / 0x100000000; }; + prng.double = prng; + + // Mix the randomness into accumulated entropy. + mixkey(tostring(arc4.S), pool); + + // Calling convention: what to return as a function of prng, seed, is_math. + return (options.pass || callback || + function(prng, seed, is_math_call, state) { + if (state) { + // Load the arc4 state from the given state if it has an S array. + if (state.S) { copy(state, arc4); } + // Only provide the .state method if requested via options.state. + prng.state = function() { return copy(arc4, {}); }; + } + + // If called as a method of Math (Math.seedrandom()), mutate + // Math.random because that is how seedrandom.js has worked since v1.0. + if (is_math_call) { math[rngname] = prng; return seed; } + + // Otherwise, it is a newer calling convention, so return the + // prng directly. + else return prng; + })( + prng, + shortseed, + 'global' in options ? options.global : (this == math), + options.state); + } + + // + // ARC4 + // + // An ARC4 implementation. The constructor takes a key in the form of + // an array of at most (width) integers that should be 0 <= x < (width). + // + // The g(count) method returns a pseudorandom integer that concatenates + // the next (count) outputs from ARC4. Its return value is a number x + // that is in the range 0 <= x < (width ^ count). + // + function ARC4(key) { + var t, keylen = key.length, + me = this, i = 0, j = me.i = me.j = 0, s = me.S = []; + + // The empty key [] is treated as [0]. + if (!keylen) { key = [keylen++]; } + + // Set up S using the standard key scheduling algorithm. + while (i < width) { + s[i] = i++; + } + for (i = 0; i < width; i++) { + s[i] = s[j = mask & (j + key[i % keylen] + (t = s[i]))]; + s[j] = t; + } + + // The "g" method returns the next (count) outputs as one number. + (me.g = function(count) { + // Using instance members instead of closure state nearly doubles speed. + var t, r = 0, + i = me.i, j = me.j, s = me.S; + while (count--) { + t = s[i = mask & (i + 1)]; + r = r * width + s[mask & ((s[i] = s[j = mask & (j + t)]) + (s[j] = t))]; + } + me.i = i; me.j = j; + return r; + // For robust unpredictability, the function call below automatically + // discards an initial batch of values. This is called RC4-drop[256]. + // See http://google.com/search?q=rsa+fluhrer+response&btnI + })(width); + } + + // + // copy() + // Copies internal state of ARC4 to or from a plain object. + // + function copy(f, t) { + t.i = f.i; + t.j = f.j; + t.S = f.S.slice(); + return t; + } + // + // flatten() + // Converts an object tree to nested arrays of strings. + // + function flatten(obj, depth) { + var result = [], typ = (typeof obj), prop; + if (depth && typ == 'object') { + for (prop in obj) { + try { result.push(flatten(obj[prop], depth - 1)); } catch (e) {} + } + } + return (result.length ? result : typ == 'string' ? obj : obj + '\0'); + } + + // + // mixkey() + // Mixes a string seed into a key that is an array of integers, and + // returns a shortened string seed that is equivalent to the result key. + // + function mixkey(seed, key) { + var stringseed = seed + '', smear, j = 0; + while (j < stringseed.length) { + key[mask & j] = + mask & ((smear ^= key[mask & j] * 19) + stringseed.charCodeAt(j++)); + } + return tostring(key); + } + + // + // autoseed() + // Returns an object for autoseeding, using window.crypto and Node crypto + // module if available. + // + function autoseed() { + try { + var out; + if (nodecrypto && (out = nodecrypto.randomBytes)) { + // The use of 'out' to remember randomBytes makes tight minified code. + out = out(width); + } else { + out = new Uint8Array(width); + (global.crypto || global.msCrypto).getRandomValues(out); + } + return tostring(out); + } catch (e) { + var browser = global.navigator, + plugins = browser && browser.plugins; + return [+new Date, global, plugins, global.screen, tostring(pool)]; + } + } + + // + // tostring() + // Converts an array of charcodes to a string + // + function tostring(a) { + return String.fromCharCode.apply(0, a); + } + + // + // When seedrandom.js is loaded, we immediately mix a few bits + // from the built-in RNG into the entropy pool. Because we do + // not want to interfere with deterministic PRNG state later, + // seedrandom will not call math.random on its own again after + // initialization. + // + mixkey(math.random(), pool); + + // + // Nodejs and AMD support: export the implementation as a module using + // either convention. + // + if (module.exports) { + module.exports = seedrandom; + // When in node.js, try using crypto package for autoseeding. + try { + nodecrypto = require('crypto'); + } catch (ex) {} + } else { + // When included as a plain script, set up Math.seedrandom global. + math['seed' + rngname] = seedrandom; + } + + + // End anonymous scope, and pass initial values. + })( + // global: `self` in browsers (including strict mode and web workers), + // otherwise `this` in Node and other environments + (typeof self !== 'undefined') ? self : seedrandom$1, + [], // pool: entropy pool starts empty + Math // math: package containing random, pow, and seedrandom + ); + } (seedrandom$2)); + return seedrandom$2.exports; + } + + var seedrandom; + var hasRequiredSeedrandom; + + function requireSeedrandom () { + if (hasRequiredSeedrandom) return seedrandom; + hasRequiredSeedrandom = 1; + // A library of seedable RNGs implemented in Javascript. // - function copy(f, t) { - t.i = f.i; - t.j = f.j; - t.S = f.S.slice(); - return t; - } - // - // flatten() - // Converts an object tree to nested arrays of strings. - // - function flatten(obj, depth) { - var result = [], typ = (typeof obj), prop; - if (depth && typ == 'object') { - for (prop in obj) { - try { result.push(flatten(obj[prop], depth - 1)); } catch (e) {} - } - } - return (result.length ? result : typ == 'string' ? obj : obj + '\0'); - } - - // - // mixkey() - // Mixes a string seed into a key that is an array of integers, and - // returns a shortened string seed that is equivalent to the result key. - // - function mixkey(seed, key) { - var stringseed = seed + '', smear, j = 0; - while (j < stringseed.length) { - key[mask & j] = - mask & ((smear ^= key[mask & j] * 19) + stringseed.charCodeAt(j++)); - } - return tostring(key); - } - - // - // autoseed() - // Returns an object for autoseeding, using window.crypto and Node crypto - // module if available. - // - function autoseed() { - try { - var out; - if (nodecrypto && (out = nodecrypto.randomBytes)) { - // The use of 'out' to remember randomBytes makes tight minified code. - out = out(width); - } else { - out = new Uint8Array(width); - (global.crypto || global.msCrypto).getRandomValues(out); - } - return tostring(out); - } catch (e) { - var browser = global.navigator, - plugins = browser && browser.plugins; - return [+new Date, global, plugins, global.screen, tostring(pool)]; - } - } - - // - // tostring() - // Converts an array of charcodes to a string - // - function tostring(a) { - return String.fromCharCode.apply(0, a); - } - - // - // When seedrandom.js is loaded, we immediately mix a few bits - // from the built-in RNG into the entropy pool. Because we do - // not want to interfere with deterministic PRNG state later, - // seedrandom will not call math.random on its own again after - // initialization. - // - mixkey(math.random(), pool); - - // - // Nodejs and AMD support: export the implementation as a module using - // either convention. + // Usage: // - if (module.exports) { - module.exports = seedrandom; - // When in node.js, try using crypto package for autoseeding. - try { - nodecrypto = require('crypto'); - } catch (ex) {} - } else { - // When included as a plain script, set up Math.seedrandom global. - math['seed' + rngname] = seedrandom; - } - - - // End anonymous scope, and pass initial values. - })( - // global: `self` in browsers (including strict mode and web workers), - // otherwise `this` in Node and other environments - (typeof self !== 'undefined') ? self : commonjsGlobal, - [], // pool: entropy pool starts empty - Math // math: package containing random, pow, and seedrandom - ); - } (seedrandom$1)); - - var seedrandomExports = seedrandom$1.exports; - - // A library of seedable RNGs implemented in Javascript. - // - // Usage: - // - // var seedrandom = require('seedrandom'); - // var random = seedrandom(1); // or any seed. - // var x = random(); // 0 <= x < 1. Every bit is random. - // var x = random.quick(); // 0 <= x < 1. 32 bits of randomness. - - // alea, a 53-bit multiply-with-carry generator by Johannes Baagøe. - // Period: ~2^116 - // Reported to pass all BigCrush tests. - var alea = aleaExports; - - // xor128, a pure xor-shift generator by George Marsaglia. - // Period: 2^128-1. - // Reported to fail: MatrixRank and LinearComp. - var xor128 = xor128Exports; - - // xorwow, George Marsaglia's 160-bit xor-shift combined plus weyl. - // Period: 2^192-2^32 - // Reported to fail: CollisionOver, SimpPoker, and LinearComp. - var xorwow = xorwowExports; - - // xorshift7, by François Panneton and Pierre L'ecuyer, takes - // a different approach: it adds robustness by allowing more shifts - // than Marsaglia's original three. It is a 7-shift generator - // with 256 bits, that passes BigCrush with no systmatic failures. - // Period 2^256-1. - // No systematic BigCrush failures reported. - var xorshift7 = xorshift7Exports; - - // xor4096, by Richard Brent, is a 4096-bit xor-shift with a - // very long period that also adds a Weyl generator. It also passes - // BigCrush with no systematic failures. Its long period may - // be useful if you have many generators and need to avoid - // collisions. - // Period: 2^4128-2^32. - // No systematic BigCrush failures reported. - var xor4096 = xor4096Exports; - - // Tyche-i, by Samuel Neves and Filipe Araujo, is a bit-shifting random - // number generator derived from ChaCha, a modern stream cipher. - // https://eden.dei.uc.pt/~sneves/pubs/2011-snfa2.pdf - // Period: ~2^127 - // No systematic BigCrush failures reported. - var tychei = tycheiExports; - - // The original ARC4-based prng included in this library. - // Period: ~2^1600 - var sr = seedrandomExports; - - sr.alea = alea; - sr.xor128 = xor128; - sr.xorwow = xorwow; - sr.xorshift7 = xorshift7; - sr.xor4096 = xor4096; - sr.tychei = tychei; - - var seedrandom = sr; - - var Seedrandom = /*@__PURE__*/getDefaultExportFromCjs(seedrandom); + // var seedrandom = require('seedrandom'); + // var random = seedrandom(1); // or any seed. + // var x = random(); // 0 <= x < 1. Every bit is random. + // var x = random.quick(); // 0 <= x < 1. 32 bits of randomness. + + // alea, a 53-bit multiply-with-carry generator by Johannes Baagøe. + // Period: ~2^116 + // Reported to pass all BigCrush tests. + var alea = requireAlea(); + + // xor128, a pure xor-shift generator by George Marsaglia. + // Period: 2^128-1. + // Reported to fail: MatrixRank and LinearComp. + var xor128 = requireXor128(); + + // xorwow, George Marsaglia's 160-bit xor-shift combined plus weyl. + // Period: 2^192-2^32 + // Reported to fail: CollisionOver, SimpPoker, and LinearComp. + var xorwow = requireXorwow(); + + // xorshift7, by François Panneton and Pierre L'ecuyer, takes + // a different approach: it adds robustness by allowing more shifts + // than Marsaglia's original three. It is a 7-shift generator + // with 256 bits, that passes BigCrush with no systmatic failures. + // Period 2^256-1. + // No systematic BigCrush failures reported. + var xorshift7 = requireXorshift7(); + + // xor4096, by Richard Brent, is a 4096-bit xor-shift with a + // very long period that also adds a Weyl generator. It also passes + // BigCrush with no systematic failures. Its long period may + // be useful if you have many generators and need to avoid + // collisions. + // Period: 2^4128-2^32. + // No systematic BigCrush failures reported. + var xor4096 = requireXor4096(); + + // Tyche-i, by Samuel Neves and Filipe Araujo, is a bit-shifting random + // number generator derived from ChaCha, a modern stream cipher. + // https://eden.dei.uc.pt/~sneves/pubs/2011-snfa2.pdf + // Period: ~2^127 + // No systematic BigCrush failures reported. + var tychei = requireTychei(); + + // The original ARC4-based prng included in this library. + // Period: ~2^1600 + var sr = requireSeedrandom$1(); + + sr.alea = alea; + sr.xor128 = xor128; + sr.xorwow = xorwow; + sr.xorshift7 = xorshift7; + sr.xor4096 = xor4096; + sr.tychei = tychei; + + seedrandom = sr; + return seedrandom; + } + + var seedrandomExports = requireSeedrandom(); + var Seedrandom = /*@__PURE__*/getDefaultExportFromCjs(seedrandomExports); /** * @param {HTMLCanvasElement} canvas @@ -5380,7 +5403,7 @@ * @param {any} getImageDataProxy * @param {CanvasRenderingContext2D | WebGL2RenderingContext | WebGLRenderingContext} ctx? */ - function computeOffScreenCanvas (canvas, domainKey, sessionKey, getImageDataProxy, ctx) { + function computeOffScreenCanvas(canvas, domainKey, sessionKey, getImageDataProxy, ctx) { if (!ctx) { // @ts-expect-error - Type 'null' is not assignable to type 'CanvasRenderingContext2D | WebGL2RenderingContext | WebGLRenderingContext'. ctx = canvas.getContext('2d'); @@ -5414,7 +5437,7 @@ // @ts-expect-error - 'offScreenCtx' is possibly 'null'. offScreenCtx.putImageData(imageData, 0, 0); - return { offScreenCanvas, offScreenCtx } + return { offScreenCanvas, offScreenCtx }; } /** @@ -5422,7 +5445,7 @@ * * @param {CanvasRenderingContext2D} canvasContext */ - function clearCanvas (canvasContext) { + function clearCanvas(canvasContext) { // Save state and clean the pixels from the canvas canvasContext.save(); canvasContext.globalCompositeOperation = 'destination-out'; @@ -5437,7 +5460,7 @@ * @param {string} domainKey * @param {number} width */ - function modifyPixelData (imageData, domainKey, sessionKey, width) { + function modifyPixelData(imageData, domainKey, sessionKey, width) { const d = imageData.data; const length = d.length / 4; let checkSum = 0; @@ -5460,7 +5483,7 @@ d[pixelCanvasIndex] = d[pixelCanvasIndex] ^ (byte & 0x1); } - return imageData + return imageData; } /** @@ -5470,7 +5493,7 @@ * @param {number} index * @param {number} width */ - function adjacentSame (imageData, index, width) { + function adjacentSame(imageData, index, width) { const widthPixel = width * 4; const x = index % widthPixel; const maxLength = imageData.length; @@ -5479,15 +5502,15 @@ if (x < widthPixel) { const right = index + 4; if (!pixelsSame(imageData, index, right)) { - return false + return false; } const diagonalRightUp = right - widthPixel; if (diagonalRightUp > 0 && !pixelsSame(imageData, index, diagonalRightUp)) { - return false + return false; } const diagonalRightDown = right + widthPixel; if (diagonalRightDown < maxLength && !pixelsSame(imageData, index, diagonalRightDown)) { - return false + return false; } } @@ -5495,29 +5518,29 @@ if (x > 0) { const left = index - 4; if (!pixelsSame(imageData, index, left)) { - return false + return false; } const diagonalLeftUp = left - widthPixel; if (diagonalLeftUp > 0 && !pixelsSame(imageData, index, diagonalLeftUp)) { - return false + return false; } const diagonalLeftDown = left + widthPixel; if (diagonalLeftDown < maxLength && !pixelsSame(imageData, index, diagonalLeftDown)) { - return false + return false; } } const up = index - widthPixel; if (up > 0 && !pixelsSame(imageData, index, up)) { - return false + return false; } const down = index + widthPixel; if (down < maxLength && !pixelsSame(imageData, index, down)) { - return false + return false; } - return true + return true; } /** @@ -5526,11 +5549,13 @@ * @param {number} index * @param {number} index2 */ - function pixelsSame (imageData, index, index2) { - return imageData[index] === imageData[index2] && - imageData[index + 1] === imageData[index2 + 1] && - imageData[index + 2] === imageData[index2 + 2] && - imageData[index + 3] === imageData[index2 + 3] + function pixelsSame(imageData, index, index2) { + return ( + imageData[index] === imageData[index2] && + imageData[index + 1] === imageData[index2 + 1] && + imageData[index + 2] === imageData[index2 + 2] && + imageData[index + 3] === imageData[index2 + 3] + ); } /** @@ -5539,16 +5564,16 @@ * @param {number} index * @returns {boolean} */ - function shouldIgnorePixel (imageData, index) { + function shouldIgnorePixel(imageData, index) { // Transparent pixels if (imageData[index + 3] === 0) { - return true + return true; } - return false + return false; } class FingerprintingCanvas extends ContentFeature { - init (args) { + init(args) { const { sessionKey, site } = args; const domainKey = site.domain; const supportsWebGl = this.getFeatureSettingEnabled('webGl'); @@ -5561,28 +5586,27 @@ * Clear cache as canvas has changed * @param {OffscreenCanvas | HTMLCanvasElement} canvas */ - function clearCache (canvas) { + function clearCache(canvas) { canvasCache.delete(canvas); } /** * @param {OffscreenCanvas | HTMLCanvasElement} canvas */ - function treatAsUnsafe (canvas) { + function treatAsUnsafe(canvas) { unsafeCanvases.add(canvas); clearCache(canvas); } const proxy = new DDGProxy(this, HTMLCanvasElement.prototype, 'getContext', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { const context = DDGReflect.apply(target, thisArg, args); try { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined'. canvasContexts.set(thisArg, context); - } catch { - } - return context - } + } catch {} + return context; + }, }); proxy.overload(); @@ -5590,7 +5614,7 @@ const safeMethods = ['putImageData', 'drawImage']; for (const methodName of safeMethods) { const safeMethodProxy = new DDGProxy(this, CanvasRenderingContext2D.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // Don't apply escape hatch for canvases if (methodName === 'drawImage' && args[0] && args[0] instanceof HTMLCanvasElement) { treatAsUnsafe(args[0]); @@ -5598,8 +5622,8 @@ // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' clearCache(thisArg.canvas); } - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); safeMethodProxy.overload(); } @@ -5623,17 +5647,17 @@ 'createConicGradient', 'createLinearGradient', 'createRadialGradient', - 'createPattern' + 'createPattern', ]; for (const methodName of unsafeMethods) { // Some methods are browser specific if (methodName in CanvasRenderingContext2D.prototype) { const unsafeProxy = new DDGProxy(this, CanvasRenderingContext2D.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' treatAsUnsafe(thisArg.canvas); - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); unsafeProxy.overload(); } @@ -5648,11 +5672,9 @@ 'createProgram', 'linkProgram', 'drawElements', - 'drawArrays' - ]; - const glContexts = [ - WebGLRenderingContext + 'drawArrays', ]; + const glContexts = [WebGLRenderingContext]; if ('WebGL2RenderingContext' in globalThis) { glContexts.push(WebGL2RenderingContext); } @@ -5661,11 +5683,11 @@ // Some methods are browser specific if (methodName in context.prototype) { const unsafeProxy = new DDGProxy(this, context.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' treatAsUnsafe(thisArg.canvas); - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); unsafeProxy.overload(); } @@ -5675,22 +5697,21 @@ // Using proxies here to swallow calls to toString etc const getImageDataProxy = new DDGProxy(this, CanvasRenderingContext2D.prototype, 'getImageData', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' if (!unsafeCanvases.has(thisArg.canvas)) { - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } // Anything we do here should be caught and ignored silently try { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' const { offScreenCtx } = getCachedOffScreenCanvasOrCompute(thisArg.canvas, domainKey, sessionKey); // Call the original method on the modified off-screen canvas - return DDGReflect.apply(target, offScreenCtx, args) - } catch { - } + return DDGReflect.apply(target, offScreenCtx, args); + } catch {} - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); getImageDataProxy.overload(); @@ -5701,7 +5722,7 @@ * @param {string} domainKey * @param {string} sessionKey */ - function getCachedOffScreenCanvasOrCompute (canvas, domainKey, sessionKey) { + function getCachedOffScreenCanvasOrCompute(canvas, domainKey, sessionKey) { let result; if (canvasCache.has(canvas)) { result = canvasCache.get(canvas); @@ -5710,28 +5731,28 @@ result = computeOffScreenCanvas(canvas, domainKey, sessionKey, getImageDataProxy, ctx); canvasCache.set(canvas, result); } - return result + return result; } const canvasMethods = ['toDataURL', 'toBlob']; for (const methodName of canvasMethods) { const proxy = new DDGProxy(this, HTMLCanvasElement.prototype, methodName, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { // Short circuit for low risk canvas calls // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' if (!unsafeCanvases.has(thisArg)) { - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } try { // @ts-expect-error - error TS18048: 'thisArg' is possibly 'undefined' const { offScreenCanvas } = getCachedOffScreenCanvasOrCompute(thisArg, domainKey, sessionKey); // Call the original method on the modified off-screen canvas - return DDGReflect.apply(target, offScreenCanvas, args) + return DDGReflect.apply(target, offScreenCanvas, args); } catch { // Something we did caused an exception, fall back to the native - return DDGReflect.apply(target, thisArg, args) + return DDGReflect.apply(target, thisArg, args); } - } + }, }); proxy.overload(); } @@ -5739,7 +5760,7 @@ } class GoogleRejected extends ContentFeature { - init () { + init() { try { if ('browsingTopics' in Document.prototype) { delete Document.prototype.browsingTopics; @@ -5767,26 +5788,26 @@ // Set Global Privacy Control property on DOM class GlobalPrivacyControl extends ContentFeature { - init (args) { + init(args) { try { // If GPC on, set DOM property prototype to true if not already true if (args.globalPrivacyControlValue) { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - if (navigator.globalPrivacyControl) return + if (navigator.globalPrivacyControl) return; this.defineProperty(Navigator.prototype, 'globalPrivacyControl', { get: () => true, configurable: true, - enumerable: true + enumerable: true, }); } else { // If GPC off & unsupported by browser, set DOM property prototype to false // this may be overwritten by the user agent or other extensions // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - if (typeof navigator.globalPrivacyControl !== 'undefined') return + if (typeof navigator.globalPrivacyControl !== 'undefined') return; this.defineProperty(Navigator.prototype, 'globalPrivacyControl', { get: () => false, configurable: true, - enumerable: true + enumerable: true, }); } } catch { @@ -5796,77 +5817,77 @@ } class FingerprintingHardware extends ContentFeature { - init () { + init() { this.wrapProperty(globalThis.Navigator.prototype, 'keyboard', { get: () => { // @ts-expect-error - error TS2554: Expected 2 arguments, but got 1. - return this.getFeatureAttr('keyboard') - } + return this.getFeatureAttr('keyboard'); + }, }); this.wrapProperty(globalThis.Navigator.prototype, 'hardwareConcurrency', { get: () => { - return this.getFeatureAttr('hardwareConcurrency', 2) - } + return this.getFeatureAttr('hardwareConcurrency', 2); + }, }); this.wrapProperty(globalThis.Navigator.prototype, 'deviceMemory', { get: () => { - return this.getFeatureAttr('deviceMemory', 8) - } + return this.getFeatureAttr('deviceMemory', 8); + }, }); } } class Referrer extends ContentFeature { - init () { + init() { // If the referer is a different host to the current one, trim it. if (document.referrer && new URL(document.URL).hostname !== new URL(document.referrer).hostname) { // trim referrer to origin. const trimmedReferer = new URL(document.referrer).origin + '/'; this.wrapProperty(Document.prototype, 'referrer', { - get: () => trimmedReferer + get: () => trimmedReferer, }); } } } class FingerprintingScreenSize extends ContentFeature { - origPropertyValues = {} + origPropertyValues = {}; - init () { + init() { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.origPropertyValues.availTop = globalThis.screen.availTop; this.wrapProperty(globalThis.Screen.prototype, 'availTop', { - get: () => this.getFeatureAttr('availTop', 0) + get: () => this.getFeatureAttr('availTop', 0), }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.origPropertyValues.availLeft = globalThis.screen.availLeft; this.wrapProperty(globalThis.Screen.prototype, 'availLeft', { - get: () => this.getFeatureAttr('availLeft', 0) + get: () => this.getFeatureAttr('availLeft', 0), }); this.origPropertyValues.availWidth = globalThis.screen.availWidth; const forcedAvailWidthValue = globalThis.screen.width; this.wrapProperty(globalThis.Screen.prototype, 'availWidth', { - get: () => forcedAvailWidthValue + get: () => forcedAvailWidthValue, }); this.origPropertyValues.availHeight = globalThis.screen.availHeight; const forcedAvailHeightValue = globalThis.screen.height; this.wrapProperty(globalThis.Screen.prototype, 'availHeight', { - get: () => forcedAvailHeightValue + get: () => forcedAvailHeightValue, }); this.origPropertyValues.colorDepth = globalThis.screen.colorDepth; this.wrapProperty(globalThis.Screen.prototype, 'colorDepth', { - get: () => this.getFeatureAttr('colorDepth', 24) + get: () => this.getFeatureAttr('colorDepth', 24), }); this.origPropertyValues.pixelDepth = globalThis.screen.pixelDepth; this.wrapProperty(globalThis.Screen.prototype, 'pixelDepth', { - get: () => this.getFeatureAttr('pixelDepth', 24) + get: () => this.getFeatureAttr('pixelDepth', 24), }); globalThis.window.addEventListener('resize', () => { @@ -5881,25 +5902,25 @@ * can mean second or more monitors have very large or negative values. This function maps a given * given coordinate value to the proper place on the main screen. */ - normalizeWindowDimension (value, targetDimension) { + normalizeWindowDimension(value, targetDimension) { if (value > targetDimension) { - return value % targetDimension + return value % targetDimension; } if (value < 0) { - return targetDimension + value + return targetDimension + value; } - return value + return value; } - setWindowPropertyValue (property, value) { + setWindowPropertyValue(property, value) { // Here we don't update the prototype getter because the values are updated dynamically try { this.defineProperty(globalThis, property, { get: () => value, - // eslint-disable-next-line @typescript-eslint/no-empty-function + set: () => {}, configurable: true, - enumerable: true + enumerable: true, }); } catch (e) {} } @@ -5910,7 +5931,7 @@ * ensuring that no information is leaked as the dimensions change, but also that the * values change correctly for valid use cases. */ - setWindowDimensions () { + setWindowDimensions() { try { const window = globalThis; const top = globalThis.top; @@ -5965,7 +5986,7 @@ } class FingerprintingTemporaryStorage extends ContentFeature { - init () { + init() { const navigator = globalThis.navigator; const Navigator = globalThis.Navigator; @@ -5981,7 +6002,7 @@ const org = navigator.webkitTemporaryStorage.queryUsageAndQuota; // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f const tStorage = navigator.webkitTemporaryStorage; - tStorage.queryUsageAndQuota = function queryUsageAndQuota (callback, err) { + tStorage.queryUsageAndQuota = function queryUsageAndQuota(callback, err) { const modifiedCallback = function (usedBytes, grantedBytes) { const maxBytesGranted = 4 * 1024 * 1024 * 1024; const spoofedGrantedBytes = Math.min(grantedBytes, maxBytesGranted); @@ -5993,7 +6014,7 @@ this.defineProperty(Navigator.prototype, 'webkitTemporaryStorage', { get: () => tStorage, enumerable: true, - configurable: true + configurable: true, }); } catch (e) {} } @@ -6001,35 +6022,35 @@ } class NavigatorInterface extends ContentFeature { - load (args) { + load(args) { if (this.matchDomainFeatureSetting('privilegedDomains').length) { this.injectNavigatorInterface(args); } } - init (args) { + init(args) { this.injectNavigatorInterface(args); } - injectNavigatorInterface (args) { + injectNavigatorInterface(args) { try { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f if (navigator.duckduckgo) { - return + return; } if (!args.platform || !args.platform.name) { - return + return; } this.defineProperty(Navigator.prototype, 'duckduckgo', { value: { platform: args.platform.name, - isDuckDuckGo () { - return DDGPromise.resolve(true) - } + isDuckDuckGo() { + return DDGPromise.resolve(true); + }, }, enumerable: true, configurable: false, - writable: false + writable: false, }); } catch { // todo: Just ignore this exception? @@ -6056,44 +6077,44 @@ * @param {Object} rule * @param {HTMLElement} [previousElement] */ - function collapseDomNode (element, rule, previousElement) { + function collapseDomNode(element, rule, previousElement) { if (!element) { - return + return; } const type = rule.type; const alreadyHidden = hiddenElements.has(element); const alreadyModified = modifiedElements.has(element) && modifiedElements.get(element) === rule.type; // return if the element has already been hidden, or modified by the same rule type if (alreadyHidden || alreadyModified) { - return + return; } switch (type) { - case 'hide': - hideNode(element); - break - case 'hide-empty': - if (isDomNodeEmpty(element)) { + case 'hide': hideNode(element); - appliedRules.add(rule); - } - break - case 'closest-empty': - // hide the outermost empty node so that we may unhide if ad loads - if (isDomNodeEmpty(element)) { - // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - collapseDomNode(element.parentNode, rule, element); - } else if (previousElement) { - hideNode(previousElement); - appliedRules.add(rule); - } - break - case 'modify-attr': - modifyAttribute(element, rule.values); - break - case 'modify-style': - modifyStyle(element, rule.values); - break + break; + case 'hide-empty': + if (isDomNodeEmpty(element)) { + hideNode(element); + appliedRules.add(rule); + } + break; + case 'closest-empty': + // hide the outermost empty node so that we may unhide if ad loads + if (isDomNodeEmpty(element)) { + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + collapseDomNode(element.parentNode, rule, element); + } else if (previousElement) { + hideNode(previousElement); + appliedRules.add(rule); + } + break; + case 'modify-attr': + modifyAttribute(element, rule.values); + break; + case 'modify-style': + modifyStyle(element, rule.values); + break; } } @@ -6102,29 +6123,29 @@ * @param {HTMLElement} element * @param {Object} rule */ - function expandNonEmptyDomNode (element, rule) { + function expandNonEmptyDomNode(element, rule) { if (!element) { - return + return; } const type = rule.type; const alreadyHidden = hiddenElements.has(element); switch (type) { - case 'hide': - // only care about rule types that specifically apply to empty elements - break - case 'hide-empty': - case 'closest-empty': - if (alreadyHidden && !isDomNodeEmpty(element)) { - unhideNode(element); - } else if (type === 'closest-empty') { - // iterate upwards from matching DOM elements until we arrive at previously - // hidden element. Unhide element if it contains visible content. - // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - expandNonEmptyDomNode(element.parentNode, rule); - } - break + case 'hide': + // only care about rule types that specifically apply to empty elements + break; + case 'hide-empty': + case 'closest-empty': + if (alreadyHidden && !isDomNodeEmpty(element)) { + unhideNode(element); + } else if (type === 'closest-empty') { + // iterate upwards from matching DOM elements until we arrive at previously + // hidden element. Unhide element if it contains visible content. + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + expandNonEmptyDomNode(element.parentNode, rule); + } + break; } } @@ -6132,13 +6153,13 @@ * Hide DOM element * @param {HTMLElement} element */ - function hideNode (element) { + function hideNode(element) { // maintain a reference to each hidden element along with the properties // that are being overwritten const cachedDisplayProperties = { display: element.style.display, 'min-height': element.style.minHeight, - height: element.style.height + height: element.style.height, }; hiddenElements.set(element, cachedDisplayProperties); @@ -6155,10 +6176,10 @@ * Show previously hidden DOM element * @param {HTMLElement} element */ - function unhideNode (element) { + function unhideNode(element) { const cachedDisplayProperties = hiddenElements.get(element); if (!cachedDisplayProperties) { - return + return; } for (const prop in cachedDisplayProperties) { @@ -6172,10 +6193,10 @@ * Check if DOM element contains visible content * @param {HTMLElement} node */ - function isDomNodeEmpty (node) { + function isDomNodeEmpty(node) { // no sense wasting cycles checking if the page's body element is empty if (node.tagName === 'BODY') { - return false + return false; } // use a DOMParser to remove all metadata elements before checking if // the node is empty. @@ -6194,19 +6215,23 @@ // - node doesn't contain any iframes // - node contains iframes, all of which are hidden or have src='about:blank' const noFramesWithContent = frameElements.every((frame) => { - return (frame.hidden || frame.src === 'about:blank') + return frame.hidden || frame.src === 'about:blank'; }); // ad containers often contain tracking pixels and other small images (eg adchoices logo). // these should be treated as empty and hidden, but real images should not. const visibleImages = imageElements.some((image) => { - return (image.getBoundingClientRect().width > 20 || image.getBoundingClientRect().height > 20) + return image.getBoundingClientRect().width > 20 || image.getBoundingClientRect().height > 20; }); - if ((visibleText === '' || adLabelStrings.includes(visibleText)) && - mediaAndFormContent === null && noFramesWithContent && !visibleImages) { - return true + if ( + (visibleText === '' || adLabelStrings.includes(visibleText)) && + mediaAndFormContent === null && + noFramesWithContent && + !visibleImages + ) { + return true; } - return false + return false; } /** @@ -6216,7 +6241,7 @@ * @param {string} values[].property * @param {string} values[].value */ - function modifyAttribute (element, values) { + function modifyAttribute(element, values) { values.forEach((item) => { element.setAttribute(item.property, item.value); }); @@ -6230,7 +6255,7 @@ * @param {string} values[].property * @param {string} values[].value */ - function modifyStyle (element, values) { + function modifyStyle(element, values) { values.forEach((item) => { element.style.setProperty(item.property, item.value, 'important'); }); @@ -6243,9 +6268,9 @@ * @param {string} rules[].selector * @param {string} rules[].type */ - function extractTimeoutRules (rules) { + function extractTimeoutRules(rules) { if (!shouldInjectStyleTag) { - return rules + return rules; } const strictHideRules = []; @@ -6260,7 +6285,7 @@ }); injectStyleTag(strictHideRules); - return timeoutRules + return timeoutRules; } /** @@ -6269,7 +6294,7 @@ * @param {string} rules[].selector * @param {string} rules[].type */ - function injectStyleTag (rules) { + function injectStyleTag(rules) { // wrap selector list in :is(...) to make it a forgiving selector list. this enables // us to use selectors not supported in all browsers, eg :has in Firefox let selector = ''; @@ -6293,7 +6318,7 @@ * @param {string} rules[].selector * @param {string} rules[].type */ - function hideAdNodes (rules) { + function hideAdNodes(rules) { const document = globalThis.document; rules.forEach((rule) => { @@ -6309,7 +6334,7 @@ /** * Iterate over previously hidden elements, unhiding if content has loaded into them */ - function unhideLoadedAds () { + function unhideLoadedAds() { const document = globalThis.document; appliedRules.forEach((rule) => { @@ -6325,17 +6350,17 @@ /** * Wrap selector(s) in :is(..) to make them forgiving */ - function forgivingSelector (selector) { - return `:is(${selector})` + function forgivingSelector(selector) { + return `:is(${selector})`; } class ElementHiding extends ContentFeature { - init () { + init() { // eslint-disable-next-line @typescript-eslint/no-this-alias featureInstance = this; if (isBeingFramed()) { - return + return; } let activeRules; @@ -6355,17 +6380,17 @@ const activeDomainRules = this.matchDomainFeatureSetting('domains').flatMap((item) => item.rules); const overrideRules = activeDomainRules.filter((rule) => { - return rule.type === 'override' + return rule.type === 'override'; }); const disableDefault = activeDomainRules.some((rule) => { - return rule.type === 'disable-default' + return rule.type === 'disable-default'; }); // if rule with type 'disable-default' is present, ignore all global rules if (disableDefault) { activeRules = activeDomainRules.filter((rule) => { - return rule.type !== 'disable-default' + return rule.type !== 'disable-default'; }); } else { activeRules = activeDomainRules.concat(globalRules); @@ -6374,7 +6399,7 @@ // remove overrides and rules that match overrides from array of rules to be applied to page overrideRules.forEach((override) => { activeRules = activeRules.filter((rule) => { - return rule.selector !== override.selector + return rule.selector !== override.selector; }); }); @@ -6391,10 +6416,10 @@ // single page applications don't have a DOMContentLoaded event on navigations, so // we use proxy/reflect on history.pushState to call applyRules on page navigations const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', { - apply (target, thisArg, args) { + apply(target, thisArg, args) { applyRules(activeRules); - return DDGReflect.apply(target, thisArg, args) - } + return DDGReflect.apply(target, thisArg, args); + }, }); historyMethodProxy.overload(); // listen for popstate events in order to run on back/forward navigations @@ -6409,7 +6434,7 @@ * @param {string} rules[].selector * @param {string} rules[].type */ - applyRules (rules) { + applyRules(rules) { const timeoutRules = extractTimeoutRules(rules); const clearCacheTimer = unhideTimeouts.concat(hideTimeouts).reduce((a, b) => Math.max(a, b), 0) + 100; @@ -6441,29 +6466,169 @@ } class ExceptionHandler extends ContentFeature { - init () { + init() { // Report to the debugger panel if an uncaught exception occurs const handleUncaughtException = (e) => { - postDebugMessage('jsException', { - documentUrl: document.location.href, - message: e.message, - filename: e.filename, - lineno: e.lineno, - colno: e.colno, - stack: e.error?.stack - }, true); + postDebugMessage( + 'jsException', + { + documentUrl: document.location.href, + message: e.message, + filename: e.filename, + lineno: e.lineno, + colno: e.colno, + stack: e.error?.stack, + }, + true, + ); this.addDebugFlag(); }; globalThis.addEventListener('error', handleUncaughtException); } } + /** + * This feature allows remote configuration of APIs that exist within the DOM. + * We support removal of APIs and returning different values from getters. + * + * @module API manipulation + */ + + /** + * @internal + */ + class ApiManipulation extends ContentFeature { + init() { + const apiChanges = this.getFeatureSetting('apiChanges'); + if (apiChanges) { + for (const scope in apiChanges) { + const change = apiChanges[scope]; + if (!this.checkIsValidAPIChange(change)) { + continue; + } + this.applyApiChange(scope, change); + } + } + } + + /** + * Checks if the config API change is valid. + * @param {any} change + * @returns {change is APIChange} + */ + checkIsValidAPIChange(change) { + if (typeof change !== 'object') { + return false; + } + if (change.type === 'remove') { + return true; + } + if (change.type === 'descriptor') { + if (change.enumerable && typeof change.enumerable !== 'boolean') { + return false; + } + if (change.configurable && typeof change.configurable !== 'boolean') { + return false; + } + return typeof change.getterValue !== 'undefined'; + } + return false; + } + + // TODO move this to schema definition imported from the privacy-config + // Additionally remove checkIsValidAPIChange when this change happens. + // See: https://app.asana.com/0/1201614831475344/1208715421518231/f + /** + * @typedef {Object} APIChange + * @property {"remove"|"descriptor"} type + * @property {import('../utils.js').ConfigSetting} [getterValue] - The value returned from a getter. + * @property {boolean} [enumerable] - Whether the property is enumerable. + * @property {boolean} [configurable] - Whether the property is configurable. + */ + + /** + * Applies a change to DOM APIs. + * @param {string} scope + * @param {APIChange} change + * @returns {void} + */ + applyApiChange(scope, change) { + const response = this.getGlobalObject(scope); + if (!response) { + return; + } + const [obj, key] = response; + if (change.type === 'remove') { + this.removeApiMethod(obj, key); + } else if (change.type === 'descriptor') { + this.wrapApiDescriptor(obj, key, change); + } + } + + /** + * Removes a method from an API. + * @param {object} api + * @param {string} key + */ + removeApiMethod(api, key) { + try { + if (hasOwnProperty.call(api, key)) { + delete api[key]; + } + } catch (e) {} + } + + /** + * Wraps a property with descriptor. + * @param {object} api + * @param {string} key + * @param {APIChange} change + */ + wrapApiDescriptor(api, key, change) { + const getterValue = change.getterValue; + if (getterValue) { + const descriptor = { + get: () => processAttr(getterValue, undefined), + }; + if ('enumerable' in change) { + descriptor.enumerable = change.enumerable; + } + if ('configurable' in change) { + descriptor.configurable = change.configurable; + } + this.wrapProperty(api, key, descriptor); + } + } + + /** + * Looks up a global object from a scope, e.g. 'Navigator.prototype'. + * @param {string} scope the scope of the object to get to. + * @returns {[object, string]|null} the object at the scope. + */ + getGlobalObject(scope) { + const parts = scope.split('.'); + // get the last part of the scope + const lastPart = parts.pop(); + if (!lastPart) { + return null; + } + let obj = window; + for (const part of parts) { + obj = obj[part]; + if (!obj) { + return null; + } + } + return [obj, lastPart]; + } + } + /** * Fixes incorrect sizing value for outerHeight and outerWidth */ - function windowSizingFix () { + function windowSizingFix() { if (window.outerHeight !== 0 && window.outerWidth !== 0) { - return + return; } window.outerHeight = window.innerHeight; window.outerWidth = window.innerWidth; @@ -6474,30 +6639,30 @@ const MSG_SCREEN_LOCK = 'screenLock'; const MSG_SCREEN_UNLOCK = 'screenUnlock'; - function canShare (data) { - if (typeof data !== 'object') return false - if (!('url' in data) && !('title' in data) && !('text' in data)) return false // At least one of these is required - if ('files' in data) return false // File sharing is not supported at the moment - if ('title' in data && typeof data.title !== 'string') return false - if ('text' in data && typeof data.text !== 'string') return false + function canShare(data) { + if (typeof data !== 'object') return false; + if (!('url' in data) && !('title' in data) && !('text' in data)) return false; // At least one of these is required + if ('files' in data) return false; // File sharing is not supported at the moment + if ('title' in data && typeof data.title !== 'string') return false; + if ('text' in data && typeof data.text !== 'string') return false; if ('url' in data) { - if (typeof data.url !== 'string') return false + if (typeof data.url !== 'string') return false; try { const url = new URL$1(data.url, location.href); - if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; } catch (err) { - return false + return false; } } - if (window !== window.top) return false // Not supported in iframes - return true + if (window !== window.top) return false; // Not supported in iframes + return true; } /** * Clean data before sending to the Android side * @returns {ShareRequestData} */ - function cleanShareData (data) { + function cleanShareData(data) { /** @type {ShareRequestData} */ const dataToSend = {}; @@ -6508,7 +6673,7 @@ // clean url and handle relative links (e.g. if url is an empty string) if ('url' in data) { - dataToSend.url = (new URL$1(data.url, location.href)).href; + dataToSend.url = new URL$1(data.url, location.href).href; } // combine url and text into text if both are present @@ -6521,17 +6686,17 @@ if (!('url' in dataToSend) && !('text' in dataToSend)) { dataToSend.text = ''; } - return dataToSend + return dataToSend; } class WebCompat extends ContentFeature { /** @type {Promise | null} */ - #activeShareRequest = null + #activeShareRequest = null; /** @type {Promise | null} */ - #activeScreenLockRequest = null + #activeScreenLockRequest = null; - init () { + init() { if (this.getFeatureSettingEnabled('windowSizing')) { windowSizingFix(); } @@ -6581,14 +6746,14 @@ } /** Shim Web Share API in Android WebView */ - shimWebShare () { - if (typeof navigator.canShare === 'function' || typeof navigator.share === 'function') return + shimWebShare() { + if (typeof navigator.canShare === 'function' || typeof navigator.share === 'function') return; this.defineProperty(Navigator.prototype, 'canShare', { configurable: true, enumerable: true, writable: true, - value: canShare + value: canShare, }); this.defineProperty(Navigator.prototype, 'share', { @@ -6596,12 +6761,12 @@ enumerable: true, writable: true, value: async (data) => { - if (!canShare(data)) return Promise.reject(new TypeError('Invalid share data')) + if (!canShare(data)) return Promise.reject(new TypeError('Invalid share data')); if (this.#activeShareRequest) { - return Promise.reject(new DOMException('Share already in progress', 'InvalidStateError')) + return Promise.reject(new DOMException('Share already in progress', 'InvalidStateError')); } if (!navigator.userActivation.isActive) { - return Promise.reject(new DOMException('Share must be initiated by a user gesture', 'InvalidStateError')) + return Promise.reject(new DOMException('Share must be initiated by a user gesture', 'InvalidStateError')); } const dataToSend = cleanShareData(data); @@ -6610,31 +6775,31 @@ try { resp = await this.#activeShareRequest; } catch (err) { - throw new DOMException(err.message, 'DataError') + throw new DOMException(err.message, 'DataError'); } finally { this.#activeShareRequest = null; } if (resp.failure) { switch (resp.failure.name) { - case 'AbortError': - case 'NotAllowedError': - case 'DataError': - throw new DOMException(resp.failure.message, resp.failure.name) - default: - throw new DOMException(resp.failure.message, 'DataError') + case 'AbortError': + case 'NotAllowedError': + case 'DataError': + throw new DOMException(resp.failure.message, resp.failure.name); + default: + throw new DOMException(resp.failure.message, 'DataError'); } } - } + }, }); } /** * Notification fix for adding missing API for Android WebView. */ - notificationFix () { + notificationFix() { if (window.Notification) { - return + return; } // Expose the API this.defineProperty(window, 'Notification', { @@ -6643,33 +6808,33 @@ }, writable: true, configurable: true, - enumerable: false + enumerable: false, }); this.defineProperty(window.Notification, 'requestPermission', { value: () => { - return Promise.resolve('denied') + return Promise.resolve('denied'); }, writable: true, configurable: true, - enumerable: true + enumerable: true, }); this.defineProperty(window.Notification, 'permission', { get: () => 'denied', configurable: true, - enumerable: false + enumerable: false, }); this.defineProperty(window.Notification, 'maxActions', { get: () => 2, configurable: true, - enumerable: true + enumerable: true, }); } - cleanIframeValue () { - function cleanValueData (val) { + cleanIframeValue() { + function cleanValueData(val) { const clone = Object.assign({}, val); const deleteKeys = ['iframeProto', 'iframeData', 'remap']; for (const key of deleteKeys) { @@ -6678,15 +6843,17 @@ } } val.iframeData = clone; - return val + return val; } window.XMLHttpRequest.prototype.send = new Proxy(window.XMLHttpRequest.prototype.send, { - apply (target, thisArg, args) { + apply(target, thisArg, args) { const body = args[0]; const cleanKey = 'bi_wvdp'; if (body && typeof body === 'string' && body.includes(cleanKey)) { - const parts = body.split('&').map((part) => { return part.split('=') }); + const parts = body.split('&').map((part) => { + return part.split('='); + }); if (parts.length > 0) { parts.forEach((part) => { if (part[0] === cleanKey) { @@ -6694,58 +6861,69 @@ part[1] = encodeURIComponent(JSON.stringify(cleanValueData(val))); } }); - args[0] = parts.map((part) => { return part.join('=') }).join('&'); + args[0] = parts + .map((part) => { + return part.join('='); + }) + .join('&'); } } - return Reflect.apply(target, thisArg, args) - } + return Reflect.apply(target, thisArg, args); + }, }); } /** * Adds missing permissions API for Android WebView. */ - permissionsFix (settings) { + permissionsFix(settings) { if (window.navigator.permissions) { - return + return; } const permissions = {}; class PermissionStatus extends EventTarget { - constructor (name, state) { + constructor(name, state) { super(); this.name = name; this.state = state; this.onchange = null; // noop } } - permissions.query = new Proxy(async (query) => { - this.addDebugFlag(); - if (!query) { - throw new TypeError("Failed to execute 'query' on 'Permissions': 1 argument required, but only 0 present.") - } - if (!query.name) { - throw new TypeError("Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': Required member is undefined.") - } - if (!settings.supportedPermissions || !(query.name in settings.supportedPermissions)) { - throw new TypeError(`Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': The provided value '${query.name}' is not a valid enum value of type PermissionName.`) - } - const permSetting = settings.supportedPermissions[query.name]; - const returnName = permSetting.name || query.name; - let returnStatus = settings.permissionResponse || 'prompt'; - if (permSetting.native) { - try { - const response = await this.messaging.request(MSG_PERMISSIONS_QUERY, query); - returnStatus = response.state || 'prompt'; - } catch (err) { - // do nothing - keep returnStatus as-is + permissions.query = new Proxy( + async (query) => { + this.addDebugFlag(); + if (!query) { + throw new TypeError("Failed to execute 'query' on 'Permissions': 1 argument required, but only 0 present."); } - } - return Promise.resolve(new PermissionStatus(returnName, returnStatus)) - }, { - get (target, name) { - return Reflect.get(target, name) - } - }); + if (!query.name) { + throw new TypeError( + "Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': Required member is undefined.", + ); + } + if (!settings.supportedPermissions || !(query.name in settings.supportedPermissions)) { + throw new TypeError( + `Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': The provided value '${query.name}' is not a valid enum value of type PermissionName.`, + ); + } + const permSetting = settings.supportedPermissions[query.name]; + const returnName = permSetting.name || query.name; + let returnStatus = settings.permissionResponse || 'prompt'; + if (permSetting.native) { + try { + const response = await this.messaging.request(MSG_PERMISSIONS_QUERY, query); + returnStatus = response.state || 'prompt'; + } catch (err) { + // do nothing - keep returnStatus as-is + } + } + return Promise.resolve(new PermissionStatus(returnName, returnStatus)); + }, + { + get(target, name) { + return Reflect.get(target, name); + }, + }, + ); // Expose the API // @ts-expect-error window.navigator isn't assignable window.navigator.permissions = permissions; @@ -6754,7 +6932,7 @@ /** * Fixes screen lock/unlock APIs for Android WebView. */ - screenLockFix () { + screenLockFix() { const validOrientations = [ 'any', 'natural', @@ -6764,19 +6942,25 @@ 'portrait-secondary', 'landscape-primary', 'landscape-secondary', - 'unsupported' + 'unsupported', ]; this.wrapProperty(globalThis.ScreenOrientation.prototype, 'lock', { value: async (requestedOrientation) => { if (!requestedOrientation) { - return Promise.reject(new TypeError("Failed to execute 'lock' on 'ScreenOrientation': 1 argument required, but only 0 present.")) + return Promise.reject( + new TypeError("Failed to execute 'lock' on 'ScreenOrientation': 1 argument required, but only 0 present."), + ); } if (!validOrientations.includes(requestedOrientation)) { - return Promise.reject(new TypeError(`Failed to execute 'lock' on 'ScreenOrientation': The provided value '${requestedOrientation}' is not a valid enum value of type OrientationLockType.`)) + return Promise.reject( + new TypeError( + `Failed to execute 'lock' on 'ScreenOrientation': The provided value '${requestedOrientation}' is not a valid enum value of type OrientationLockType.`, + ), + ); } if (this.#activeScreenLockRequest) { - return Promise.reject(new DOMException('Screen lock already in progress', 'AbortError')) + return Promise.reject(new DOMException('Screen lock already in progress', 'AbortError')); } this.#activeScreenLockRequest = this.messaging.request(MSG_SCREEN_LOCK, { orientation: requestedOrientation }); @@ -6784,86 +6968,86 @@ try { resp = await this.#activeScreenLockRequest; } catch (err) { - throw new DOMException(err.message, 'DataError') + throw new DOMException(err.message, 'DataError'); } finally { this.#activeScreenLockRequest = null; } if (resp.failure) { switch (resp.failure.name) { - case 'TypeError': - return Promise.reject(new TypeError(resp.failure.message)) - case 'InvalidStateError': - return Promise.reject(new DOMException(resp.failure.message, resp.failure.name)) - default: - return Promise.reject(new DOMException(resp.failure.message, 'DataError')) + case 'TypeError': + return Promise.reject(new TypeError(resp.failure.message)); + case 'InvalidStateError': + return Promise.reject(new DOMException(resp.failure.message, resp.failure.name)); + default: + return Promise.reject(new DOMException(resp.failure.message, 'DataError')); } } - return Promise.resolve() - } + return Promise.resolve(); + }, }); this.wrapProperty(globalThis.ScreenOrientation.prototype, 'unlock', { value: () => { this.messaging.request(MSG_SCREEN_UNLOCK, {}); - } + }, }); } /** * Add missing navigator.credentials API */ - navigatorCredentialsFix () { + navigatorCredentialsFix() { try { if ('credentials' in navigator && 'get' in navigator.credentials) { - return + return; } const value = { - get () { - return Promise.reject(new Error()) - } + get() { + return Promise.reject(new Error()); + }, }; // TODO: original property is an accessor descriptor this.defineProperty(Navigator.prototype, 'credentials', { value, configurable: true, enumerable: true, - writable: true + writable: true, }); } catch { // Ignore exceptions that could be caused by conflicting with other extensions } } - safariObjectFix () { + safariObjectFix() { try { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f if (window.safari) { - return + return; } this.defineProperty(window, 'safari', { - value: { - }, + value: {}, writable: true, configurable: true, - enumerable: true + enumerable: true, }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari, 'pushNotification', { - value: { - }, + value: {}, configurable: true, - enumerable: true + enumerable: true, }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari.pushNotification, 'toString', { - value: () => { return '[object SafariRemoteNotification]' }, + value: () => { + return '[object SafariRemoteNotification]'; + }, configurable: true, - enumerable: true + enumerable: true, }); class SafariRemoteNotificationPermission { - constructor () { + constructor() { this.deviceToken = null; this.permission = 'denied'; } @@ -6871,84 +7055,88 @@ // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari.pushNotification, 'permission', { value: () => { - return new SafariRemoteNotificationPermission() + return new SafariRemoteNotificationPermission(); }, configurable: true, - enumerable: true + enumerable: true, }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari.pushNotification, 'requestPermission', { value: (name, domain, options, callback) => { if (typeof callback === 'function') { callback(new SafariRemoteNotificationPermission()); - return + return; } const reason = "Invalid 'callback' value passed to safari.pushNotification.requestPermission(). Expected a function."; - throw new Error(reason) + throw new Error(reason); }, configurable: true, - enumerable: true + enumerable: true, }); } catch { // Ignore exceptions that could be caused by conflicting with other extensions } } - mediaSessionFix () { + mediaSessionFix() { try { if (window.navigator.mediaSession && "android" !== 'integration') { - return + return; } class MyMediaSession { - metadata = null + metadata = null; /** @type {MediaSession['playbackState']} */ - playbackState = 'none' + playbackState = 'none'; - setActionHandler () {} - setCameraActive () {} - setMicrophoneActive () {} - setPositionState () {} + setActionHandler() {} + setCameraActive() {} + setMicrophoneActive() {} + setPositionState() {} } this.shimInterface('MediaSession', MyMediaSession, { disallowConstructor: true, allowConstructorCall: false, - wrapToString: true + wrapToString: true, }); this.shimProperty(Navigator.prototype, 'mediaSession', new MyMediaSession(), true); - this.shimInterface('MediaMetadata', class { - constructor (metadata = {}) { - this.title = metadata.title; - this.artist = metadata.artist; - this.album = metadata.album; - this.artwork = metadata.artwork; - } - }, { - disallowConstructor: false, - allowConstructorCall: false, - wrapToString: true - }); + this.shimInterface( + 'MediaMetadata', + class { + constructor(metadata = {}) { + this.title = metadata.title; + this.artist = metadata.artist; + this.album = metadata.album; + this.artwork = metadata.artwork; + } + }, + { + disallowConstructor: false, + allowConstructorCall: false, + wrapToString: true, + }, + ); } catch { // Ignore exceptions that could be caused by conflicting with other extensions } } - presentationFix () { + presentationFix() { try { // @ts-expect-error due to: Property 'presentation' does not exist on type 'Navigator' if (window.navigator.presentation && "android" !== 'integration') { - return + return; } const MyPresentation = class { - get defaultRequest () { - return null + get defaultRequest() { + return null; } - get receiver () { - return null + get receiver() { + return null; } }; @@ -6956,26 +7144,34 @@ this.shimInterface('Presentation', MyPresentation, { disallowConstructor: true, allowConstructorCall: false, - wrapToString: true + wrapToString: true, }); - // @ts-expect-error Presentation API is still experimental, TS types are missing - this.shimInterface('PresentationAvailability', class { - // class definition is empty because there's no way to get an instance of it anyways - }, { - disallowConstructor: true, - allowConstructorCall: false, - wrapToString: true - }); + this.shimInterface( + // @ts-expect-error Presentation API is still experimental, TS types are missing + 'PresentationAvailability', + class { + // class definition is empty because there's no way to get an instance of it anyways + }, + { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true, + }, + ); - // @ts-expect-error Presentation API is still experimental, TS types are missing - this.shimInterface('PresentationRequest', class { - // class definition is empty because there's no way to get an instance of it anyways - }, { - disallowConstructor: true, - allowConstructorCall: false, - wrapToString: true - }); + this.shimInterface( + // @ts-expect-error Presentation API is still experimental, TS types are missing + 'PresentationRequest', + class { + // class definition is empty because there's no way to get an instance of it anyways + }, + { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true, + }, + ); /** TODO: add shims for other classes in the Presentation API: * PresentationConnection, @@ -6995,11 +7191,11 @@ /** * Support for modifying localStorage entries */ - modifyLocalStorage () { - /** @type {import('../types//webcompat-settings').WebCompatSettings['modifyLocalStorage']} */ + modifyLocalStorage() { + /** @type {import('@duckduckgo/privacy-configuration/schema/features/webcompat').WebCompatSettings['modifyLocalStorage']} */ const settings = this.getFeatureSetting('modifyLocalStorage'); - if (!settings || !settings.changes) return + if (!settings || !settings.changes) return; settings.changes.forEach((change) => { if (change.action === 'delete') { @@ -7011,49 +7207,47 @@ /** * Support for proxying `window.webkit.messageHandlers` */ - messageHandlersFix () { - /** @type {import('../types//webcompat-settings').WebCompatSettings['messageHandlers']} */ + messageHandlersFix() { + /** @type {import('@duckduckgo/privacy-configuration/schema/features/webcompat').WebCompatSettings['messageHandlers']} */ const settings = this.getFeatureSetting('messageHandlers'); // Do nothing if `messageHandlers` is absent - if (!globalThis.webkit?.messageHandlers) return + if (!globalThis.webkit?.messageHandlers) return; // This should never occur, but keeps typescript happy - if (!settings) return + if (!settings) return; const proxy = new Proxy(globalThis.webkit.messageHandlers, { - get (target, messageName, receiver) { + get(target, messageName, receiver) { const handlerName = String(messageName); // handle known message names, such as DDG webkit messaging if (settings.handlerStrategies.reflect.includes(handlerName)) { - return Reflect.get(target, messageName, receiver) + return Reflect.get(target, messageName, receiver); } if (settings.handlerStrategies.undefined.includes(handlerName)) { - return undefined + return undefined; } - if (settings.handlerStrategies.polyfill.includes('*') || - settings.handlerStrategies.polyfill.includes(handlerName) - ) { - return { - postMessage () { - return Promise.resolve({}) - } - } + if (settings.handlerStrategies.polyfill.includes('*') || settings.handlerStrategies.polyfill.includes(handlerName)) { + return { + postMessage() { + return Promise.resolve({}); + }, + }; } // if we get here, we couldn't handle the message handler name, so we opt for doing nothing. // It's unlikely we'll ever reach here, since `["*"]' should be present - } + }, }); globalThis.webkit = { ...globalThis.webkit, - messageHandlers: proxy + messageHandlers: proxy, }; } - viewportWidthFix () { + viewportWidthFix() { if (document.readyState === 'loading') { // if the document is not ready, we may miss the original viewport tag document.addEventListener('DOMContentLoaded', () => this.viewportWidthFixInner()); @@ -7067,7 +7261,7 @@ * @param {HTMLMetaElement|null} viewportTag * @param {string} forcedValue */ - forceViewportTag (viewportTag, forcedValue) { + forceViewportTag(viewportTag, forcedValue) { const viewportTagExists = Boolean(viewportTag); if (!viewportTag) { viewportTag = document.createElement('meta'); @@ -7079,7 +7273,7 @@ } } - viewportWidthFixInner () { + viewportWidthFixInner() { /** @type {NodeListOf} **/ const viewportTags = document.querySelectorAll('meta[name=viewport i]'); // Chrome respects only the last viewport tag @@ -7089,18 +7283,18 @@ const viewportContentParts = viewportContent ? viewportContent.split(/,|;/) : []; /** @type {readonly string[][]} **/ const parsedViewportContent = viewportContentParts.map((part) => { - const [key, value] = part.split('=').map(p => p.trim().toLowerCase()); - return [key, value] + const [key, value] = part.split('=').map((p) => p.trim().toLowerCase()); + return [key, value]; }); // first, check if there are any forced values const { forcedDesktopValue, forcedMobileValue } = this.getFeatureSetting('viewportWidth'); if (typeof forcedDesktopValue === 'string' && this.desktopModeEnabled) { this.forceViewportTag(viewportTag, forcedDesktopValue); - return + return; } else if (typeof forcedMobileValue === 'string' && !this.desktopModeEnabled) { this.forceViewportTag(viewportTag, forcedMobileValue); - return + return; } // otherwise, check for special cases @@ -7124,7 +7318,8 @@ // Race condition: depending on the loading state of the page, initial scale may or may not be respected, so the page may look zoomed-in after applying this hack. // Usually this is just an annoyance, but it may be a bigger issue if user-scalable=no is set, so we remove it too. forcedValues['user-scalable'] = 'yes'; - } else { // mobile mode with a viewport tag + } else { + // mobile mode with a viewport tag // fix an edge case where WebView forces the wide viewport const widthPart = parsedViewportContent.find(([key]) => key === 'width'); const initialScalePart = parsedViewportContent.find(([key]) => key === 'initial-scale'); @@ -7142,7 +7337,8 @@ newContent.push(`${key}=${forcedValues[key]}`); }); - if (newContent.length > 0) { // need to override at least one viewport component + if (newContent.length > 0) { + // need to override at least one viewport component parsedViewportContent.forEach(([key], idx) => { if (!(key in forcedValues)) { newContent.push(viewportContentParts[idx].trim()); // reuse the original values, not the parsed ones @@ -7153,19 +7349,28 @@ } } - const logoImg = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFQAAABUCAYAAAAcaxDBAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABNTSURBVHgBzV0LcFPXmf6PJFt+gkEY8wrYMSEbgst7m02ywZnOZiEJCQlJC+QB25lNs7OzlEJ2ptmZLGayfUy3EEhmW5rM7gCZBtjJgzxmSTvTRSST9IF5pCE0TUosmmBjHIKNZFmWLN2e78hHPvfqXuleSdfONyNLV7q6uve7//uc85vRlwAda25oTFK8lZGn0UPaLI2okUhrTH/KGnU7M+olTevlL0KaeM3e01LaKa/PE2p64dgpGmMwGgN0rGqtS1Ve2cB/fhk/gVbSqI5KAU4wvxlBTdNe9VJ5sOnAb0I0yhg1QiWJTGN3E0gcHQRTpO0dTXJdJ7RjzZJWflHrGaNVdiTRN2kalTfOIU9VLfnqp5ruM9TTxR+dlIqGKX7uI7IDLrl7PFS2zW1iXSMURGqkbaUc0uiprqWqxa1UOXcxVcxdxAmcRoUApMZDH9HAmeMU+8NxQbYV3Ca25ITCwaRY4immcYk0AUgcv3wtJ3CxeLgBEBw++jpF249akusWsSUltGPNoq0aY5vMVLviusU04b5HbJMoVLo/ItRaBUyBp7rGtjTHuNSGj75BkbdeN/2ckdbWdODENioRSkIopFLThl4hpi0wflZzy0pO5D9aEiDsIFfXQagtf4CAXCqronzWHHFc3CQ/f53rZuGYl198zorYEKOyW0shrUUT2rFu8bc1jdqMUplLIkFi9NhRCvOLA4mp/jCVAjAn+N2qJa1UvXSZkGYjQOylfTu4OQjqPxAhl7atef+JnVQEiiK0Y+2ipzSNq7gCXFT9o1vFRRkB6evnFxJ5642SkWgF4fD4OUxYba4dEW4GLr/0bJY2FGsCCiIUMaVWEX6FDB4cF1D/T1uzJANE4uTxPBaoWbbSlNgcZiDIYsl7mg6d6iWHcEyolb0MPLyFxq1Yq9sXqg31ihx9nb4MsCK298VnxQ3XQaNTjJXd49SuOiJUkEmJIyRy7TSgWg2bf5xlK/sO76defpJuq7ZTgMy61Y9Q7bI7de/Dlndvf8xoAhw7K9uECjX3R46okomTm/rEbt0dh1TixIzqDeI9lSPZD/ZDWDT0uT2PXmqYSSvI7HryUT2pkNTB5K121d82oZ+sWQzJbJXbZmRa3GWBces2UuXX7qOKigryeDy6z0A+wqbosaDIdEYLZtdgSiq3qVcfOH6rnWPaIlQE7MTacp1ImHvuL/Ztz63iE+qpZtN2qp8z13IX6Siix4OjYi7gQCdy+6+aADNSecKys3l/+3fyHc+bb4d0nMl+KLfNyIS9vPTfPyAtEbc8jvjevz5F45r/inIBpqF6aSvV/M1twiTYLX4UCpwzYlIRw17TMnIOS5aJ8E5eE5e8Gza2TO17+nTXb3IdLyehaSeUOsBfVsj3pv77z6hsWmNmH5AJycwFQeb3nqfBqvHU399P4XBYPMfjcWK8DOXz+bK+I4mFCo2GGRh479dZpFbMbhGkSvBzvWHTvFkHd53+zNKe5lR5bjc7SPHoE7h3rOPZjwTU/POftlE+4ORS5ZVEly+OvDm1UTw0bldRsmtoaCC/32/6/SvQgDw3rVSY9GibTv2zfps7qasPHl9o9X1LCYXd5HxnKkbIyQPrt2Q+h325uOOxnGqeOQfsE+vXvxnhN7krROzd/6PUlJkU9nOJrK4mrzf7lPxcaiCt0IxE57msgkkpAQdZNf9G8tYFMr8Ns5PoDKV3YDRl47zp7OnTnUGz75tK6HC82SG3jXbTwhM6Q0U1sZvvFERVz77e1PtbwSptLBVwndN/+PNMxocb+OnGu0acJM/7mVa20Cw+Nb2CFCW2qtsIhFUndPml5wq/mAmTiT2yjep2HKKZ/7CF6r+ylKqqqmyTCdRwlcQNRmXfDeDaEP5JgFjUJzLghSDUfM2+m3UVkE4uthvkNvJz1aZAOgpNJbWv3U/jnnyeZi5bQRMmTHBEohFprfmZa6RC9eFwJcCDmg2igI5RCeP3sq7IKJ2BhzdnXosY0Zjz2gHUm0vltAe/TYFAoCgiVUByQGqhQyf5gBxftddwyiqGh3j056RuGKUTjqhoVR8mc8bf/r2wk6VGmtTdIpIoNWRxRwISCk4UtBqlVEeoUTpRaZcAkYWoOtQ8MG+xaaxZKuCmj1u+ltwArlmtS6icABjRVbczhNqRTqfQFvGM57avU21t6aXnvTOd9PKb79O+l9rpnfYOGn/7WlekFFDNnBxykcDweMeqBZnRigyhmAqjHsSY2xbkiLh0Tpw4MbMZiQ5yAo7T1h2/oG89/iL9aHeQLvQ4jynfaQ8JEqsry6lhUi2dPXeJdr/4vmtSCgnVSalqS+HxK30b5GZGD73E1mvyTcNdKEg6m3hsOeWqjKqDuMf+43VOQA09vHoJNTcGqKbKL0h2ipuWNIqHEaloC115c78rRRUM3UhO8Cyyv+HfYZqG2TBiLEpIaDqQHynNVfHCwMhJhrMHtOzguqUi85GAet52y7W0/Ym7aP7caYJMQD6XAnBQmDjhBhAuqh7foA2tUu0FoVnqrngyjE4WdMeb5upy83uXt3DJdGdigwpjJb5UAJn9nAuJSsMIhVR7QejwBC4BqLsaLPcXIp0Az7vLy8szm1Pq3XEYRoh5US45J3UwT6q9BFf7VjynCfWMqDvGtVUUVDrjhWRx8BIF8FaQTk46OGxD7TEBwg1gQoaq9jrzwkjYSU/H/UsXqJMUVGcEz1aIumt1k/OSibDnP3cfoZ/se7cgTw/8ZN+vRdjUzb+/ekUL/fJouhjtFqFylouETu05h/BFnqQv1ah+ya+czKBL1XKQsIV7/F+89VFGygrx9t09V8RzJBrnEnpEhFOAf9a15BZUTjBjUEWSkq0ebj914+uq/SxmYkIqlbL87J3joczrmqp0Ovpue4icAtGCBGJRue1WwQRQJdRYQ2CkNfpI0+bLqqhRVYod4gWpZqof6R8pSr/85u/F880mcWU+IJ6Fs4NkNs8KZKIIT1UNuQWjTwGpsr6B9QE+D6M6GdAbp9Cod8MJWO9FzL+0JHT1innC/kmAlBsLIBRAbIuHCjte3sMVo2o2FyLuP+N8ZCbyAdmCsTgEIZTv8ZHhRp8mVlukRdQ4Pl0wBqLiCYNwZkWRe5d/RQT0cEwNnMx7V7RQKWE26068P0xi7fXc/l2l/8wuoQC4kVzpfwsqz1gdDYuoOqc9FY1QwcD4USxKiUTCchczySoVZGjjG8clqIGTN4M7qsnZJErEPiVHwPA2pSPDrHUAPquFBEXnw5zUoaEhKhpJfh69PEMZ5BoT78q/L394+H6z/oVLj42sNsWDi543yRFyDBI2ulek5KOEA5OnU8EY4Pb7Uz58Gy4s0rBLZtdBrsJ9VDK4R+jlnsIl9NIbRKE2chNQc0hmKckE3CP0Qkh4eTgmNafPi3ina2RCIsOnecHnT87tpl1wQrVQ1npKoqILDKzjA+HrBgYGnBHamb/2CmLiF7Pf940f/jyW3gfSl+DJ1BB/xP6cfi4FrKIIjNfrJBQr1Ea+VGRwzFUenn5w0OFxon/M+XHPYWchjhvAsh4JlTMuQb08rmchua16r5IMzXZ1UCwWc/adpHW4BiLHmkxAF6/rskkW8nC1PCc3jVMHiya185xwTI6cU611ETrp8N64AWN6rg+htD5O6IiEGrMjY23UMTrOiCfYUdsIWFfcx/PTKZ9MYwqjkKnpOefyFCc0FVJ3UEkttmoDxyR+NJ5/hl4GkNDASsuPpz/Mk5QVY0esWi82ajQv3Z3yeSkV1JRZjQNnTvBxmfRd8BdbqEUKygP8ft9sMQXHNq7azE+EO6eoeXGm5vr0A148zn3f4MW0V0+ZlFSRfiLILxufjgJkwA+v7zRDAlROsopHzBPyNR04Ffpk7eJemYKiBioHuuT4TFFpKFf7IT6+ZFV5MoWXhyXXvcBvxrPcsVnPpfINk4SCh2MUsOQN4ZIqoQNqKY+HTGjRIa5QS1FQvq8OGZdkfIYH+ACmgDvGtEeIWl7LaQIKQR/n4dIRcgzjWixdAV4jMSSaFhkPy4yPwmupO9beUtzFsDPHxLMjO6qinJufxq1pYhvbKOUp7AbDHIBI5O5fHEkH/06hrl+F/VT9Da/WH8KzCOw9/qE9WsybmUCKzgjyblRhVe/zRag97GhvD7ejPmd21AhO7BAfVTn/X9sxeCMKw3BM/vqRDEkFCEOWBBuLrMoss3ICaCtWOEuEs6YmpYL4Kwht2nOqt2PN4qCcPYKJ+hOGFyfgQDW33CneKxgfHKOhm253ZkdNgAmw8sYiF3crHzcDpFNNOdEtYgQsCF+EV5mrSzH2aua1Qe2rTZZqO0IxdlSBKOyOEdRpjMYmCYxSe+XrDKFQe9FkahjqFL5i+4MUbUfHGMapnWFl7VIaaXUHMoRC7bmnykip8S4Yp0M7grSjRUqom8PDuZBr4jGPvvZIdQd0Bo0XSvao2+o0RpPp0M4AO+o0rzfAqo+TEVE/o8MLy+hHd1fQQHlxXUDyTzxO6ro/6AhtOtAe5D8flNvG6dCB9ZsLr5MO5/XFSGmlDbMTvN5H2+73c0J99FmAie1CASKdSCdg4nKZjnHVlsLLFar6Mq93XM5TYMxUVFyqZfTMCj+9/NUynVT+9pq864MtYVyfpS5gSCOZ1Zsk69d2ne4MbWqZhuk5YtkwCqh+brvkglks1Ut378ozAmnEUEJMwk1yUurq9AOtF/o76YVP/ofe7v5/ev/ySUqk+LCJ10/Vvuzi9Nnuk/Re8iy9P8tLA34PNfSlhBTubS2n7rps+QC5X/04RZVxjZwg3R5pRHgw4bbvtT2Z7bR0ntxr/J7F0sQFjRrznpT5PSTjqmde0y3VO//dBxxPhtBu30DE49GpU6dSZWVl5v21h2+niC87cbi69hq6a+b91DJxIb392a/of//8PEWTepMBovq9Gnm81vHtA28nOKn2bbedpZiMkk1GdQdMzwI7ahrbJbdBYM9PR6QbxDZs+bFzezpsR41qf2HA/MZ8Ev6Ydn7wfXrglytp95mdWWQCkMBYbIA0zVoCv6ix75hwTcZ+AMb1Wbzuuc2MTPF9skDzgfY2fhsyDU5RNFGX6qFoEnhoMzmBtKNqwRnqXiwY81Aibj1LxQmhgYe2GMh81rgCJiS4sUDOPJBpyXvUYB+NBlSvj0YoaC9kG4hHOamQUDndcUr1NF7tym/ftBzTI7EkPJkjHBuwOeiKa6lR5uijAILliRlgFTIlc/YeyUmoUP2UpvNkxiYt6NXkiNTO9BCWGj5VeXOPjKLrg1bE53ZiUWPfKeOKZCCXqkvkrVQ0HzyxU2Oks6dGA40TwfJnOzaV/SGdhqpqP6V6ak4bCAlM8LTVah9I+1AiwR/mUjoxYn3sdGu5tiwys5q4cDKb97fn7Ytnq/TTvP/4JjXgN/tBqP/0H/w8/0hpV0iM10ej0cxbC+qXWpIhfo+rM8iMRvqFrcQjPhinAX6MSDhMc88O0sLzTLy+0ttHUS79g7FBcUyQXTFobi7kEvGaPB1xUE3KZTdV2I56Ny1peJWSnuX85RRspxeEHRXdY6Rkym4yObvZIB6dM5+0unqxOrmsrIy+iH1O73QeobLyMt2uIDHGJXmiN0Dfv/lp6rzyKSUScQqU1dOc2rnU0j+RVh3ppjs/9tEN5710z4c+uraH0cRwWmL7tDhFEjF6sJ1R3aBe7TGii4Y0+RthsVNscGjFrg8v2MpIHLZq4/EpeXWt2nBCaNVmLFzkamOh3XgH0R3rafz48aLoHEmE6Y5DN9G4upFKMSQQZK6evY6+Oe+fqaYs25zgpp3/7jpyAtx0ZHvGPn1wtt07HjMW0kNwQvnspgpHedmu0xd6N83jkso8raRIavhXL4lbo+baINhKWhk88l//HSWTSUEqsqKTF39H3dEu7q2TQpUDvkn0vZt20arZ3xCfm558XcBR1obsZ8rjT5v26et55t/0DWkgmSy5wgmZ4tqoAHRsWFBHMe8rmqHdpZO2ktoTe7jeVdGMGTPEZLKPL39IG498U5zQfXMepK9f+5CpVBoByep68ls597FqDisTluy1rCzIYkOj0+5Sxdk1S9qYoU2EVfdDQG3Dlly2WqSh6D2CBwDVt0OiEecfX5c1Rg7VxtBNtaFXiARI7Nm9LWusjJvtXc0Hj2+iAlF0y+Cz31i0iXnYVuPUcozBoF+JmdcXDu2zEEXG1YsYEk2wioHsbgYSy2fO4TdzZXpw0WTaoWVzWNEy2F5olAslamqd7awkrMxAKSGXDMp/KGCGdAOa58wbKQh7yVXcob00Q0kIlTAzARIgtparoFu9662Qs10xpJIXgezGmHZQUkKBYWlt4y/Xm30OSUWDA0ygcLPnEqbJXDls3d2BW5pDpCW/Uwqp1B2XXEI+YgHZigNeGJOwCiUY6hw7c0KQCGeTe1IGwzDPNgz3kAtwjVAJO8SqQFkQzgVk+yZZ/HOVz7sEacbpMJYQveq4RBLb6xaRIz81SgCxSfK0esmzXqN09wP3waWRpV6lgdSeQmLKgn6RxgAZcpnnbkFuCf9BFR8KD3K/f3Q0SdSfwpcAHevQVSLVmNLYAg+j+SBYLOrlNQ0TskP4k15swUIp0s5hFvZY/YcvI/4CeAZjCToTSnsAAAAASUVORK5CYII='; + const logoImg = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFQAAABUCAYAAAAcaxDBAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABNTSURBVHgBzV0LcFPXmf6PJFt+gkEY8wrYMSEbgst7m02ywZnOZiEJCQlJC+QB25lNs7OzlEJ2ptmZLGayfUy3EEhmW5rM7gCZBtjJgzxmSTvTRSST9IF5pCE0TUosmmBjHIKNZFmWLN2e78hHPvfqXuleSdfONyNLV7q6uve7//uc85vRlwAda25oTFK8lZGn0UPaLI2okUhrTH/KGnU7M+olTevlL0KaeM3e01LaKa/PE2p64dgpGmMwGgN0rGqtS1Ve2cB/fhk/gVbSqI5KAU4wvxlBTdNe9VJ5sOnAb0I0yhg1QiWJTGN3E0gcHQRTpO0dTXJdJ7RjzZJWflHrGaNVdiTRN2kalTfOIU9VLfnqp5ruM9TTxR+dlIqGKX7uI7IDLrl7PFS2zW1iXSMURGqkbaUc0uiprqWqxa1UOXcxVcxdxAmcRoUApMZDH9HAmeMU+8NxQbYV3Ca25ITCwaRY4immcYk0AUgcv3wtJ3CxeLgBEBw++jpF249akusWsSUltGPNoq0aY5vMVLviusU04b5HbJMoVLo/ItRaBUyBp7rGtjTHuNSGj75BkbdeN/2ckdbWdODENioRSkIopFLThl4hpi0wflZzy0pO5D9aEiDsIFfXQagtf4CAXCqronzWHHFc3CQ/f53rZuGYl198zorYEKOyW0shrUUT2rFu8bc1jdqMUplLIkFi9NhRCvOLA4mp/jCVAjAn+N2qJa1UvXSZkGYjQOylfTu4OQjqPxAhl7atef+JnVQEiiK0Y+2ipzSNq7gCXFT9o1vFRRkB6evnFxJ5642SkWgF4fD4OUxYba4dEW4GLr/0bJY2FGsCCiIUMaVWEX6FDB4cF1D/T1uzJANE4uTxPBaoWbbSlNgcZiDIYsl7mg6d6iWHcEyolb0MPLyFxq1Yq9sXqg31ihx9nb4MsCK298VnxQ3XQaNTjJXd49SuOiJUkEmJIyRy7TSgWg2bf5xlK/sO76defpJuq7ZTgMy61Y9Q7bI7de/Dlndvf8xoAhw7K9uECjX3R46okomTm/rEbt0dh1TixIzqDeI9lSPZD/ZDWDT0uT2PXmqYSSvI7HryUT2pkNTB5K121d82oZ+sWQzJbJXbZmRa3GWBces2UuXX7qOKigryeDy6z0A+wqbosaDIdEYLZtdgSiq3qVcfOH6rnWPaIlQE7MTacp1ImHvuL/Ztz63iE+qpZtN2qp8z13IX6Siix4OjYi7gQCdy+6+aADNSecKys3l/+3fyHc+bb4d0nMl+KLfNyIS9vPTfPyAtEbc8jvjevz5F45r/inIBpqF6aSvV/M1twiTYLX4UCpwzYlIRw17TMnIOS5aJ8E5eE5e8Gza2TO17+nTXb3IdLyehaSeUOsBfVsj3pv77z6hsWmNmH5AJycwFQeb3nqfBqvHU399P4XBYPMfjcWK8DOXz+bK+I4mFCo2GGRh479dZpFbMbhGkSvBzvWHTvFkHd53+zNKe5lR5bjc7SPHoE7h3rOPZjwTU/POftlE+4ORS5ZVEly+OvDm1UTw0bldRsmtoaCC/32/6/SvQgDw3rVSY9GibTv2zfps7qasPHl9o9X1LCYXd5HxnKkbIyQPrt2Q+h325uOOxnGqeOQfsE+vXvxnhN7krROzd/6PUlJkU9nOJrK4mrzf7lPxcaiCt0IxE57msgkkpAQdZNf9G8tYFMr8Ns5PoDKV3YDRl47zp7OnTnUGz75tK6HC82SG3jXbTwhM6Q0U1sZvvFERVz77e1PtbwSptLBVwndN/+PNMxocb+OnGu0acJM/7mVa20Cw+Nb2CFCW2qtsIhFUndPml5wq/mAmTiT2yjep2HKKZ/7CF6r+ylKqqqmyTCdRwlcQNRmXfDeDaEP5JgFjUJzLghSDUfM2+m3UVkE4uthvkNvJz1aZAOgpNJbWv3U/jnnyeZi5bQRMmTHBEohFprfmZa6RC9eFwJcCDmg2igI5RCeP3sq7IKJ2BhzdnXosY0Zjz2gHUm0vltAe/TYFAoCgiVUByQGqhQyf5gBxftddwyiqGh3j056RuGKUTjqhoVR8mc8bf/r2wk6VGmtTdIpIoNWRxRwISCk4UtBqlVEeoUTpRaZcAkYWoOtQ8MG+xaaxZKuCmj1u+ltwArlmtS6icABjRVbczhNqRTqfQFvGM57avU21t6aXnvTOd9PKb79O+l9rpnfYOGn/7WlekFFDNnBxykcDweMeqBZnRigyhmAqjHsSY2xbkiLh0Tpw4MbMZiQ5yAo7T1h2/oG89/iL9aHeQLvQ4jynfaQ8JEqsry6lhUi2dPXeJdr/4vmtSCgnVSalqS+HxK30b5GZGD73E1mvyTcNdKEg6m3hsOeWqjKqDuMf+43VOQA09vHoJNTcGqKbKL0h2ipuWNIqHEaloC115c78rRRUM3UhO8Cyyv+HfYZqG2TBiLEpIaDqQHynNVfHCwMhJhrMHtOzguqUi85GAet52y7W0/Ym7aP7caYJMQD6XAnBQmDjhBhAuqh7foA2tUu0FoVnqrngyjE4WdMeb5upy83uXt3DJdGdigwpjJb5UAJn9nAuJSsMIhVR7QejwBC4BqLsaLPcXIp0Az7vLy8szm1Pq3XEYRoh5US45J3UwT6q9BFf7VjynCfWMqDvGtVUUVDrjhWRx8BIF8FaQTk46OGxD7TEBwg1gQoaq9jrzwkjYSU/H/UsXqJMUVGcEz1aIumt1k/OSibDnP3cfoZ/se7cgTw/8ZN+vRdjUzb+/ekUL/fJouhjtFqFylouETu05h/BFnqQv1ah+ya+czKBL1XKQsIV7/F+89VFGygrx9t09V8RzJBrnEnpEhFOAf9a15BZUTjBjUEWSkq0ebj914+uq/SxmYkIqlbL87J3joczrmqp0Ovpue4icAtGCBGJRue1WwQRQJdRYQ2CkNfpI0+bLqqhRVYod4gWpZqof6R8pSr/85u/F880mcWU+IJ6Fs4NkNs8KZKIIT1UNuQWjTwGpsr6B9QE+D6M6GdAbp9Cod8MJWO9FzL+0JHT1innC/kmAlBsLIBRAbIuHCjte3sMVo2o2FyLuP+N8ZCbyAdmCsTgEIZTv8ZHhRp8mVlukRdQ4Pl0wBqLiCYNwZkWRe5d/RQT0cEwNnMx7V7RQKWE26068P0xi7fXc/l2l/8wuoQC4kVzpfwsqz1gdDYuoOqc9FY1QwcD4USxKiUTCchczySoVZGjjG8clqIGTN4M7qsnZJErEPiVHwPA2pSPDrHUAPquFBEXnw5zUoaEhKhpJfh69PEMZ5BoT78q/L394+H6z/oVLj42sNsWDi543yRFyDBI2ulek5KOEA5OnU8EY4Pb7Uz58Gy4s0rBLZtdBrsJ9VDK4R+jlnsIl9NIbRKE2chNQc0hmKckE3CP0Qkh4eTgmNafPi3ina2RCIsOnecHnT87tpl1wQrVQ1npKoqILDKzjA+HrBgYGnBHamb/2CmLiF7Pf940f/jyW3gfSl+DJ1BB/xP6cfi4FrKIIjNfrJBQr1Ea+VGRwzFUenn5w0OFxon/M+XHPYWchjhvAsh4JlTMuQb08rmchua16r5IMzXZ1UCwWc/adpHW4BiLHmkxAF6/rskkW8nC1PCc3jVMHiya185xwTI6cU611ETrp8N64AWN6rg+htD5O6IiEGrMjY23UMTrOiCfYUdsIWFfcx/PTKZ9MYwqjkKnpOefyFCc0FVJ3UEkttmoDxyR+NJ5/hl4GkNDASsuPpz/Mk5QVY0esWi82ajQv3Z3yeSkV1JRZjQNnTvBxmfRd8BdbqEUKygP8ft9sMQXHNq7azE+EO6eoeXGm5vr0A148zn3f4MW0V0+ZlFSRfiLILxufjgJkwA+v7zRDAlROsopHzBPyNR04Ffpk7eJemYKiBioHuuT4TFFpKFf7IT6+ZFV5MoWXhyXXvcBvxrPcsVnPpfINk4SCh2MUsOQN4ZIqoQNqKY+HTGjRIa5QS1FQvq8OGZdkfIYH+ACmgDvGtEeIWl7LaQIKQR/n4dIRcgzjWixdAV4jMSSaFhkPy4yPwmupO9beUtzFsDPHxLMjO6qinJufxq1pYhvbKOUp7AbDHIBI5O5fHEkH/06hrl+F/VT9Da/WH8KzCOw9/qE9WsybmUCKzgjyblRhVe/zRag97GhvD7ejPmd21AhO7BAfVTn/X9sxeCMKw3BM/vqRDEkFCEOWBBuLrMoss3ICaCtWOEuEs6YmpYL4Kwht2nOqt2PN4qCcPYKJ+hOGFyfgQDW33CneKxgfHKOhm253ZkdNgAmw8sYiF3crHzcDpFNNOdEtYgQsCF+EV5mrSzH2aua1Qe2rTZZqO0IxdlSBKOyOEdRpjMYmCYxSe+XrDKFQe9FkahjqFL5i+4MUbUfHGMapnWFl7VIaaXUHMoRC7bmnykip8S4Yp0M7grSjRUqom8PDuZBr4jGPvvZIdQd0Bo0XSvao2+o0RpPp0M4AO+o0rzfAqo+TEVE/o8MLy+hHd1fQQHlxXUDyTzxO6ro/6AhtOtAe5D8flNvG6dCB9ZsLr5MO5/XFSGmlDbMTvN5H2+73c0J99FmAie1CASKdSCdg4nKZjnHVlsLLFar6Mq93XM5TYMxUVFyqZfTMCj+9/NUynVT+9pq864MtYVyfpS5gSCOZ1Zsk69d2ne4MbWqZhuk5YtkwCqh+brvkglks1Ut378ozAmnEUEJMwk1yUurq9AOtF/o76YVP/ofe7v5/ev/ySUqk+LCJ10/Vvuzi9Nnuk/Re8iy9P8tLA34PNfSlhBTubS2n7rps+QC5X/04RZVxjZwg3R5pRHgw4bbvtT2Z7bR0ntxr/J7F0sQFjRrznpT5PSTjqmde0y3VO//dBxxPhtBu30DE49GpU6dSZWVl5v21h2+niC87cbi69hq6a+b91DJxIb392a/of//8PEWTepMBovq9Gnm81vHtA28nOKn2bbedpZiMkk1GdQdMzwI7ahrbJbdBYM9PR6QbxDZs+bFzezpsR41qf2HA/MZ8Ev6Ydn7wfXrglytp95mdWWQCkMBYbIA0zVoCv6ix75hwTcZ+AMb1Wbzuuc2MTPF9skDzgfY2fhsyDU5RNFGX6qFoEnhoMzmBtKNqwRnqXiwY81Aibj1LxQmhgYe2GMh81rgCJiS4sUDOPJBpyXvUYB+NBlSvj0YoaC9kG4hHOamQUDndcUr1NF7tym/ftBzTI7EkPJkjHBuwOeiKa6lR5uijAILliRlgFTIlc/YeyUmoUP2UpvNkxiYt6NXkiNTO9BCWGj5VeXOPjKLrg1bE53ZiUWPfKeOKZCCXqkvkrVQ0HzyxU2Oks6dGA40TwfJnOzaV/SGdhqpqP6V6ak4bCAlM8LTVah9I+1AiwR/mUjoxYn3sdGu5tiwys5q4cDKb97fn7Ytnq/TTvP/4JjXgN/tBqP/0H/w8/0hpV0iM10ej0cxbC+qXWpIhfo+rM8iMRvqFrcQjPhinAX6MSDhMc88O0sLzTLy+0ttHUS79g7FBcUyQXTFobi7kEvGaPB1xUE3KZTdV2I56Ny1peJWSnuX85RRspxeEHRXdY6Rkym4yObvZIB6dM5+0unqxOrmsrIy+iH1O73QeobLyMt2uIDHGJXmiN0Dfv/lp6rzyKSUScQqU1dOc2rnU0j+RVh3ppjs/9tEN5710z4c+uraH0cRwWmL7tDhFEjF6sJ1R3aBe7TGii4Y0+RthsVNscGjFrg8v2MpIHLZq4/EpeXWt2nBCaNVmLFzkamOh3XgH0R3rafz48aLoHEmE6Y5DN9G4upFKMSQQZK6evY6+Oe+fqaYs25zgpp3/7jpyAtx0ZHvGPn1wtt07HjMW0kNwQvnspgpHedmu0xd6N83jkso8raRIavhXL4lbo+baINhKWhk88l//HSWTSUEqsqKTF39H3dEu7q2TQpUDvkn0vZt20arZ3xCfm558XcBR1obsZ8rjT5v26et55t/0DWkgmSy5wgmZ4tqoAHRsWFBHMe8rmqHdpZO2ktoTe7jeVdGMGTPEZLKPL39IG498U5zQfXMepK9f+5CpVBoByep68ls597FqDisTluy1rCzIYkOj0+5Sxdk1S9qYoU2EVfdDQG3Dlly2WqSh6D2CBwDVt0OiEecfX5c1Rg7VxtBNtaFXiARI7Nm9LWusjJvtXc0Hj2+iAlF0y+Cz31i0iXnYVuPUcozBoF+JmdcXDu2zEEXG1YsYEk2wioHsbgYSy2fO4TdzZXpw0WTaoWVzWNEy2F5olAslamqd7awkrMxAKSGXDMp/KGCGdAOa58wbKQh7yVXcob00Q0kIlTAzARIgtparoFu9662Qs10xpJIXgezGmHZQUkKBYWlt4y/Xm30OSUWDA0ygcLPnEqbJXDls3d2BW5pDpCW/Uwqp1B2XXEI+YgHZigNeGJOwCiUY6hw7c0KQCGeTe1IGwzDPNgz3kAtwjVAJO8SqQFkQzgVk+yZZ/HOVz7sEacbpMJYQveq4RBLb6xaRIz81SgCxSfK0esmzXqN09wP3waWRpV6lgdSeQmLKgn6RxgAZcpnnbkFuCf9BFR8KD3K/f3Q0SdSfwpcAHevQVSLVmNLYAg+j+SBYLOrlNQ0TskP4k15swUIp0s5hFvZY/YcvI/4CeAZjCToTSnsAAAAASUVORK5CYII='; const loadingImages = { - darkMode: 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%40keyframes%20rotate%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20from%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%280deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20to%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%28359deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%20%20%20%20%3Cg%20style%3D%22transform-origin%3A%2050%25%2050%25%3B%20animation%3A%20rotate%201s%20infinite%20reverse%20linear%3B%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2218.0968%22%20y%3D%2216.0861%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%2018.0968%2016.0861%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.1%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.4%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2219.9976%22%20y%3D%228.37451%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%2019.9976%208.37451%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.2%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2216.1727%22%20y%3D%221.9917%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%2016.1727%201.9917%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.3%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.91309%22%20y%3D%226.88501%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%208.91309%206.88501%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%226.79602%22%20y%3D%2210.996%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%206.79602%2010.996%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.7%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%227%22%20y%3D%228.62549%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%207%208.62549%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.8%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20y%3D%2213%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.9%22%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fsvg%3E', - lightMode: 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%40keyframes%20rotate%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20from%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%280deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20to%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%28359deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%20%20%20%20%3Cg%20style%3D%22transform-origin%3A%2050%25%2050%25%3B%20animation%3A%20rotate%201s%20infinite%20reverse%20linear%3B%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2218.0968%22%20y%3D%2216.0861%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%2018.0968%2016.0861%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.1%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.4%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2219.9976%22%20y%3D%228.37451%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%2019.9976%208.37451%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.2%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2216.1727%22%20y%3D%221.9917%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%2016.1727%201.9917%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.3%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.91309%22%20y%3D%226.88501%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%208.91309%206.88501%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%226.79602%22%20y%3D%2210.996%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%206.79602%2010.996%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.7%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%227%22%20y%3D%228.62549%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%207%208.62549%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.8%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20y%3D%2213%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.9%22%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fsvg%3E' // 'data:application/octet-stream;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCTxzdHlsZT4KCQlAa2V5ZnJhbWVzIHJvdGF0ZSB7CgkJCWZyb20gewoJCQkJdHJhbnNmb3JtOiByb3RhdGUoMGRlZyk7CgkJCX0KCQkJdG8gewoJCQkJdHJhbnNmb3JtOiByb3RhdGUoMzU5ZGVnKTsKCQkJfQoJCX0KCTwvc3R5bGU+Cgk8ZyBzdHlsZT0idHJhbnNmb3JtLW9yaWdpbjogNTAlIDUwJTsgYW5pbWF0aW9uOiByb3RhdGUgMXMgaW5maW5pdGUgcmV2ZXJzZSBsaW5lYXI7Ij4KCQk8cmVjdCB4PSIxOC4wOTY4IiB5PSIxNi4wODYxIiB3aWR0aD0iMyIgaGVpZ2h0PSI3IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSgxMzYuMTYxIDE4LjA5NjggMTYuMDg2MSkiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4xIi8+CQoJCTxyZWN0IHg9IjguNDk4NzgiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC40Ii8+CgkJPHJlY3QgeD0iMTkuOTk3NiIgeT0iOC4zNzQ1MSIgd2lkdGg9IjMiIGhlaWdodD0iNyIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoOTAgMTkuOTk3NiA4LjM3NDUxKSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjIiLz4KCQk8cmVjdCB4PSIxNi4xNzI3IiB5PSIxLjk5MTciIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ2LjE2MDcgMTYuMTcyNyAxLjk5MTcpIiBmaWxsPSIjZmZmZmZmIiBmaWxsLW9wYWNpdHk9IjAuMyIvPgoJCTxyZWN0IHg9IjguOTEzMDkiIHk9IjYuODg1MDEiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDEzNi4xNjEgOC45MTMwOSA2Ljg4NTAxKSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjYiLz4KCQk8cmVjdCB4PSI2Ljc5NjAyIiB5PSIxMC45OTYiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ2LjE2MDcgNi43OTYwMiAxMC45OTYpIiBmaWxsPSIjZmZmZmZmIiBmaWxsLW9wYWNpdHk9IjAuNyIvPgoJCTxyZWN0IHg9IjciIHk9IjguNjI1NDkiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDkwIDcgOC42MjU0OSkiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC44Ii8+CQkKCQk8cmVjdCB4PSI4LjQ5ODc4IiB5PSIxMyIgd2lkdGg9IjMiIGhlaWdodD0iNyIgcng9IjEuNSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjkiLz4KCTwvZz4KPC9zdmc+Cg==' + darkMode: + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%40keyframes%20rotate%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20from%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%280deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20to%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%28359deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%20%20%20%20%3Cg%20style%3D%22transform-origin%3A%2050%25%2050%25%3B%20animation%3A%20rotate%201s%20infinite%20reverse%20linear%3B%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2218.0968%22%20y%3D%2216.0861%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%2018.0968%2016.0861%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.1%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.4%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2219.9976%22%20y%3D%228.37451%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%2019.9976%208.37451%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.2%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2216.1727%22%20y%3D%221.9917%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%2016.1727%201.9917%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.3%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.91309%22%20y%3D%226.88501%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%208.91309%206.88501%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%226.79602%22%20y%3D%2210.996%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%206.79602%2010.996%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.7%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%227%22%20y%3D%228.62549%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%207%208.62549%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.8%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20y%3D%2213%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.9%22%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fsvg%3E', + lightMode: + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%40keyframes%20rotate%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20from%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%280deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20to%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20transform%3A%20rotate%28359deg%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%20%20%20%20%3Cg%20style%3D%22transform-origin%3A%2050%25%2050%25%3B%20animation%3A%20rotate%201s%20infinite%20reverse%20linear%3B%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2218.0968%22%20y%3D%2216.0861%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%2018.0968%2016.0861%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.1%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.4%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2219.9976%22%20y%3D%228.37451%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%2019.9976%208.37451%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.2%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%2216.1727%22%20y%3D%221.9917%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%2016.1727%201.9917%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.3%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.91309%22%20y%3D%226.88501%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%28136.161%208.91309%206.88501%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%226.79602%22%20y%3D%2210.996%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2846.1607%206.79602%2010.996%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.7%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%227%22%20y%3D%228.62549%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20transform%3D%22rotate%2890%207%208.62549%29%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.8%22%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20x%3D%228.49878%22%20y%3D%2213%22%20width%3D%223%22%20height%3D%227%22%20rx%3D%221.5%22%20fill%3D%22%23111111%22%20fill-opacity%3D%220.9%22%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fsvg%3E', // 'data:application/octet-stream;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCTxzdHlsZT4KCQlAa2V5ZnJhbWVzIHJvdGF0ZSB7CgkJCWZyb20gewoJCQkJdHJhbnNmb3JtOiByb3RhdGUoMGRlZyk7CgkJCX0KCQkJdG8gewoJCQkJdHJhbnNmb3JtOiByb3RhdGUoMzU5ZGVnKTsKCQkJfQoJCX0KCTwvc3R5bGU+Cgk8ZyBzdHlsZT0idHJhbnNmb3JtLW9yaWdpbjogNTAlIDUwJTsgYW5pbWF0aW9uOiByb3RhdGUgMXMgaW5maW5pdGUgcmV2ZXJzZSBsaW5lYXI7Ij4KCQk8cmVjdCB4PSIxOC4wOTY4IiB5PSIxNi4wODYxIiB3aWR0aD0iMyIgaGVpZ2h0PSI3IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSgxMzYuMTYxIDE4LjA5NjggMTYuMDg2MSkiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4xIi8+CQoJCTxyZWN0IHg9IjguNDk4NzgiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC40Ii8+CgkJPHJlY3QgeD0iMTkuOTk3NiIgeT0iOC4zNzQ1MSIgd2lkdGg9IjMiIGhlaWdodD0iNyIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoOTAgMTkuOTk3NiA4LjM3NDUxKSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjIiLz4KCQk8cmVjdCB4PSIxNi4xNzI3IiB5PSIxLjk5MTciIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ2LjE2MDcgMTYuMTcyNyAxLjk5MTcpIiBmaWxsPSIjZmZmZmZmIiBmaWxsLW9wYWNpdHk9IjAuMyIvPgoJCTxyZWN0IHg9IjguOTEzMDkiIHk9IjYuODg1MDEiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDEzNi4xNjEgOC45MTMwOSA2Ljg4NTAxKSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjYiLz4KCQk8cmVjdCB4PSI2Ljc5NjAyIiB5PSIxMC45OTYiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ2LjE2MDcgNi43OTYwMiAxMC45OTYpIiBmaWxsPSIjZmZmZmZmIiBmaWxsLW9wYWNpdHk9IjAuNyIvPgoJCTxyZWN0IHg9IjciIHk9IjguNjI1NDkiIHdpZHRoPSIzIiBoZWlnaHQ9IjciIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDkwIDcgOC42MjU0OSkiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC44Ii8+CQkKCQk8cmVjdCB4PSI4LjQ5ODc4IiB5PSIxMyIgd2lkdGg9IjMiIGhlaWdodD0iNyIgcng9IjEuNSIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjkiLz4KCTwvZz4KPC9zdmc+Cg==' }; - const closeIcon = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5.99998%204.58578L10.2426%200.34314C10.6331%20-0.0473839%2011.2663%20-0.0473839%2011.6568%200.34314C12.0474%200.733665%2012.0474%201.36683%2011.6568%201.75735L7.41419%205.99999L11.6568%2010.2426C12.0474%2010.6332%2012.0474%2011.2663%2011.6568%2011.6568C11.2663%2012.0474%2010.6331%2012.0474%2010.2426%2011.6568L5.99998%207.41421L1.75734%2011.6568C1.36681%2012.0474%200.733649%2012.0474%200.343125%2011.6568C-0.0473991%2011.2663%20-0.0473991%2010.6332%200.343125%2010.2426L4.58577%205.99999L0.343125%201.75735C-0.0473991%201.36683%20-0.0473991%200.733665%200.343125%200.34314C0.733649%20-0.0473839%201.36681%20-0.0473839%201.75734%200.34314L5.99998%204.58578Z%22%20fill%3D%22%23222222%22%2F%3E%0A%3C%2Fsvg%3E'; + const closeIcon = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5.99998%204.58578L10.2426%200.34314C10.6331%20-0.0473839%2011.2663%20-0.0473839%2011.6568%200.34314C12.0474%200.733665%2012.0474%201.36683%2011.6568%201.75735L7.41419%205.99999L11.6568%2010.2426C12.0474%2010.6332%2012.0474%2011.2663%2011.6568%2011.6568C11.2663%2012.0474%2010.6331%2012.0474%2010.2426%2011.6568L5.99998%207.41421L1.75734%2011.6568C1.36681%2012.0474%200.733649%2012.0474%200.343125%2011.6568C-0.0473991%2011.2663%20-0.0473991%2010.6332%200.343125%2010.2426L4.58577%205.99999L0.343125%201.75735C-0.0473991%201.36683%20-0.0473991%200.733665%200.343125%200.34314C0.733649%20-0.0473839%201.36681%20-0.0473839%201.75734%200.34314L5.99998%204.58578Z%22%20fill%3D%22%23222222%22%2F%3E%0A%3C%2Fsvg%3E'; - const blockedFBLogo = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2280%22%20height%3D%2280%22%20viewBox%3D%220%200%2080%2080%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Ccircle%20cx%3D%2240%22%20cy%3D%2240%22%20r%3D%2240%22%20fill%3D%22white%22%2F%3E%0A%3Cg%20clip-path%3D%22url%28%23clip0%29%22%3E%0A%3Cpath%20d%3D%22M73.8457%2039.974C73.8457%2021.284%2058.7158%206.15405%2040.0258%206.15405C21.3358%206.15405%206.15344%2021.284%206.15344%2039.974C6.15344%2056.884%2018.5611%2070.8622%2034.7381%2073.4275V49.764H26.0999V39.974H34.7381V32.5399C34.7381%2024.0587%2039.764%2019.347%2047.5122%2019.347C51.2293%2019.347%2055.0511%2020.0799%2055.0511%2020.0799V28.3517H50.8105C46.6222%2028.3517%2045.2611%2030.9693%2045.2611%2033.6393V39.974H54.6846L53.1664%2049.764H45.2611V73.4275C61.4381%2070.9146%2073.8457%2056.884%2073.8457%2039.974Z%22%20fill%3D%22%231877F2%22%2F%3E%0A%3C%2Fg%3E%0A%3Crect%20x%3D%223.01295%22%20y%3D%2211.7158%22%20width%3D%2212.3077%22%20height%3D%2292.3077%22%20rx%3D%226.15385%22%20transform%3D%22rotate%28-45%203.01295%2011.7158%29%22%20fill%3D%22%23666666%22%20stroke%3D%22white%22%20stroke-width%3D%226.15385%22%2F%3E%0A%3Cdefs%3E%0A%3CclipPath%20id%3D%22clip0%22%3E%0A%3Crect%20width%3D%2267.6923%22%20height%3D%2267.6923%22%20fill%3D%22white%22%20transform%3D%22translate%286.15344%206.15405%29%22%2F%3E%0A%3C%2FclipPath%3E%0A%3C%2Fdefs%3E%0A%3C%2Fsvg%3E'; - const facebookLogo = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMSAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTguODUgMTkuOUM0LjEgMTkuMDUgMC41IDE0Ljk1IDAuNSAxMEMwLjUgNC41IDUgMCAxMC41IDBDMTYgMCAyMC41IDQuNSAyMC41IDEwQzIwLjUgMTQuOTUgMTYuOSAxOS4wNSAxMi4xNSAxOS45TDExLjYgMTkuNDVIOS40TDguODUgMTkuOVoiIGZpbGw9IiMxODc3RjIiLz4KPHBhdGggZD0iTTE0LjQgMTIuOEwxNC44NSAxMEgxMi4yVjguMDVDMTIuMiA3LjI1IDEyLjUgNi42NSAxMy43IDYuNjVIMTVWNC4xQzE0LjMgNCAxMy41IDMuOSAxMi44IDMuOUMxMC41IDMuOSA4LjkgNS4zIDguOSA3LjhWMTBINi40VjEyLjhIOC45VjE5Ljg1QzkuNDUgMTkuOTUgMTAgMjAgMTAuNTUgMjBDMTEuMSAyMCAxMS42NSAxOS45NSAxMi4yIDE5Ljg1VjEyLjhIMTQuNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo='; + const blockedFBLogo = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2280%22%20height%3D%2280%22%20viewBox%3D%220%200%2080%2080%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Ccircle%20cx%3D%2240%22%20cy%3D%2240%22%20r%3D%2240%22%20fill%3D%22white%22%2F%3E%0A%3Cg%20clip-path%3D%22url%28%23clip0%29%22%3E%0A%3Cpath%20d%3D%22M73.8457%2039.974C73.8457%2021.284%2058.7158%206.15405%2040.0258%206.15405C21.3358%206.15405%206.15344%2021.284%206.15344%2039.974C6.15344%2056.884%2018.5611%2070.8622%2034.7381%2073.4275V49.764H26.0999V39.974H34.7381V32.5399C34.7381%2024.0587%2039.764%2019.347%2047.5122%2019.347C51.2293%2019.347%2055.0511%2020.0799%2055.0511%2020.0799V28.3517H50.8105C46.6222%2028.3517%2045.2611%2030.9693%2045.2611%2033.6393V39.974H54.6846L53.1664%2049.764H45.2611V73.4275C61.4381%2070.9146%2073.8457%2056.884%2073.8457%2039.974Z%22%20fill%3D%22%231877F2%22%2F%3E%0A%3C%2Fg%3E%0A%3Crect%20x%3D%223.01295%22%20y%3D%2211.7158%22%20width%3D%2212.3077%22%20height%3D%2292.3077%22%20rx%3D%226.15385%22%20transform%3D%22rotate%28-45%203.01295%2011.7158%29%22%20fill%3D%22%23666666%22%20stroke%3D%22white%22%20stroke-width%3D%226.15385%22%2F%3E%0A%3Cdefs%3E%0A%3CclipPath%20id%3D%22clip0%22%3E%0A%3Crect%20width%3D%2267.6923%22%20height%3D%2267.6923%22%20fill%3D%22white%22%20transform%3D%22translate%286.15344%206.15405%29%22%2F%3E%0A%3C%2FclipPath%3E%0A%3C%2Fdefs%3E%0A%3C%2Fsvg%3E'; + const facebookLogo = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMSAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTguODUgMTkuOUM0LjEgMTkuMDUgMC41IDE0Ljk1IDAuNSAxMEMwLjUgNC41IDUgMCAxMC41IDBDMTYgMCAyMC41IDQuNSAyMC41IDEwQzIwLjUgMTQuOTUgMTYuOSAxOS4wNSAxMi4xNSAxOS45TDExLjYgMTkuNDVIOS40TDguODUgMTkuOVoiIGZpbGw9IiMxODc3RjIiLz4KPHBhdGggZD0iTTE0LjQgMTIuOEwxNC44NSAxMEgxMi4yVjguMDVDMTIuMiA3LjI1IDEyLjUgNi42NSAxMy43IDYuNjVIMTVWNC4xQzE0LjMgNCAxMy41IDMuOSAxMi44IDMuOUMxMC41IDMuOSA4LjkgNS4zIDguOSA3LjhWMTBINi40VjEyLjhIOC45VjE5Ljg1QzkuNDUgMTkuOTUgMTAgMjAgMTAuNTUgMjBDMTEuMSAyMCAxMS42NSAxOS45NSAxMi4yIDE5Ljg1VjEyLjhIMTQuNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo='; - const blockedYTVideo = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2275%22%20height%3D%2275%22%20viewBox%3D%220%200%2075%2075%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Crect%20x%3D%226.75%22%20y%3D%2215.75%22%20width%3D%2256.25%22%20height%3D%2239%22%20rx%3D%2213.5%22%20fill%3D%22%23DE5833%22%2F%3E%0A%20%20%3Cmask%20id%3D%22path-2-outside-1_885_11045%22%20maskUnits%3D%22userSpaceOnUse%22%20x%3D%2223.75%22%20y%3D%2222.5%22%20width%3D%2224%22%20height%3D%2226%22%20fill%3D%22black%22%3E%0A%20%20%3Crect%20fill%3D%22white%22%20x%3D%2223.75%22%20y%3D%2222.5%22%20width%3D%2224%22%20height%3D%2226%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M41.9425%2037.5279C43.6677%2036.492%2043.6677%2033.9914%2041.9425%2032.9555L31.0394%2026.4088C29.262%2025.3416%2027%2026.6218%2027%2028.695L27%2041.7884C27%2043.8615%2029.262%2045.1418%2031.0394%2044.0746L41.9425%2037.5279Z%22%2F%3E%0A%20%20%3C%2Fmask%3E%0A%20%20%3Cpath%20d%3D%22M41.9425%2037.5279C43.6677%2036.492%2043.6677%2033.9914%2041.9425%2032.9555L31.0394%2026.4088C29.262%2025.3416%2027%2026.6218%2027%2028.695L27%2041.7884C27%2043.8615%2029.262%2045.1418%2031.0394%2044.0746L41.9425%2037.5279Z%22%20fill%3D%22white%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M30.0296%2044.6809L31.5739%2047.2529L30.0296%2044.6809ZM30.0296%2025.8024L31.5739%2023.2304L30.0296%2025.8024ZM42.8944%2036.9563L44.4387%2039.5283L42.8944%2036.9563ZM41.35%2036.099L28.4852%2028.3744L31.5739%2023.2304L44.4387%2030.955L41.35%2036.099ZM30%2027.5171L30%2042.9663L24%2042.9663L24%2027.5171L30%2027.5171ZM28.4852%2042.1089L41.35%2034.3843L44.4387%2039.5283L31.5739%2047.2529L28.4852%2042.1089ZM30%2042.9663C30%2042.1888%2029.1517%2041.7087%2028.4852%2042.1089L31.5739%2047.2529C28.2413%2049.2539%2024%2046.8535%2024%2042.9663L30%2042.9663ZM28.4852%2028.3744C29.1517%2028.7746%2030%2028.2945%2030%2027.5171L24%2027.5171C24%2023.6299%2028.2413%2021.2294%2031.5739%2023.2304L28.4852%2028.3744ZM44.4387%2030.955C47.6735%2032.8974%2047.6735%2037.586%2044.4387%2039.5283L41.35%2034.3843C40.7031%2034.7728%2040.7031%2035.7105%2041.35%2036.099L44.4387%2030.955Z%22%20fill%3D%22%23BC4726%22%20mask%3D%22url(%23path-2-outside-1_885_11045)%22%2F%3E%0A%20%20%3Ccircle%20cx%3D%2257.75%22%20cy%3D%2252.5%22%20r%3D%2213.5%22%20fill%3D%22%23E0E0E0%22%2F%3E%0A%20%20%3Crect%20x%3D%2248.75%22%20y%3D%2250.25%22%20width%3D%2218%22%20height%3D%224.5%22%20rx%3D%221.5%22%20fill%3D%22%23666666%22%2F%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M57.9853%2015.8781C58.2046%2016.1015%2058.5052%2016.2262%2058.8181%2016.2238C59.1311%2016.2262%2059.4316%2016.1015%2059.6509%2015.8781L62.9821%2012.5469C63.2974%2012.2532%2063.4272%2011.8107%2063.3206%2011.3931C63.2139%2010.9756%2062.8879%2010.6495%2062.4703%2010.5429C62.0528%2010.4363%2061.6103%2010.5661%2061.3165%2010.8813L57.9853%2014.2125C57.7627%2014.4325%2057.6374%2014.7324%2057.6374%2015.0453C57.6374%2015.3583%2057.7627%2015.6582%2057.9853%2015.8781ZM61.3598%2018.8363C61.388%2019.4872%2061.9385%2019.9919%2062.5893%2019.9637L62.6915%2019.9559L66.7769%2019.6023C67.4278%2019.5459%2067.9097%2018.9726%2067.8533%2018.3217C67.7968%2017.6708%2067.2235%2017.1889%2066.5726%2017.2453L62.4872%2017.6067C61.8363%2017.6349%2061.3316%2018.1854%2061.3598%2018.8363Z%22%20fill%3D%22%23AAAAAA%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.6535%2015.8781C10.4342%2016.1015%2010.1336%2016.2262%209.82067%2016.2238C9.5077%2016.2262%209.20717%2016.1015%208.98787%2015.8781L5.65667%2012.5469C5.34138%2012.2532%205.2116%2011.8107%205.31823%2011.3931C5.42487%2010.9756%205.75092%2010.6495%206.16847%2010.5429C6.58602%2010.4363%207.02848%2010.5661%207.32227%2010.8813L10.6535%2014.2125C10.8761%2014.4325%2011.0014%2014.7324%2011.0014%2015.0453C11.0014%2015.3583%2010.8761%2015.6582%2010.6535%2015.8781ZM7.2791%2018.8362C7.25089%2019.4871%206.7004%2019.9919%206.04954%2019.9637L5.9474%2019.9558L1.86197%2019.6023C1.44093%2019.5658%201.07135%2019.3074%200.892432%2018.9246C0.713515%2018.5417%200.752449%2018.0924%200.994567%2017.7461C1.23669%2017.3997%201.6452%2017.2088%202.06624%2017.2453L6.15167%2017.6067C6.80254%2017.6349%207.3073%2018.1854%207.2791%2018.8362Z%22%20fill%3D%22%23AAAAAA%22%20fill-opacity%3D%220.6%22%2F%3E%0A%3C%2Fsvg%3E%0A'; - const videoPlayDark = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2222%22%20height%3D%2226%22%20viewBox%3D%220%200%2022%2026%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20d%3D%22M21%2011.2679C22.3333%2012.0377%2022.3333%2013.9622%2021%2014.732L3%2025.1244C1.66667%2025.8942%202.59376e-06%2024.9319%202.66105e-06%2023.3923L3.56958e-06%202.60769C3.63688e-06%201.06809%201.66667%200.105844%203%200.875644L21%2011.2679Z%22%20fill%3D%22%23222222%22%2F%3E%0A%3C%2Fsvg%3E%0A'; - const videoPlayLight = 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2222%22%20height%3D%2226%22%20viewBox%3D%220%200%2022%2026%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20d%3D%22M21%2011.2679C22.3333%2012.0377%2022.3333%2013.9622%2021%2014.732L3%2025.1244C1.66667%2025.8942%202.59376e-06%2024.9319%202.66105e-06%2023.3923L3.56958e-06%202.60769C3.63688e-06%201.06809%201.66667%200.105844%203%200.875644L21%2011.2679Z%22%20fill%3D%22%23FFFFFF%22%2F%3E%0A%3C%2Fsvg%3E'; + const blockedYTVideo = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2275%22%20height%3D%2275%22%20viewBox%3D%220%200%2075%2075%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Crect%20x%3D%226.75%22%20y%3D%2215.75%22%20width%3D%2256.25%22%20height%3D%2239%22%20rx%3D%2213.5%22%20fill%3D%22%23DE5833%22%2F%3E%0A%20%20%3Cmask%20id%3D%22path-2-outside-1_885_11045%22%20maskUnits%3D%22userSpaceOnUse%22%20x%3D%2223.75%22%20y%3D%2222.5%22%20width%3D%2224%22%20height%3D%2226%22%20fill%3D%22black%22%3E%0A%20%20%3Crect%20fill%3D%22white%22%20x%3D%2223.75%22%20y%3D%2222.5%22%20width%3D%2224%22%20height%3D%2226%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M41.9425%2037.5279C43.6677%2036.492%2043.6677%2033.9914%2041.9425%2032.9555L31.0394%2026.4088C29.262%2025.3416%2027%2026.6218%2027%2028.695L27%2041.7884C27%2043.8615%2029.262%2045.1418%2031.0394%2044.0746L41.9425%2037.5279Z%22%2F%3E%0A%20%20%3C%2Fmask%3E%0A%20%20%3Cpath%20d%3D%22M41.9425%2037.5279C43.6677%2036.492%2043.6677%2033.9914%2041.9425%2032.9555L31.0394%2026.4088C29.262%2025.3416%2027%2026.6218%2027%2028.695L27%2041.7884C27%2043.8615%2029.262%2045.1418%2031.0394%2044.0746L41.9425%2037.5279Z%22%20fill%3D%22white%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M30.0296%2044.6809L31.5739%2047.2529L30.0296%2044.6809ZM30.0296%2025.8024L31.5739%2023.2304L30.0296%2025.8024ZM42.8944%2036.9563L44.4387%2039.5283L42.8944%2036.9563ZM41.35%2036.099L28.4852%2028.3744L31.5739%2023.2304L44.4387%2030.955L41.35%2036.099ZM30%2027.5171L30%2042.9663L24%2042.9663L24%2027.5171L30%2027.5171ZM28.4852%2042.1089L41.35%2034.3843L44.4387%2039.5283L31.5739%2047.2529L28.4852%2042.1089ZM30%2042.9663C30%2042.1888%2029.1517%2041.7087%2028.4852%2042.1089L31.5739%2047.2529C28.2413%2049.2539%2024%2046.8535%2024%2042.9663L30%2042.9663ZM28.4852%2028.3744C29.1517%2028.7746%2030%2028.2945%2030%2027.5171L24%2027.5171C24%2023.6299%2028.2413%2021.2294%2031.5739%2023.2304L28.4852%2028.3744ZM44.4387%2030.955C47.6735%2032.8974%2047.6735%2037.586%2044.4387%2039.5283L41.35%2034.3843C40.7031%2034.7728%2040.7031%2035.7105%2041.35%2036.099L44.4387%2030.955Z%22%20fill%3D%22%23BC4726%22%20mask%3D%22url(%23path-2-outside-1_885_11045)%22%2F%3E%0A%20%20%3Ccircle%20cx%3D%2257.75%22%20cy%3D%2252.5%22%20r%3D%2213.5%22%20fill%3D%22%23E0E0E0%22%2F%3E%0A%20%20%3Crect%20x%3D%2248.75%22%20y%3D%2250.25%22%20width%3D%2218%22%20height%3D%224.5%22%20rx%3D%221.5%22%20fill%3D%22%23666666%22%2F%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M57.9853%2015.8781C58.2046%2016.1015%2058.5052%2016.2262%2058.8181%2016.2238C59.1311%2016.2262%2059.4316%2016.1015%2059.6509%2015.8781L62.9821%2012.5469C63.2974%2012.2532%2063.4272%2011.8107%2063.3206%2011.3931C63.2139%2010.9756%2062.8879%2010.6495%2062.4703%2010.5429C62.0528%2010.4363%2061.6103%2010.5661%2061.3165%2010.8813L57.9853%2014.2125C57.7627%2014.4325%2057.6374%2014.7324%2057.6374%2015.0453C57.6374%2015.3583%2057.7627%2015.6582%2057.9853%2015.8781ZM61.3598%2018.8363C61.388%2019.4872%2061.9385%2019.9919%2062.5893%2019.9637L62.6915%2019.9559L66.7769%2019.6023C67.4278%2019.5459%2067.9097%2018.9726%2067.8533%2018.3217C67.7968%2017.6708%2067.2235%2017.1889%2066.5726%2017.2453L62.4872%2017.6067C61.8363%2017.6349%2061.3316%2018.1854%2061.3598%2018.8363Z%22%20fill%3D%22%23AAAAAA%22%20fill-opacity%3D%220.6%22%2F%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.6535%2015.8781C10.4342%2016.1015%2010.1336%2016.2262%209.82067%2016.2238C9.5077%2016.2262%209.20717%2016.1015%208.98787%2015.8781L5.65667%2012.5469C5.34138%2012.2532%205.2116%2011.8107%205.31823%2011.3931C5.42487%2010.9756%205.75092%2010.6495%206.16847%2010.5429C6.58602%2010.4363%207.02848%2010.5661%207.32227%2010.8813L10.6535%2014.2125C10.8761%2014.4325%2011.0014%2014.7324%2011.0014%2015.0453C11.0014%2015.3583%2010.8761%2015.6582%2010.6535%2015.8781ZM7.2791%2018.8362C7.25089%2019.4871%206.7004%2019.9919%206.04954%2019.9637L5.9474%2019.9558L1.86197%2019.6023C1.44093%2019.5658%201.07135%2019.3074%200.892432%2018.9246C0.713515%2018.5417%200.752449%2018.0924%200.994567%2017.7461C1.23669%2017.3997%201.6452%2017.2088%202.06624%2017.2453L6.15167%2017.6067C6.80254%2017.6349%207.3073%2018.1854%207.2791%2018.8362Z%22%20fill%3D%22%23AAAAAA%22%20fill-opacity%3D%220.6%22%2F%3E%0A%3C%2Fsvg%3E%0A'; + const videoPlayDark = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2222%22%20height%3D%2226%22%20viewBox%3D%220%200%2022%2026%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20d%3D%22M21%2011.2679C22.3333%2012.0377%2022.3333%2013.9622%2021%2014.732L3%2025.1244C1.66667%2025.8942%202.59376e-06%2024.9319%202.66105e-06%2023.3923L3.56958e-06%202.60769C3.63688e-06%201.06809%201.66667%200.105844%203%200.875644L21%2011.2679Z%22%20fill%3D%22%23222222%22%2F%3E%0A%3C%2Fsvg%3E%0A'; + const videoPlayLight = + 'data:image/svg+xml;utf8,%3Csvg%20width%3D%2222%22%20height%3D%2226%22%20viewBox%3D%220%200%2022%2026%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20d%3D%22M21%2011.2679C22.3333%2012.0377%2022.3333%2013.9622%2021%2014.732L3%2025.1244C1.66667%2025.8942%202.59376e-06%2024.9319%202.66105e-06%2023.3923L3.56958e-06%202.60769C3.63688e-06%201.06809%201.66667%200.105844%203%200.875644L21%2011.2679Z%22%20fill%3D%22%23FFFFFF%22%2F%3E%0A%3C%2Fsvg%3E'; var localesJSON = `{"bg":{"facebook.json":{"informationalModalMessageTitle":"При влизане разрешавате на Facebook да Ви проследява","informationalModalMessageBody":"След като влезете, DuckDuckGo не може да блокира проследяването от Facebook в съдържанието на този сайт.","informationalModalConfirmButtonText":"Вход","informationalModalRejectButtonText":"Назад","loginButtonText":"Вход във Facebook","loginBodyText":"Facebook проследява Вашата активност в съответния сайт, когато го използвате за вход.","buttonTextUnblockContent":"Разблокиране на съдържание от Facebook","buttonTextUnblockComment":"Разблокиране на коментар във Facebook","buttonTextUnblockComments":"Разблокиране на коментари във Facebook","buttonTextUnblockPost":"Разблокиране на публикация от Facebook","buttonTextUnblockVideo":"Разблокиране на видео от Facebook","buttonTextUnblockLogin":"Разблокиране на вход с Facebook","infoTitleUnblockContent":"DuckDuckGo блокира това съдържание, за да предотврати проследяване от Facebook","infoTitleUnblockComment":"DuckDuckGo блокира този коментар, за да предотврати проследяване от Facebook","infoTitleUnblockComments":"DuckDuckGo блокира тези коментари, за да предотврати проследяване от Facebook","infoTitleUnblockPost":"DuckDuckGo блокира тази публикация, за да предотврати проследяване от Facebook","infoTitleUnblockVideo":"DuckDuckGo блокира това видео, за да предотврати проследяване от Facebook","infoTextUnblockContent":"Блокирахме проследяването от Facebook при зареждане на страницата. Ако разблокирате това съдържание, Facebook ще следи Вашата активност."},"shared.json":{"learnMore":"Научете повече","readAbout":"Прочетете за тази защита на поверителността","shareFeedback":"Споделяне на отзив"},"youtube.json":{"informationalModalMessageTitle":"Активиране на всички прегледи в YouTube?","informationalModalMessageBody":"Показването на преглед позволява на Google (собственик на YouTube) да види част от информацията за Вашето устройство, но все пак осигурява повече поверителност отколкото при възпроизвеждане на видеоклипа.","informationalModalConfirmButtonText":"Активиране на всички прегледи","informationalModalRejectButtonText":"Не, благодаря","buttonTextUnblockVideo":"Разблокиране на видео от YouTube","infoTitleUnblockVideo":"DuckDuckGo блокира този видеоклип в YouTube, за да предотврати проследяване от Google","infoTextUnblockVideo":"Блокирахме проследяването от Google (собственик на YouTube) при зареждане на страницата. Ако разблокирате този видеоклип, Google ще следи Вашата активност.","infoPreviewToggleText":"Прегледите са деактивирани за осигуряване на допълнителна поверителност","infoPreviewToggleEnabledText":"Прегледите са активирани","infoPreviewToggleEnabledDuckDuckGoText":"Визуализациите от YouTube са активирани в DuckDuckGo.","infoPreviewInfoText":"Научете повече за вградената защита от социални медии на DuckDuckGo"}},"cs":{"facebook.json":{"informationalModalMessageTitle":"Když se přihlásíš přes Facebook, bude tě moct sledovat","informationalModalMessageBody":"Po přihlášení už DuckDuckGo nemůže bránit Facebooku, aby tě na téhle stránce sledoval.","informationalModalConfirmButtonText":"Přihlásit se","informationalModalRejectButtonText":"Zpět","loginButtonText":"Přihlásit se pomocí Facebooku","loginBodyText":"Facebook sleduje tvou aktivitu na webu, když se přihlásíš jeho prostřednictvím.","buttonTextUnblockContent":"Odblokovat obsah na Facebooku","buttonTextUnblockComment":"Odblokovat komentář na Facebooku","buttonTextUnblockComments":"Odblokovat komentáře na Facebooku","buttonTextUnblockPost":"Odblokovat příspěvek na Facebooku","buttonTextUnblockVideo":"Odblokovat video na Facebooku","buttonTextUnblockLogin":"Odblokovat přihlášení k Facebooku","infoTitleUnblockContent":"DuckDuckGo zablokoval tenhle obsah, aby Facebooku zabránil tě sledovat","infoTitleUnblockComment":"Služba DuckDuckGo zablokovala tento komentář, aby Facebooku zabránila ve tvém sledování","infoTitleUnblockComments":"Služba DuckDuckGo zablokovala tyto komentáře, aby Facebooku zabránila ve tvém sledování","infoTitleUnblockPost":"DuckDuckGo zablokoval tenhle příspěvek, aby Facebooku zabránil tě sledovat","infoTitleUnblockVideo":"DuckDuckGo zablokoval tohle video, aby Facebooku zabránil tě sledovat","infoTextUnblockContent":"Při načítání stránky jsme Facebooku zabránili, aby tě sledoval. Když tenhle obsah odblokuješ, Facebook bude mít přístup ke tvé aktivitě."},"shared.json":{"learnMore":"Více informací","readAbout":"Přečti si o téhle ochraně soukromí","shareFeedback":"Podělte se o zpětnou vazbu"},"youtube.json":{"informationalModalMessageTitle":"Zapnout všechny náhledy YouTube?","informationalModalMessageBody":"Zobrazování náhledů umožní společnosti Google (která vlastní YouTube) zobrazit některé informace o tvém zařízení, ale pořád jde o diskrétnější volbu, než je přehrávání videa.","informationalModalConfirmButtonText":"Zapnout všechny náhledy","informationalModalRejectButtonText":"Ne, děkuji","buttonTextUnblockVideo":"Odblokovat video na YouTube","infoTitleUnblockVideo":"DuckDuckGo zablokoval tohle video z YouTube, aby Googlu zabránil tě sledovat","infoTextUnblockVideo":"Zabránili jsme společnosti Google (která vlastní YouTube), aby tě při načítání stránky sledovala. Pokud toto video odblokuješ, Google získá přístup ke tvé aktivitě.","infoPreviewToggleText":"Náhledy jsou pro větší soukromí vypnuté","infoPreviewToggleEnabledText":"Náhledy jsou zapnuté","infoPreviewToggleEnabledDuckDuckGoText":"Náhledy YouTube jsou v DuckDuckGo povolené.","infoPreviewInfoText":"Další informace o ochraně DuckDuckGo před sledováním prostřednictvím vloženého obsahu ze sociálních médií"}},"da":{"facebook.json":{"informationalModalMessageTitle":"Når du logger ind med Facebook, kan de spore dig","informationalModalMessageBody":"Når du er logget ind, kan DuckDuckGo ikke blokere for, at indhold fra Facebook sporer dig på dette websted.","informationalModalConfirmButtonText":"Log på","informationalModalRejectButtonText":"Gå tilbage","loginButtonText":"Log ind med Facebook","loginBodyText":"Facebook sporer din aktivitet på et websted, når du bruger dem til at logge ind.","buttonTextUnblockContent":"Bloker ikke Facebook-indhold","buttonTextUnblockComment":"Bloker ikke Facebook-kommentar","buttonTextUnblockComments":"Bloker ikke Facebook-kommentarer","buttonTextUnblockPost":"Bloker ikke Facebook-opslag","buttonTextUnblockVideo":"Bloker ikke Facebook-video","buttonTextUnblockLogin":"Bloker ikke Facebook-login","infoTitleUnblockContent":"DuckDuckGo har blokeret dette indhold for at forhindre Facebook i at spore dig","infoTitleUnblockComment":"DuckDuckGo har blokeret denne kommentar for at forhindre Facebook i at spore dig","infoTitleUnblockComments":"DuckDuckGo har blokeret disse kommentarer for at forhindre Facebook i at spore dig","infoTitleUnblockPost":"DuckDuckGo blokerede dette indlæg for at forhindre Facebook i at spore dig","infoTitleUnblockVideo":"DuckDuckGo har blokeret denne video for at forhindre Facebook i at spore dig","infoTextUnblockContent":"Vi blokerede for, at Facebook sporede dig, da siden blev indlæst. Hvis du ophæver blokeringen af dette indhold, vil Facebook kende din aktivitet."},"shared.json":{"learnMore":"Mere info","readAbout":"Læs om denne beskyttelse af privatlivet","shareFeedback":"Del feedback"},"youtube.json":{"informationalModalMessageTitle":"Vil du aktivere alle YouTube-forhåndsvisninger?","informationalModalMessageBody":"Med forhåndsvisninger kan Google (som ejer YouTube) se nogle af enhedens oplysninger, men det er stadig mere privat end at afspille videoen.","informationalModalConfirmButtonText":"Aktivér alle forhåndsvisninger","informationalModalRejectButtonText":"Nej tak.","buttonTextUnblockVideo":"Bloker ikke YouTube-video","infoTitleUnblockVideo":"DuckDuckGo har blokeret denne YouTube-video for at forhindre Google i at spore dig","infoTextUnblockVideo":"Vi blokerede Google (som ejer YouTube) fra at spore dig, da siden blev indlæst. Hvis du fjerner blokeringen af denne video, vil Google få kendskab til din aktivitet.","infoPreviewToggleText":"Forhåndsvisninger er deaktiveret for at give yderligere privatliv","infoPreviewToggleEnabledText":"Forhåndsvisninger er deaktiveret","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-forhåndsvisninger er aktiveret i DuckDuckGo.","infoPreviewInfoText":"Få mere at vide på om DuckDuckGos indbyggede beskyttelse på sociale medier"}},"de":{"facebook.json":{"informationalModalMessageTitle":"Wenn du dich bei Facebook anmeldest, kann Facebook dich tracken","informationalModalMessageBody":"Sobald du angemeldet bist, kann DuckDuckGo nicht mehr verhindern, dass Facebook-Inhalte dich auf dieser Website tracken.","informationalModalConfirmButtonText":"Anmelden","informationalModalRejectButtonText":"Zurück","loginButtonText":"Mit Facebook anmelden","loginBodyText":"Facebook trackt deine Aktivität auf einer Website, wenn du dich über Facebook dort anmeldest.","buttonTextUnblockContent":"Facebook-Inhalt entsperren","buttonTextUnblockComment":"Facebook-Kommentar entsperren","buttonTextUnblockComments":"Facebook-Kommentare entsperren","buttonTextUnblockPost":"Facebook-Beitrag entsperren","buttonTextUnblockVideo":"Facebook-Video entsperren","buttonTextUnblockLogin":"Facebook-Anmeldung entsperren","infoTitleUnblockContent":"DuckDuckGo hat diesen Inhalt blockiert, um zu verhindern, dass Facebook dich trackt","infoTitleUnblockComment":"DuckDuckGo hat diesen Kommentar blockiert, um zu verhindern, dass Facebook dich trackt","infoTitleUnblockComments":"DuckDuckGo hat diese Kommentare blockiert, um zu verhindern, dass Facebook dich trackt","infoTitleUnblockPost":"DuckDuckGo hat diesen Beitrag blockiert, um zu verhindern, dass Facebook dich trackt","infoTitleUnblockVideo":"DuckDuckGo hat dieses Video blockiert, um zu verhindern, dass Facebook dich trackt","infoTextUnblockContent":"Wir haben Facebook daran gehindert, dich zu tracken, als die Seite geladen wurde. Wenn du die Blockierung für diesen Inhalt aufhebst, kennt Facebook deine Aktivitäten."},"shared.json":{"learnMore":"Mehr erfahren","readAbout":"Weitere Informationen über diesen Datenschutz","shareFeedback":"Feedback teilen"},"youtube.json":{"informationalModalMessageTitle":"Alle YouTube-Vorschauen aktivieren?","informationalModalMessageBody":"Durch das Anzeigen von Vorschauen kann Google (dem YouTube gehört) einige Informationen zu deinem Gerät sehen. Dies ist aber immer noch privater als das Abspielen des Videos.","informationalModalConfirmButtonText":"Alle Vorschauen aktivieren","informationalModalRejectButtonText":"Nein, danke","buttonTextUnblockVideo":"YouTube-Video entsperren","infoTitleUnblockVideo":"DuckDuckGo hat dieses YouTube-Video blockiert, um zu verhindern, dass Google dich trackt.","infoTextUnblockVideo":"Wir haben Google (dem YouTube gehört) daran gehindert, dich beim Laden der Seite zu tracken. Wenn du die Blockierung für dieses Video aufhebst, kennt Google deine Aktivitäten.","infoPreviewToggleText":"Vorschau für mehr Privatsphäre deaktiviert","infoPreviewToggleEnabledText":"Vorschau aktiviert","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-Vorschauen sind in DuckDuckGo aktiviert.","infoPreviewInfoText":"Erfahre mehr über den DuckDuckGo-Schutz vor eingebetteten Social Media-Inhalten"}},"el":{"facebook.json":{"informationalModalMessageTitle":"Η σύνδεση μέσω Facebook τους επιτρέπει να σας παρακολουθούν","informationalModalMessageBody":"Μόλις συνδεθείτε, το DuckDuckGo δεν μπορεί να εμποδίσει το περιεχόμενο του Facebook από το να σας παρακολουθεί σε αυτόν τον ιστότοπο.","informationalModalConfirmButtonText":"Σύνδεση","informationalModalRejectButtonText":"Επιστροφή","loginButtonText":"Σύνδεση μέσω Facebook","loginBodyText":"Το Facebook παρακολουθεί τη δραστηριότητά σας σε έναν ιστότοπο όταν τον χρησιμοποιείτε για να συνδεθείτε.","buttonTextUnblockContent":"Άρση αποκλεισμού περιεχομένου στο Facebook","buttonTextUnblockComment":"Άρση αποκλεισμού σχόλιου στο Facebook","buttonTextUnblockComments":"Άρση αποκλεισμού σχολίων στο Facebook","buttonTextUnblockPost":"Άρση αποκλεισμού ανάρτησης στο Facebook","buttonTextUnblockVideo":"Άρση αποκλεισμού βίντεο στο Facebook","buttonTextUnblockLogin":"Άρση αποκλεισμού σύνδεσης στο Facebook","infoTitleUnblockContent":"Το DuckDuckGo απέκλεισε το περιεχόμενο αυτό για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTitleUnblockComment":"Το DuckDuckGo απέκλεισε το σχόλιο αυτό για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTitleUnblockComments":"Το DuckDuckGo απέκλεισε τα σχόλια αυτά για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTitleUnblockPost":"Το DuckDuckGo απέκλεισε την ανάρτηση αυτή για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTitleUnblockVideo":"Το DuckDuckGo απέκλεισε το βίντεο αυτό για να εμποδίσει το Facebook από το να σας παρακολουθεί","infoTextUnblockContent":"Αποκλείσαμε το Facebook από το να σας παρακολουθεί όταν φορτώθηκε η σελίδα. Εάν κάνετε άρση αποκλεισμού γι' αυτό το περιεχόμενο, το Facebook θα γνωρίζει τη δραστηριότητά σας."},"shared.json":{"learnMore":"Μάθετε περισσότερα","readAbout":"Διαβάστε σχετικά με την παρούσα προστασίας προσωπικών δεδομένων","shareFeedback":"Κοινοποίηση σχολίου"},"youtube.json":{"informationalModalMessageTitle":"Ενεργοποίηση όλων των προεπισκοπήσεων του YouTube;","informationalModalMessageBody":"Η προβολή των προεπισκοπήσεων θα επιτρέψει στην Google (στην οποία ανήκει το YouTube) να βλέπει ορισμένες από τις πληροφορίες της συσκευής σας, ωστόσο εξακολουθεί να είναι πιο ιδιωτική από την αναπαραγωγή του βίντεο.","informationalModalConfirmButtonText":"Ενεργοποίηση όλων των προεπισκοπήσεων","informationalModalRejectButtonText":"Όχι, ευχαριστώ","buttonTextUnblockVideo":"Άρση αποκλεισμού βίντεο YouTube","infoTitleUnblockVideo":"Το DuckDuckGo απέκλεισε το βίντεο αυτό στο YouTube για να εμποδίσει την Google από το να σας παρακολουθεί","infoTextUnblockVideo":"Αποκλείσαμε την Google (στην οποία ανήκει το YouTube) από το να σας παρακολουθεί όταν φορτώθηκε η σελίδα. Εάν κάνετε άρση αποκλεισμού γι' αυτό το βίντεο, η Google θα γνωρίζει τη δραστηριότητά σας.","infoPreviewToggleText":"Οι προεπισκοπήσεις απενεργοποιήθηκαν για πρόσθετη προστασία των προσωπικών δεδομένων","infoPreviewToggleEnabledText":"Οι προεπισκοπήσεις ενεργοποιήθηκαν","infoPreviewToggleEnabledDuckDuckGoText":"Οι προεπισκοπήσεις YouTube ενεργοποιήθηκαν στο DuckDuckGo.","infoPreviewInfoText":"Μάθετε περισσότερα για την ενσωματωμένη προστασία κοινωνικών μέσων DuckDuckGo"}},"en":{"facebook.json":{"informationalModalMessageTitle":"Logging in with Facebook lets them track you","informationalModalMessageBody":"Once you're logged in, DuckDuckGo can't block Facebook content from tracking you on this site.","informationalModalConfirmButtonText":"Log In","informationalModalRejectButtonText":"Go back","loginButtonText":"Log in with Facebook","loginBodyText":"Facebook tracks your activity on a site when you use them to login.","buttonTextUnblockContent":"Unblock Facebook Content","buttonTextUnblockComment":"Unblock Facebook Comment","buttonTextUnblockComments":"Unblock Facebook Comments","buttonTextUnblockPost":"Unblock Facebook Post","buttonTextUnblockVideo":"Unblock Facebook Video","buttonTextUnblockLogin":"Unblock Facebook Login","infoTitleUnblockContent":"DuckDuckGo blocked this content to prevent Facebook from tracking you","infoTitleUnblockComment":"DuckDuckGo blocked this comment to prevent Facebook from tracking you","infoTitleUnblockComments":"DuckDuckGo blocked these comments to prevent Facebook from tracking you","infoTitleUnblockPost":"DuckDuckGo blocked this post to prevent Facebook from tracking you","infoTitleUnblockVideo":"DuckDuckGo blocked this video to prevent Facebook from tracking you","infoTextUnblockContent":"We blocked Facebook from tracking you when the page loaded. If you unblock this content, Facebook will know your activity."},"shared.json":{"learnMore":"Learn More","readAbout":"Read about this privacy protection","shareFeedback":"Share Feedback"},"youtube.json":{"informationalModalMessageTitle":"Enable all YouTube previews?","informationalModalMessageBody":"Showing previews will allow Google (which owns YouTube) to see some of your device’s information, but is still more private than playing the video.","informationalModalConfirmButtonText":"Enable All Previews","informationalModalRejectButtonText":"No Thanks","buttonTextUnblockVideo":"Unblock YouTube Video","infoTitleUnblockVideo":"DuckDuckGo blocked this YouTube video to prevent Google from tracking you","infoTextUnblockVideo":"We blocked Google (which owns YouTube) from tracking you when the page loaded. If you unblock this video, Google will know your activity.","infoPreviewToggleText":"Previews disabled for additional privacy","infoPreviewToggleEnabledText":"Previews enabled","infoPreviewToggleEnabledDuckDuckGoText":"YouTube previews enabled in DuckDuckGo.","infoPreviewInfoText":"Learn more about DuckDuckGo Embedded Social Media Protection"}},"es":{"facebook.json":{"informationalModalMessageTitle":"Al iniciar sesión en Facebook, les permites que te rastreen","informationalModalMessageBody":"Una vez que hayas iniciado sesión, DuckDuckGo no puede bloquear el contenido de Facebook para que no te rastree en este sitio.","informationalModalConfirmButtonText":"Iniciar sesión","informationalModalRejectButtonText":"Volver atrás","loginButtonText":"Iniciar sesión con Facebook","loginBodyText":"Facebook rastrea tu actividad en un sitio web cuando lo usas para iniciar sesión.","buttonTextUnblockContent":"Desbloquear contenido de Facebook","buttonTextUnblockComment":"Desbloquear comentario de Facebook","buttonTextUnblockComments":"Desbloquear comentarios de Facebook","buttonTextUnblockPost":"Desbloquear publicación de Facebook","buttonTextUnblockVideo":"Desbloquear vídeo de Facebook","buttonTextUnblockLogin":"Desbloquear inicio de sesión de Facebook","infoTitleUnblockContent":"DuckDuckGo ha bloqueado este contenido para evitar que Facebook te rastree","infoTitleUnblockComment":"DuckDuckGo ha bloqueado este comentario para evitar que Facebook te rastree","infoTitleUnblockComments":"DuckDuckGo ha bloqueado estos comentarios para evitar que Facebook te rastree","infoTitleUnblockPost":"DuckDuckGo ha bloqueado esta publicación para evitar que Facebook te rastree","infoTitleUnblockVideo":"DuckDuckGo ha bloqueado este vídeo para evitar que Facebook te rastree","infoTextUnblockContent":"Hemos bloqueado el rastreo de Facebook cuando se ha cargado la página. Si desbloqueas este contenido, Facebook tendrá conocimiento de tu actividad."},"shared.json":{"learnMore":"Más información","readAbout":"Lee acerca de esta protección de privacidad","shareFeedback":"Compartir opiniones"},"youtube.json":{"informationalModalMessageTitle":"¿Habilitar todas las vistas previas de YouTube?","informationalModalMessageBody":"Mostrar vistas previas permitirá a Google (que es el propietario de YouTube) ver parte de la información de tu dispositivo, pero sigue siendo más privado que reproducir el vídeo.","informationalModalConfirmButtonText":"Habilitar todas las vistas previas","informationalModalRejectButtonText":"No, gracias","buttonTextUnblockVideo":"Desbloquear vídeo de YouTube","infoTitleUnblockVideo":"DuckDuckGo ha bloqueado este vídeo de YouTube para evitar que Google te rastree","infoTextUnblockVideo":"Hemos bloqueado el rastreo de Google (que es el propietario de YouTube) al cargarse la página. Si desbloqueas este vídeo, Goggle tendrá conocimiento de tu actividad.","infoPreviewToggleText":"Vistas previas desactivadas para mayor privacidad","infoPreviewToggleEnabledText":"Vistas previas activadas","infoPreviewToggleEnabledDuckDuckGoText":"Vistas previas de YouTube habilitadas en DuckDuckGo.","infoPreviewInfoText":"Más información sobre la protección integrada de redes sociales DuckDuckGo"}},"et":{"facebook.json":{"informationalModalMessageTitle":"Kui logid Facebookiga sisse, saab Facebook sind jälgida","informationalModalMessageBody":"Kui oled sisse logitud, ei saa DuckDuckGo blokeerida Facebooki sisu sind jälgimast.","informationalModalConfirmButtonText":"Logi sisse","informationalModalRejectButtonText":"Mine tagasi","loginButtonText":"Logi sisse Facebookiga","loginBodyText":"Kui logid sisse Facebookiga, saab Facebook sinu tegevust saidil jälgida.","buttonTextUnblockContent":"Deblokeeri Facebooki sisu","buttonTextUnblockComment":"Deblokeeri Facebooki kommentaar","buttonTextUnblockComments":"Deblokeeri Facebooki kommentaarid","buttonTextUnblockPost":"Deblokeeri Facebooki postitus","buttonTextUnblockVideo":"Deblokeeri Facebooki video","buttonTextUnblockLogin":"Deblokeeri Facebooki sisselogimine","infoTitleUnblockContent":"DuckDuckGo blokeeris selle sisu, et Facebook ei saaks sind jälgida","infoTitleUnblockComment":"DuckDuckGo blokeeris selle kommentaari, et Facebook ei saaks sind jälgida","infoTitleUnblockComments":"DuckDuckGo blokeeris need kommentaarid, et Facebook ei saaks sind jälgida","infoTitleUnblockPost":"DuckDuckGo blokeeris selle postituse, et Facebook ei saaks sind jälgida","infoTitleUnblockVideo":"DuckDuckGo blokeeris selle video, et Facebook ei saaks sind jälgida","infoTextUnblockContent":"Blokeerisime lehe laadimise ajal Facebooki jaoks sinu jälgimise. Kui sa selle sisu deblokeerid, saab Facebook sinu tegevust jälgida."},"shared.json":{"learnMore":"Loe edasi","readAbout":"Loe selle privaatsuskaitse kohta","shareFeedback":"Jaga tagasisidet"},"youtube.json":{"informationalModalMessageTitle":"Kas lubada kõik YouTube’i eelvaated?","informationalModalMessageBody":"Eelvaate näitamine võimaldab Google’il (kellele YouTube kuulub) näha osa sinu seadme teabest, kuid see on siiski privaatsem kui video esitamine.","informationalModalConfirmButtonText":"Luba kõik eelvaated","informationalModalRejectButtonText":"Ei aitäh","buttonTextUnblockVideo":"Deblokeeri YouTube’i video","infoTitleUnblockVideo":"DuckDuckGo blokeeris selle YouTube’i video, et takistada Google’it sind jälgimast","infoTextUnblockVideo":"Me blokeerisime lehe laadimise ajal Google’i (kellele YouTube kuulub) jälgimise. Kui sa selle video deblokeerid, saab Google sinu tegevusest teada.","infoPreviewToggleText":"Eelvaated on täiendava privaatsuse tagamiseks keelatud","infoPreviewToggleEnabledText":"Eelvaated on lubatud","infoPreviewToggleEnabledDuckDuckGoText":"YouTube’i eelvaated on DuckDuckGos lubatud.","infoPreviewInfoText":"Lisateave DuckDuckGo sisseehitatud sotsiaalmeediakaitse kohta"}},"fi":{"facebook.json":{"informationalModalMessageTitle":"Kun kirjaudut sisään Facebook-tunnuksilla, Facebook voi seurata sinua","informationalModalMessageBody":"Kun olet kirjautunut sisään, DuckDuckGo ei voi estää Facebook-sisältöä seuraamasta sinua tällä sivustolla.","informationalModalConfirmButtonText":"Kirjaudu sisään","informationalModalRejectButtonText":"Edellinen","loginButtonText":"Kirjaudu sisään Facebook-tunnuksilla","loginBodyText":"Facebook seuraa toimintaasi sivustolla, kun kirjaudut sisään sen kautta.","buttonTextUnblockContent":"Poista Facebook-sisällön esto","buttonTextUnblockComment":"Poista Facebook-kommentin esto","buttonTextUnblockComments":"Poista Facebook-kommenttien esto","buttonTextUnblockPost":"Poista Facebook-julkaisun esto","buttonTextUnblockVideo":"Poista Facebook-videon esto","buttonTextUnblockLogin":"Poista Facebook-kirjautumisen esto","infoTitleUnblockContent":"DuckDuckGo esti tämän sisällön estääkseen Facebookia seuraamasta sinua","infoTitleUnblockComment":"DuckDuckGo esti tämän kommentin estääkseen Facebookia seuraamasta sinua","infoTitleUnblockComments":"DuckDuckGo esti nämä kommentit estääkseen Facebookia seuraamasta sinua","infoTitleUnblockPost":"DuckDuckGo esti tämän julkaisun estääkseen Facebookia seuraamasta sinua","infoTitleUnblockVideo":"DuckDuckGo esti tämän videon estääkseen Facebookia seuraamasta sinua","infoTextUnblockContent":"Estimme Facebookia seuraamasta sinua, kun sivua ladattiin. Jos poistat tämän sisällön eston, Facebook saa tietää toimintasi."},"shared.json":{"learnMore":"Lue lisää","readAbout":"Lue tästä yksityisyydensuojasta","shareFeedback":"Jaa palaute"},"youtube.json":{"informationalModalMessageTitle":"Otetaanko käyttöön kaikki YouTube-esikatselut?","informationalModalMessageBody":"Kun sallit esikatselun, Google (joka omistaa YouTuben) voi nähdä joitakin laitteesi tietoja, mutta se on silti yksityisempää kuin videon toistaminen.","informationalModalConfirmButtonText":"Ota käyttöön kaikki esikatselut","informationalModalRejectButtonText":"Ei kiitos","buttonTextUnblockVideo":"Poista YouTube-videon esto","infoTitleUnblockVideo":"DuckDuckGo esti tämän YouTube-videon, jotta Google ei voi seurata sinua","infoTextUnblockVideo":"Estimme Googlea (joka omistaa YouTuben) seuraamasta sinua, kun sivua ladattiin. Jos poistat tämän videon eston, Google tietää toimintasi.","infoPreviewToggleText":"Esikatselut on poistettu käytöstä yksityisyyden lisäämiseksi","infoPreviewToggleEnabledText":"Esikatselut käytössä","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-esikatselut käytössä DuckDuckGossa.","infoPreviewInfoText":"Lue lisää DuckDuckGon upotetusta sosiaalisen median suojauksesta"}},"fr":{"facebook.json":{"informationalModalMessageTitle":"L'identification via Facebook leur permet de vous pister","informationalModalMessageBody":"Une fois que vous êtes connecté(e), DuckDuckGo ne peut pas empêcher le contenu Facebook de vous pister sur ce site.","informationalModalConfirmButtonText":"Connexion","informationalModalRejectButtonText":"Revenir en arrière","loginButtonText":"S'identifier avec Facebook","loginBodyText":"Facebook piste votre activité sur un site lorsque vous l'utilisez pour vous identifier.","buttonTextUnblockContent":"Débloquer le contenu Facebook","buttonTextUnblockComment":"Débloquer le commentaire Facebook","buttonTextUnblockComments":"Débloquer les commentaires Facebook","buttonTextUnblockPost":"Débloquer la publication Facebook","buttonTextUnblockVideo":"Débloquer la vidéo Facebook","buttonTextUnblockLogin":"Débloquer la connexion Facebook","infoTitleUnblockContent":"DuckDuckGo a bloqué ce contenu pour empêcher Facebook de vous suivre","infoTitleUnblockComment":"DuckDuckGo a bloqué ce commentaire pour empêcher Facebook de vous suivre","infoTitleUnblockComments":"DuckDuckGo a bloqué ces commentaires pour empêcher Facebook de vous suivre","infoTitleUnblockPost":"DuckDuckGo a bloqué cette publication pour empêcher Facebook de vous pister","infoTitleUnblockVideo":"DuckDuckGo a bloqué cette vidéo pour empêcher Facebook de vous pister","infoTextUnblockContent":"Nous avons empêché Facebook de vous pister lors du chargement de la page. Si vous débloquez ce contenu, Facebook connaîtra votre activité."},"shared.json":{"learnMore":"En savoir plus","readAbout":"En savoir plus sur cette protection de la confidentialité","shareFeedback":"Partagez vos commentaires"},"youtube.json":{"informationalModalMessageTitle":"Activer tous les aperçus YouTube ?","informationalModalMessageBody":"L'affichage des aperçus permettra à Google (propriétaire de YouTube) de voir certaines informations de votre appareil, mais cela reste davantage confidentiel qu'en lisant la vidéo.","informationalModalConfirmButtonText":"Activer tous les aperçus","informationalModalRejectButtonText":"Non merci","buttonTextUnblockVideo":"Débloquer la vidéo YouTube","infoTitleUnblockVideo":"DuckDuckGo a bloqué cette vidéo YouTube pour empêcher Google de vous pister","infoTextUnblockVideo":"Nous avons empêché Google (propriétaire de YouTube) de vous pister lors du chargement de la page. Si vous débloquez cette vidéo, Google connaîtra votre activité.","infoPreviewToggleText":"Aperçus désactivés pour plus de confidentialité","infoPreviewToggleEnabledText":"Aperçus activés","infoPreviewToggleEnabledDuckDuckGoText":"Les aperçus YouTube sont activés dans DuckDuckGo.","infoPreviewInfoText":"En savoir plus sur la protection intégrée DuckDuckGo des réseaux sociaux"}},"hr":{"facebook.json":{"informationalModalMessageTitle":"Prijava putem Facebooka omogućuje im da te prate","informationalModalMessageBody":"Nakon što se prijaviš, DuckDuckGo ne može blokirati Facebookov sadržaj da te prati na Facebooku.","informationalModalConfirmButtonText":"Prijavljivanje","informationalModalRejectButtonText":"Vrati se","loginButtonText":"Prijavi se putem Facebooka","loginBodyText":"Facebook prati tvoju aktivnost na toj web lokaciji kad je koristiš za prijavu.","buttonTextUnblockContent":"Deblokiraj sadržaj na Facebooku","buttonTextUnblockComment":"Deblokiraj komentar na Facebooku","buttonTextUnblockComments":"Deblokiraj komentare na Facebooku","buttonTextUnblockPost":"Deblokiraj objavu na Facebooku","buttonTextUnblockVideo":"Deblokiraj videozapis na Facebooku","buttonTextUnblockLogin":"Deblokiraj prijavu na Facebook","infoTitleUnblockContent":"DuckDuckGo je blokirao ovaj sadržaj kako bi spriječio Facebook da te prati","infoTitleUnblockComment":"DuckDuckGo je blokirao ovaj komentar kako bi spriječio Facebook da te prati","infoTitleUnblockComments":"DuckDuckGo je blokirao ove komentare kako bi spriječio Facebook da te prati","infoTitleUnblockPost":"DuckDuckGo je blokirao ovu objavu kako bi spriječio Facebook da te prati","infoTitleUnblockVideo":"DuckDuckGo je blokirao ovaj video kako bi spriječio Facebook da te prati","infoTextUnblockContent":"Blokirali smo Facebook da te prati kad se stranica učita. Ako deblokiraš ovaj sadržaj, Facebook će znati tvoju aktivnost."},"shared.json":{"learnMore":"Saznajte više","readAbout":"Pročitaj više o ovoj zaštiti privatnosti","shareFeedback":"Podijeli povratne informacije"},"youtube.json":{"informationalModalMessageTitle":"Omogućiti sve YouTube pretpreglede?","informationalModalMessageBody":"Prikazivanje pretpregleda omogućit će Googleu (u čijem je vlasništvu YouTube) da vidi neke podatke o tvom uređaju, ali je i dalje privatnija opcija od reprodukcije videozapisa.","informationalModalConfirmButtonText":"Omogući sve pretpreglede","informationalModalRejectButtonText":"Ne, hvala","buttonTextUnblockVideo":"Deblokiraj YouTube videozapis","infoTitleUnblockVideo":"DuckDuckGo je blokirao ovaj YouTube videozapis kako bi spriječio Google da te prati","infoTextUnblockVideo":"Blokirali smo Google (u čijem je vlasništvu YouTube) da te prati kad se stranica učita. Ako deblokiraš ovaj videozapis, Google će znati tvoju aktivnost.","infoPreviewToggleText":"Pretpregledi su onemogućeni radi dodatne privatnosti","infoPreviewToggleEnabledText":"Pretpregledi su omogućeni","infoPreviewToggleEnabledDuckDuckGoText":"YouTube pretpregledi omogućeni su u DuckDuckGou.","infoPreviewInfoText":"Saznaj više o uključenoj DuckDuckGo zaštiti od društvenih medija"}},"hu":{"facebook.json":{"informationalModalMessageTitle":"A Facebookkal való bejelentkezéskor a Facebook nyomon követhet","informationalModalMessageBody":"Miután bejelentkezel, a DuckDuckGo nem fogja tudni blokkolni a Facebook-tartalmat, amely nyomon követ ezen az oldalon.","informationalModalConfirmButtonText":"Bejelentkezés","informationalModalRejectButtonText":"Visszalépés","loginButtonText":"Bejelentkezés Facebookkal","loginBodyText":"Ha a Facebookkal jelentkezel be, nyomon követik a webhelyen végzett tevékenységedet.","buttonTextUnblockContent":"Facebook-tartalom feloldása","buttonTextUnblockComment":"Facebook-hozzászólás feloldása","buttonTextUnblockComments":"Facebook-hozzászólások feloldása","buttonTextUnblockPost":"Facebook-bejegyzés feloldása","buttonTextUnblockVideo":"Facebook-videó feloldása","buttonTextUnblockLogin":"Facebook-bejelentkezés feloldása","infoTitleUnblockContent":"A DuckDuckGo blokkolta ezt a tartalmat, hogy megakadályozza a Facebookot a nyomon követésedben","infoTitleUnblockComment":"A DuckDuckGo blokkolta ezt a hozzászólást, hogy megakadályozza a Facebookot a nyomon követésedben","infoTitleUnblockComments":"A DuckDuckGo blokkolta ezeket a hozzászólásokat, hogy megakadályozza a Facebookot a nyomon követésedben","infoTitleUnblockPost":"A DuckDuckGo blokkolta ezt a bejegyzést, hogy megakadályozza a Facebookot a nyomon követésedben","infoTitleUnblockVideo":"A DuckDuckGo blokkolta ezt a videót, hogy megakadályozza a Facebookot a nyomon követésedben","infoTextUnblockContent":"Az oldal betöltésekor blokkoltuk a Facebookot a nyomon követésedben. Ha feloldod ezt a tartalmat, a Facebook tudni fogja, hogy milyen tevékenységet végzel."},"shared.json":{"learnMore":"További részletek","readAbout":"Tudj meg többet erről az adatvédelemről","shareFeedback":"Visszajelzés megosztása"},"youtube.json":{"informationalModalMessageTitle":"Engedélyezed minden YouTube-videó előnézetét?","informationalModalMessageBody":"Az előnézetek megjelenítésével a Google (a YouTube tulajdonosa) láthatja a készülék néhány adatát, de ez adatvédelmi szempontból még mindig előnyösebb, mint a videó lejátszása.","informationalModalConfirmButtonText":"Minden előnézet engedélyezése","informationalModalRejectButtonText":"Nem, köszönöm","buttonTextUnblockVideo":"YouTube-videó feloldása","infoTitleUnblockVideo":"A DuckDuckGo blokkolta a YouTube-videót, hogy a Google ne követhessen nyomon","infoTextUnblockVideo":"Blokkoltuk, hogy a Google (a YouTube tulajdonosa) nyomon követhessen az oldal betöltésekor. Ha feloldod a videó blokkolását, a Google tudni fogja, hogy milyen tevékenységet végzel.","infoPreviewToggleText":"Az előnézetek a fokozott adatvédelem érdekében letiltva","infoPreviewToggleEnabledText":"Az előnézetek engedélyezve","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-előnézetek engedélyezve a DuckDuckGo-ban.","infoPreviewInfoText":"További tudnivalók a DuckDuckGo beágyazott közösségi média elleni védelméről"}},"it":{"facebook.json":{"informationalModalMessageTitle":"L'accesso con Facebook consente di tracciarti","informationalModalMessageBody":"Dopo aver effettuato l'accesso, DuckDuckGo non può bloccare il tracciamento dei contenuti di Facebook su questo sito.","informationalModalConfirmButtonText":"Accedi","informationalModalRejectButtonText":"Torna indietro","loginButtonText":"Accedi con Facebook","loginBodyText":"Facebook tiene traccia della tua attività su un sito quando lo usi per accedere.","buttonTextUnblockContent":"Sblocca i contenuti di Facebook","buttonTextUnblockComment":"Sblocca il commento di Facebook","buttonTextUnblockComments":"Sblocca i commenti di Facebook","buttonTextUnblockPost":"Sblocca post di Facebook","buttonTextUnblockVideo":"Sblocca video di Facebook","buttonTextUnblockLogin":"Sblocca l'accesso a Facebook","infoTitleUnblockContent":"DuckDuckGo ha bloccato questo contenuto per impedire a Facebook di tracciarti","infoTitleUnblockComment":"DuckDuckGo ha bloccato questo commento per impedire a Facebook di tracciarti","infoTitleUnblockComments":"DuckDuckGo ha bloccato questi commenti per impedire a Facebook di tracciarti","infoTitleUnblockPost":"DuckDuckGo ha bloccato questo post per impedire a Facebook di tracciarti","infoTitleUnblockVideo":"DuckDuckGo ha bloccato questo video per impedire a Facebook di tracciarti","infoTextUnblockContent":"Abbiamo impedito a Facebook di tracciarti al caricamento della pagina. Se sblocchi questo contenuto, Facebook conoscerà la tua attività."},"shared.json":{"learnMore":"Ulteriori informazioni","readAbout":"Leggi di più su questa protezione della privacy","shareFeedback":"Condividi feedback"},"youtube.json":{"informationalModalMessageTitle":"Abilitare tutte le anteprime di YouTube?","informationalModalMessageBody":"La visualizzazione delle anteprime consentirà a Google (che possiede YouTube) di vedere alcune delle informazioni del tuo dispositivo, ma è comunque più privato rispetto alla riproduzione del video.","informationalModalConfirmButtonText":"Abilita tutte le anteprime","informationalModalRejectButtonText":"No, grazie","buttonTextUnblockVideo":"Sblocca video YouTube","infoTitleUnblockVideo":"DuckDuckGo ha bloccato questo video di YouTube per impedire a Google di tracciarti","infoTextUnblockVideo":"Abbiamo impedito a Google (che possiede YouTube) di tracciarti quando la pagina è stata caricata. Se sblocchi questo video, Google conoscerà la tua attività.","infoPreviewToggleText":"Anteprime disabilitate per una maggiore privacy","infoPreviewToggleEnabledText":"Anteprime abilitate","infoPreviewToggleEnabledDuckDuckGoText":"Anteprime YouTube abilitate in DuckDuckGo.","infoPreviewInfoText":"Scopri di più sulla protezione dai social media integrata di DuckDuckGo"}},"lt":{"facebook.json":{"informationalModalMessageTitle":"Prisijungę prie „Facebook“ galite būti sekami","informationalModalMessageBody":"Kai esate prisijungę, „DuckDuckGo“ negali užblokuoti „Facebook“ turinio, todėl esate sekami šioje svetainėje.","informationalModalConfirmButtonText":"Prisijungti","informationalModalRejectButtonText":"Grįžti atgal","loginButtonText":"Prisijunkite su „Facebook“","loginBodyText":"„Facebook“ seka jūsų veiklą svetainėje, kai prisijungiate su šia svetaine.","buttonTextUnblockContent":"Atblokuoti „Facebook“ turinį","buttonTextUnblockComment":"Atblokuoti „Facebook“ komentarą","buttonTextUnblockComments":"Atblokuoti „Facebook“ komentarus","buttonTextUnblockPost":"Atblokuoti „Facebook“ įrašą","buttonTextUnblockVideo":"Atblokuoti „Facebook“ vaizdo įrašą","buttonTextUnblockLogin":"Atblokuoti „Facebook“ prisijungimą","infoTitleUnblockContent":"„DuckDuckGo“ užblokavo šį turinį, kad „Facebook“ negalėtų jūsų sekti","infoTitleUnblockComment":"„DuckDuckGo“ užblokavo šį komentarą, kad „Facebook“ negalėtų jūsų sekti","infoTitleUnblockComments":"„DuckDuckGo“ užblokavo šiuos komentarus, kad „Facebook“ negalėtų jūsų sekti","infoTitleUnblockPost":"„DuckDuckGo“ užblokavo šį įrašą, kad „Facebook“ negalėtų jūsų sekti","infoTitleUnblockVideo":"„DuckDuckGo“ užblokavo šį vaizdo įrašą, kad „Facebook“ negalėtų jūsų sekti","infoTextUnblockContent":"Užblokavome „Facebook“, kad negalėtų jūsų sekti, kai puslapis buvo įkeltas. Jei atblokuosite šį turinį, „Facebook“ žinos apie jūsų veiklą."},"shared.json":{"learnMore":"Sužinoti daugiau","readAbout":"Skaitykite apie šią privatumo apsaugą","shareFeedback":"Bendrinti atsiliepimą"},"youtube.json":{"informationalModalMessageTitle":"Įjungti visas „YouTube“ peržiūras?","informationalModalMessageBody":"Peržiūrų rodymas leis „Google“ (kuriai priklauso „YouTube“) matyti tam tikrą jūsų įrenginio informaciją, tačiau ji vis tiek bus privatesnė nei leidžiant vaizdo įrašą.","informationalModalConfirmButtonText":"Įjungti visas peržiūras","informationalModalRejectButtonText":"Ne, dėkoju","buttonTextUnblockVideo":"Atblokuoti „YouTube“ vaizdo įrašą","infoTitleUnblockVideo":"„DuckDuckGo“ užblokavo šį „YouTube“ vaizdo įrašą, kad „Google“ negalėtų jūsų sekti","infoTextUnblockVideo":"Užblokavome „Google“ (kuriai priklauso „YouTube“) galimybę sekti jus, kai puslapis buvo įkeltas. Jei atblokuosite šį vaizdo įrašą, „Google“ sužinos apie jūsų veiklą.","infoPreviewToggleText":"Peržiūros išjungtos dėl papildomo privatumo","infoPreviewToggleEnabledText":"Peržiūros įjungtos","infoPreviewToggleEnabledDuckDuckGoText":"„YouTube“ peržiūros įjungtos „DuckDuckGo“.","infoPreviewInfoText":"Sužinokite daugiau apie „DuckDuckGo“ įdėtąją socialinės žiniasklaidos apsaugą"}},"lv":{"facebook.json":{"informationalModalMessageTitle":"Ja pieteiksies ar Facebook, viņi varēs tevi izsekot","informationalModalMessageBody":"Kad tu piesakies, DuckDuckGo nevar novērst, ka Facebook saturs tevi izseko šajā vietnē.","informationalModalConfirmButtonText":"Pieteikties","informationalModalRejectButtonText":"Atgriezties","loginButtonText":"Pieteikties ar Facebook","loginBodyText":"Facebook izseko tavas aktivitātes vietnē, kad esi pieteicies ar Facebook.","buttonTextUnblockContent":"Atbloķēt Facebook saturu","buttonTextUnblockComment":"Atbloķēt Facebook komentāru","buttonTextUnblockComments":"Atbloķēt Facebook komentārus","buttonTextUnblockPost":"Atbloķēt Facebook ziņu","buttonTextUnblockVideo":"Atbloķēt Facebook video","buttonTextUnblockLogin":"Atbloķēt Facebook pieteikšanos","infoTitleUnblockContent":"DuckDuckGo bloķēja šo saturu, lai neļautu Facebook tevi izsekot","infoTitleUnblockComment":"DuckDuckGo bloķēja šo komentāru, lai neļautu Facebook tevi izsekot","infoTitleUnblockComments":"DuckDuckGo bloķēja šos komentārus, lai neļautu Facebook tevi izsekot","infoTitleUnblockPost":"DuckDuckGo bloķēja šo ziņu, lai neļautu Facebook tevi izsekot","infoTitleUnblockVideo":"DuckDuckGo bloķēja šo videoklipu, lai neļautu Facebook tevi izsekot","infoTextUnblockContent":"Mēs bloķējām Facebook iespēju tevi izsekot, ielādējot lapu. Ja atbloķēsi šo saturu, Facebook redzēs, ko tu dari."},"shared.json":{"learnMore":"Uzzināt vairāk","readAbout":"Lasi par šo privātuma aizsardzību","shareFeedback":"Kopīgot atsauksmi"},"youtube.json":{"informationalModalMessageTitle":"Vai iespējot visus YouTube priekšskatījumus?","informationalModalMessageBody":"Priekšskatījumu rādīšana ļaus Google (kam pieder YouTube) redzēt daļu tavas ierīces informācijas, taču tas tāpat ir privātāk par videoklipa atskaņošanu.","informationalModalConfirmButtonText":"Iespējot visus priekšskatījumus","informationalModalRejectButtonText":"Nē, paldies","buttonTextUnblockVideo":"Atbloķēt YouTube videoklipu","infoTitleUnblockVideo":"DuckDuckGo bloķēja šo YouTube videoklipu, lai neļautu Google tevi izsekot","infoTextUnblockVideo":"Mēs neļāvām Google (kam pieder YouTube) tevi izsekot, kad lapa tika ielādēta. Ja atbloķēsi šo videoklipu, Google zinās, ko tu dari.","infoPreviewToggleText":"Priekšskatījumi ir atspējoti, lai nodrošinātu papildu konfidencialitāti","infoPreviewToggleEnabledText":"Priekšskatījumi ir iespējoti","infoPreviewToggleEnabledDuckDuckGoText":"DuckDuckGo iespējoti YouTube priekšskatījumi.","infoPreviewInfoText":"Uzzini vairāk par DuckDuckGo iegulto sociālo mediju aizsardzību"}},"nb":{"facebook.json":{"informationalModalMessageTitle":"Når du logger på med Facebook, kan de spore deg","informationalModalMessageBody":"Når du er logget på, kan ikke DuckDuckGo hindre Facebook-innhold i å spore deg på dette nettstedet.","informationalModalConfirmButtonText":"Logg inn","informationalModalRejectButtonText":"Gå tilbake","loginButtonText":"Logg på med Facebook","loginBodyText":"Når du logger på med Facebook, sporer de aktiviteten din på nettstedet.","buttonTextUnblockContent":"Fjern blokkering av Facebook-innhold","buttonTextUnblockComment":"Fjern blokkering av Facebook-kommentar","buttonTextUnblockComments":"Fjern blokkering av Facebook-kommentarer","buttonTextUnblockPost":"Fjern blokkering av Facebook-innlegg","buttonTextUnblockVideo":"Fjern blokkering av Facebook-video","buttonTextUnblockLogin":"Fjern blokkering av Facebook-pålogging","infoTitleUnblockContent":"DuckDuckGo blokkerte dette innholdet for å hindre Facebook i å spore deg","infoTitleUnblockComment":"DuckDuckGo blokkerte denne kommentaren for å hindre Facebook i å spore deg","infoTitleUnblockComments":"DuckDuckGo blokkerte disse kommentarene for å hindre Facebook i å spore deg","infoTitleUnblockPost":"DuckDuckGo blokkerte dette innlegget for å hindre Facebook i å spore deg","infoTitleUnblockVideo":"DuckDuckGo blokkerte denne videoen for å hindre Facebook i å spore deg","infoTextUnblockContent":"Vi hindret Facebook i å spore deg da siden ble lastet. Hvis du opphever blokkeringen av dette innholdet, får Facebook vite om aktiviteten din."},"shared.json":{"learnMore":"Finn ut mer","readAbout":"Les om denne personvernfunksjonen","shareFeedback":"Del tilbakemelding"},"youtube.json":{"informationalModalMessageTitle":"Vil du aktivere alle YouTube-forhåndsvisninger?","informationalModalMessageBody":"Forhåndsvisninger gjør det mulig for Google (som eier YouTube) å se enkelte opplysninger om enheten din, men det er likevel mer privat enn å spille av videoen.","informationalModalConfirmButtonText":"Aktiver alle forhåndsvisninger","informationalModalRejectButtonText":"Nei takk","buttonTextUnblockVideo":"Fjern blokkering av YouTube-video","infoTitleUnblockVideo":"DuckDuckGo blokkerte denne YouTube-videoen for å hindre Google i å spore deg","infoTextUnblockVideo":"Vi blokkerte Google (som eier YouTube) mot å spore deg da siden ble lastet. Hvis du opphever blokkeringen av denne videoen, får Google vite om aktiviteten din.","infoPreviewToggleText":"Forhåndsvisninger er deaktivert for å gi deg ekstra personvern","infoPreviewToggleEnabledText":"Forhåndsvisninger er aktivert","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-forhåndsvisninger er aktivert i DuckDuckGo.","infoPreviewInfoText":"Finn ut mer om DuckDuckGos innebygde beskyttelse for sosiale medier"}},"nl":{"facebook.json":{"informationalModalMessageTitle":"Als je inlogt met Facebook, kunnen zij je volgen","informationalModalMessageBody":"Als je eenmaal bent ingelogd, kan DuckDuckGo niet voorkomen dat Facebook je op deze site volgt.","informationalModalConfirmButtonText":"Inloggen","informationalModalRejectButtonText":"Terug","loginButtonText":"Inloggen met Facebook","loginBodyText":"Facebook volgt je activiteit op een site als je Facebook gebruikt om in te loggen.","buttonTextUnblockContent":"Facebook-inhoud deblokkeren","buttonTextUnblockComment":"Facebook-opmerkingen deblokkeren","buttonTextUnblockComments":"Facebook-opmerkingen deblokkeren","buttonTextUnblockPost":"Facebook-bericht deblokkeren","buttonTextUnblockVideo":"Facebook-video deblokkeren","buttonTextUnblockLogin":"Facebook-aanmelding deblokkeren","infoTitleUnblockContent":"DuckDuckGo heeft deze inhoud geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTitleUnblockComment":"DuckDuckGo heeft deze opmerking geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTitleUnblockComments":"DuckDuckGo heeft deze opmerkingen geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTitleUnblockPost":"DuckDuckGo heeft dit bericht geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTitleUnblockVideo":"DuckDuckGo heeft deze video geblokkeerd om te voorkomen dat Facebook je kan volgen","infoTextUnblockContent":"We hebben voorkomen dat Facebook je volgde toen de pagina werd geladen. Als je deze inhoud deblokkeert, kan Facebook je activiteit zien."},"shared.json":{"learnMore":"Meer informatie","readAbout":"Lees meer over deze privacybescherming","shareFeedback":"Feedback delen"},"youtube.json":{"informationalModalMessageTitle":"Alle YouTube-voorbeelden inschakelen?","informationalModalMessageBody":"Bij het tonen van voorbeelden kan Google (eigenaar van YouTube) een deel van de informatie over je apparaat zien, maar blijft je privacy beter beschermd dan als je de video zou afspelen.","informationalModalConfirmButtonText":"Alle voorbeelden inschakelen","informationalModalRejectButtonText":"Nee, bedankt","buttonTextUnblockVideo":"YouTube-video deblokkeren","infoTitleUnblockVideo":"DuckDuckGo heeft deze YouTube-video geblokkeerd om te voorkomen dat Google je kan volgen","infoTextUnblockVideo":"We hebben voorkomen dat Google (eigenaar van YouTube) je volgde toen de pagina werd geladen. Als je deze video deblokkeert, kan Google je activiteit zien.","infoPreviewToggleText":"Voorbeelden uitgeschakeld voor extra privacy","infoPreviewToggleEnabledText":"Voorbeelden ingeschakeld","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-voorbeelden ingeschakeld in DuckDuckGo.","infoPreviewInfoText":"Meer informatie over DuckDuckGo's bescherming tegen ingesloten social media"}},"pl":{"facebook.json":{"informationalModalMessageTitle":"Jeśli zalogujesz się za pośrednictwem Facebooka, będzie on mógł śledzić Twoją aktywność","informationalModalMessageBody":"Po zalogowaniu się DuckDuckGo nie może zablokować możliwości śledzenia Cię przez Facebooka na tej stronie.","informationalModalConfirmButtonText":"Zaloguj się","informationalModalRejectButtonText":"Wróć","loginButtonText":"Zaloguj się za pośrednictwem Facebooka","loginBodyText":"Facebook śledzi Twoją aktywność na stronie, gdy logujesz się za jego pośrednictwem.","buttonTextUnblockContent":"Odblokuj treść na Facebooku","buttonTextUnblockComment":"Odblokuj komentarz na Facebooku","buttonTextUnblockComments":"Odblokuj komentarze na Facebooku","buttonTextUnblockPost":"Odblokuj post na Facebooku","buttonTextUnblockVideo":"Odblokuj wideo na Facebooku","buttonTextUnblockLogin":"Odblokuj logowanie na Facebooku","infoTitleUnblockContent":"DuckDuckGo zablokował tę treść, aby Facebook nie mógł Cię śledzić","infoTitleUnblockComment":"DuckDuckGo zablokował ten komentarz, aby Facebook nie mógł Cię śledzić","infoTitleUnblockComments":"DuckDuckGo zablokował te komentarze, aby Facebook nie mógł Cię śledzić","infoTitleUnblockPost":"DuckDuckGo zablokował ten post, aby Facebook nie mógł Cię śledzić","infoTitleUnblockVideo":"DuckDuckGo zablokował tę treść wideo, aby Facebook nie mógł Cię śledzić.","infoTextUnblockContent":"Zablokowaliśmy Facebookowi możliwość śledzenia Cię podczas ładowania strony. Jeśli odblokujesz tę treść, Facebook uzyska informacje o Twojej aktywności."},"shared.json":{"learnMore":"Dowiedz się więcej","readAbout":"Dowiedz się więcej o tej ochronie prywatności","shareFeedback":"Podziel się opinią"},"youtube.json":{"informationalModalMessageTitle":"Włączyć wszystkie podglądy w YouTube?","informationalModalMessageBody":"Wyświetlanie podglądu pozwala Google (który jest właścicielem YouTube) zobaczyć niektóre informacje o Twoim urządzeniu, ale nadal jest to bardziej prywatne niż odtwarzanie filmu.","informationalModalConfirmButtonText":"Włącz wszystkie podglądy","informationalModalRejectButtonText":"Nie, dziękuję","buttonTextUnblockVideo":"Odblokuj wideo w YouTube","infoTitleUnblockVideo":"DuckDuckGo zablokował ten film w YouTube, aby uniemożliwić Google śledzenie Twojej aktywności","infoTextUnblockVideo":"Zablokowaliśmy możliwość śledzenia Cię przez Google (właściciela YouTube) podczas ładowania strony. Jeśli odblokujesz ten film, Google zobaczy Twoją aktywność.","infoPreviewToggleText":"Podglądy zostały wyłączone, aby zapewnić większą ptywatność","infoPreviewToggleEnabledText":"Podglądy włączone","infoPreviewToggleEnabledDuckDuckGoText":"Podglądy YouTube włączone w DuckDuckGo.","infoPreviewInfoText":"Dowiedz się więcej o zabezpieczeniu osadzonych treści społecznościowych DuckDuckGo"}},"pt":{"facebook.json":{"informationalModalMessageTitle":"Iniciar sessão no Facebook permite que este te rastreie","informationalModalMessageBody":"Depois de iniciares sessão, o DuckDuckGo não poderá bloquear o rastreio por parte do conteúdo do Facebook neste site.","informationalModalConfirmButtonText":"Iniciar sessão","informationalModalRejectButtonText":"Retroceder","loginButtonText":"Iniciar sessão com o Facebook","loginBodyText":"O Facebook rastreia a tua atividade num site quando o usas para iniciares sessão.","buttonTextUnblockContent":"Desbloquear Conteúdo do Facebook","buttonTextUnblockComment":"Desbloquear Comentário do Facebook","buttonTextUnblockComments":"Desbloquear Comentários do Facebook","buttonTextUnblockPost":"Desbloquear Publicação no Facebook","buttonTextUnblockVideo":"Desbloquear Vídeo do Facebook","buttonTextUnblockLogin":"Desbloquear Início de Sessão no Facebook","infoTitleUnblockContent":"O DuckDuckGo bloqueou este conteúdo para evitar que o Facebook te rastreie","infoTitleUnblockComment":"O DuckDuckGo bloqueou este comentário para evitar que o Facebook te rastreie","infoTitleUnblockComments":"O DuckDuckGo bloqueou estes comentários para evitar que o Facebook te rastreie","infoTitleUnblockPost":"O DuckDuckGo bloqueou esta publicação para evitar que o Facebook te rastreie","infoTitleUnblockVideo":"O DuckDuckGo bloqueou este vídeo para evitar que o Facebook te rastreie","infoTextUnblockContent":"Bloqueámos o rastreio por parte do Facebook quando a página foi carregada. Se desbloqueares este conteúdo, o Facebook fica a saber a tua atividade."},"shared.json":{"learnMore":"Saiba mais","readAbout":"Ler mais sobre esta proteção de privacidade","shareFeedback":"Partilhar comentários"},"youtube.json":{"informationalModalMessageTitle":"Ativar todas as pré-visualizações do YouTube?","informationalModalMessageBody":"Mostrar visualizações permite à Google (que detém o YouTube) ver algumas das informações do teu dispositivo, mas ainda é mais privado do que reproduzir o vídeo.","informationalModalConfirmButtonText":"Ativar todas as pré-visualizações","informationalModalRejectButtonText":"Não, obrigado","buttonTextUnblockVideo":"Desbloquear Vídeo do YouTube","infoTitleUnblockVideo":"O DuckDuckGo bloqueou este vídeo do YouTube para impedir que a Google te rastreie","infoTextUnblockVideo":"Bloqueámos o rastreio por parte da Google (que detém o YouTube) quando a página foi carregada. Se desbloqueares este vídeo, a Google fica a saber a tua atividade.","infoPreviewToggleText":"Pré-visualizações desativadas para privacidade adicional","infoPreviewToggleEnabledText":"Pré-visualizações ativadas","infoPreviewToggleEnabledDuckDuckGoText":"Pré-visualizações do YouTube ativadas no DuckDuckGo.","infoPreviewInfoText":"Saiba mais sobre a Proteção contra conteúdos de redes sociais incorporados do DuckDuckGo"}},"ro":{"facebook.json":{"informationalModalMessageTitle":"Conectarea cu Facebook îi permite să te urmărească","informationalModalMessageBody":"Odată ce te-ai conectat, DuckDuckGo nu poate împiedica conținutul Facebook să te urmărească pe acest site.","informationalModalConfirmButtonText":"Autentificare","informationalModalRejectButtonText":"Înapoi","loginButtonText":"Conectează-te cu Facebook","loginBodyText":"Facebook urmărește activitatea ta pe un site atunci când îl utilizezi pentru a te conecta.","buttonTextUnblockContent":"Deblochează conținutul Facebook","buttonTextUnblockComment":"Deblochează comentariul de pe Facebook","buttonTextUnblockComments":"Deblochează comentariile de pe Facebook","buttonTextUnblockPost":"Deblochează postarea de pe Facebook","buttonTextUnblockVideo":"Deblochează videoclipul de pe Facebook","buttonTextUnblockLogin":"Deblochează conectarea cu Facebook","infoTitleUnblockContent":"DuckDuckGo a blocat acest conținut pentru a împiedica Facebook să te urmărească","infoTitleUnblockComment":"DuckDuckGo a blocat acest comentariu pentru a împiedica Facebook să te urmărească","infoTitleUnblockComments":"DuckDuckGo a blocat aceste comentarii pentru a împiedica Facebook să te urmărească","infoTitleUnblockPost":"DuckDuckGo a blocat această postare pentru a împiedica Facebook să te urmărească","infoTitleUnblockVideo":"DuckDuckGo a blocat acest videoclip pentru a împiedica Facebook să te urmărească","infoTextUnblockContent":"Am împiedicat Facebook să te urmărească atunci când pagina a fost încărcată. Dacă deblochezi acest conținut, Facebook îți va cunoaște activitatea."},"shared.json":{"learnMore":"Află mai multe","readAbout":"Citește despre această protecție a confidențialității","shareFeedback":"Partajează feedback"},"youtube.json":{"informationalModalMessageTitle":"Activezi toate previzualizările YouTube?","informationalModalMessageBody":"Afișarea previzualizărilor va permite ca Google (care deține YouTube) să vadă unele dintre informațiile despre dispozitivul tău, dar este totuși mai privată decât redarea videoclipului.","informationalModalConfirmButtonText":"Activează toate previzualizările","informationalModalRejectButtonText":"Nu, mulțumesc","buttonTextUnblockVideo":"Deblochează videoclipul de pe YouTube","infoTitleUnblockVideo":"DuckDuckGo a blocat acest videoclip de pe YouTube pentru a împiedica Google să te urmărească","infoTextUnblockVideo":"Am împiedicat Google (care deține YouTube) să te urmărească atunci când s-a încărcat pagina. Dacă deblochezi acest videoclip, Google va cunoaște activitatea ta.","infoPreviewToggleText":"Previzualizările au fost dezactivate pentru o confidențialitate suplimentară","infoPreviewToggleEnabledText":"Previzualizări activate","infoPreviewToggleEnabledDuckDuckGoText":"Previzualizările YouTube sunt activate în DuckDuckGo.","infoPreviewInfoText":"Află mai multe despre Protecția integrată DuckDuckGo pentru rețelele sociale"}},"ru":{"facebook.json":{"informationalModalMessageTitle":"Вход через Facebook позволяет этой социальной сети отслеживать вас","informationalModalMessageBody":"После входа DuckDuckGo не сможет блокировать отслеживание ваших действий с контентом на Facebook.","informationalModalConfirmButtonText":"Войти","informationalModalRejectButtonText":"Вернуться","loginButtonText":"Войти через Facebook","loginBodyText":"При использовании учётной записи Facebook для входа на сайты эта социальная сеть сможет отслеживать на них ваши действия.","buttonTextUnblockContent":"Разблокировать контент из Facebook","buttonTextUnblockComment":"Разблокировать комментарий из Facebook","buttonTextUnblockComments":"Разблокировать комментарии из Facebook","buttonTextUnblockPost":"Разблокировать публикацию из Facebook","buttonTextUnblockVideo":"Разблокировать видео из Facebook","buttonTextUnblockLogin":"Разблокировать окно входа в Facebook","infoTitleUnblockContent":"DuckDuckGo заблокировал этот контент, чтобы вас не отслеживал Facebook","infoTitleUnblockComment":"DuckDuckGo заблокировал этот комментарий, чтобы вас не отслеживал Facebook","infoTitleUnblockComments":"DuckDuckGo заблокировал эти комментарии, чтобы вас не отслеживал Facebook","infoTitleUnblockPost":"DuckDuckGo заблокировал эту публикацию, чтобы вас не отслеживал Facebook","infoTitleUnblockVideo":"DuckDuckGo заблокировал это видео, чтобы вас не отслеживал Facebook","infoTextUnblockContent":"Во время загрузки страницы мы помешали Facebook отследить ваши действия. Если разблокировать этот контент, Facebook сможет фиксировать вашу активность."},"shared.json":{"learnMore":"Узнать больше","readAbout":"Подробнее об этом виде защиты конфиденциальности","shareFeedback":"Оставьте нам отзыв"},"youtube.json":{"informationalModalMessageTitle":"Включить предпросмотр видео из YouTube?","informationalModalMessageBody":"Включение предварительного просмотра позволит Google (владельцу YouTube) получить некоторые сведения о вашем устройстве, однако это более безопасный вариант, чем воспроизведение видео целиком.","informationalModalConfirmButtonText":"Включить предпросмотр","informationalModalRejectButtonText":"Нет, спасибо","buttonTextUnblockVideo":"Разблокировать видео из YouTube","infoTitleUnblockVideo":"DuckDuckGo заблокировал это видео из YouTube, чтобы вас не отслеживал Google","infoTextUnblockVideo":"Во время загрузки страницы мы помешали Google (владельцу YouTube) отследить ваши действия. Если разблокировать видео, Google сможет фиксировать вашу активность.","infoPreviewToggleText":"Предварительный просмотр отключён для дополнительной защиты конфиденциальности","infoPreviewToggleEnabledText":"Предварительный просмотр включён","infoPreviewToggleEnabledDuckDuckGoText":"В DuckDuckGo включён предпросмотр видео из YouTube.","infoPreviewInfoText":"Подробнее о защите DuckDuckGo от внедрённого контента соцсетей"}},"sk":{"facebook.json":{"informationalModalMessageTitle":"Prihlásenie cez Facebook mu umožní sledovať vás","informationalModalMessageBody":"DuckDuckGo po prihlásení nemôže na tejto lokalite zablokovať sledovanie vašej osoby obsahom Facebooku.","informationalModalConfirmButtonText":"Prihlásiť sa","informationalModalRejectButtonText":"Prejsť späť","loginButtonText":"Prihláste sa pomocou služby Facebook","loginBodyText":"Keď použijete prihlasovanie cez Facebook, Facebook bude na lokalite sledovať vašu aktivitu.","buttonTextUnblockContent":"Odblokovať obsah Facebooku","buttonTextUnblockComment":"Odblokovať komentár na Facebooku","buttonTextUnblockComments":"Odblokovať komentáre na Facebooku","buttonTextUnblockPost":"Odblokovať príspevok na Facebooku","buttonTextUnblockVideo":"Odblokovanie videa na Facebooku","buttonTextUnblockLogin":"Odblokovať prihlásenie na Facebook","infoTitleUnblockContent":"DuckDuckGo zablokoval tento obsah, aby vás Facebook nesledoval","infoTitleUnblockComment":"DuckDuckGo zablokoval tento komentár, aby zabránil sledovaniu zo strany Facebooku","infoTitleUnblockComments":"DuckDuckGo zablokoval tieto komentáre, aby vás Facebook nesledoval","infoTitleUnblockPost":"DuckDuckGo zablokoval tento príspevok, aby vás Facebook nesledoval","infoTitleUnblockVideo":"DuckDuckGo zablokoval toto video, aby vás Facebook nesledoval","infoTextUnblockContent":"Pri načítaní stránky sme zablokovali Facebook, aby vás nesledoval. Ak tento obsah odblokujete, Facebook bude vedieť o vašej aktivite."},"shared.json":{"learnMore":"Zistite viac","readAbout":"Prečítajte si o tejto ochrane súkromia","shareFeedback":"Zdieľať spätnú väzbu"},"youtube.json":{"informationalModalMessageTitle":"Chcete povoliť všetky ukážky zo služby YouTube?","informationalModalMessageBody":"Zobrazenie ukážok umožní spoločnosti Google (ktorá vlastní YouTube) vidieť niektoré informácie o vašom zariadení, ale stále je to súkromnejšie ako prehrávanie videa.","informationalModalConfirmButtonText":"Povoliť všetky ukážky","informationalModalRejectButtonText":"Nie, ďakujem","buttonTextUnblockVideo":"Odblokovať YouTube video","infoTitleUnblockVideo":"DuckDuckGo toto video v službe YouTube zablokoval s cieľom predísť tomu, aby vás spoločnosť Google mohla sledovať","infoTextUnblockVideo":"Zablokovali sme pre spoločnosť Google (ktorá vlastní YouTube), aby vás nemohla sledovať, keď sa stránka načíta. Ak toto video odblokujete, Google bude poznať vašu aktivitu.","infoPreviewToggleText":"Ukážky sú zakázané s cieľom zvýšiť ochranu súkromia","infoPreviewToggleEnabledText":"Ukážky sú povolené","infoPreviewToggleEnabledDuckDuckGoText":"Ukážky YouTube sú v DuckDuckGo povolené.","infoPreviewInfoText":"Získajte viac informácií o DuckDuckGo, vloženej ochrane sociálnych médií"}},"sl":{"facebook.json":{"informationalModalMessageTitle":"Če se prijavite s Facebookom, vam Facebook lahko sledi","informationalModalMessageBody":"Ko ste enkrat prijavljeni, DuckDuckGo ne more blokirati Facebookove vsebine, da bi vam sledila na tem spletnem mestu.","informationalModalConfirmButtonText":"Prijava","informationalModalRejectButtonText":"Pojdi nazaj","loginButtonText":"Prijavite se s Facebookom","loginBodyText":"Če se prijavite s Facebookom, bo nato spremljal vaša dejanja na spletnem mestu.","buttonTextUnblockContent":"Odblokiraj vsebino na Facebooku","buttonTextUnblockComment":"Odblokiraj komentar na Facebooku","buttonTextUnblockComments":"Odblokiraj komentarje na Facebooku","buttonTextUnblockPost":"Odblokiraj objavo na Facebooku","buttonTextUnblockVideo":"Odblokiraj videoposnetek na Facebooku","buttonTextUnblockLogin":"Odblokiraj prijavo na Facebooku","infoTitleUnblockContent":"DuckDuckGo je blokiral to vsebino, da bi Facebooku preprečil sledenje","infoTitleUnblockComment":"DuckDuckGo je blokiral ta komentar, da bi Facebooku preprečil sledenje","infoTitleUnblockComments":"DuckDuckGo je blokiral te komentarje, da bi Facebooku preprečil sledenje","infoTitleUnblockPost":"DuckDuckGo je blokiral to objavo, da bi Facebooku preprečil sledenje","infoTitleUnblockVideo":"DuckDuckGo je blokiral ta videoposnetek, da bi Facebooku preprečil sledenje","infoTextUnblockContent":"Ko se je stran naložila, smo Facebooku preprečili, da bi vam sledil. Če to vsebino odblokirate, bo Facebook izvedel za vaša dejanja."},"shared.json":{"learnMore":"Več","readAbout":"Preberite več o tej zaščiti zasebnosti","shareFeedback":"Deli povratne informacije"},"youtube.json":{"informationalModalMessageTitle":"Želite omogočiti vse YouTubove predoglede?","informationalModalMessageBody":"Prikaz predogledov omogoča Googlu (ki je lastnik YouTuba) vpogled v nekatere podatke o napravi, vendar je še vedno bolj zasebno kot predvajanje videoposnetka.","informationalModalConfirmButtonText":"Omogoči vse predoglede","informationalModalRejectButtonText":"Ne, hvala","buttonTextUnblockVideo":"Odblokiraj videoposnetek na YouTubu","infoTitleUnblockVideo":"DuckDuckGo je blokiral ta videoposnetek v YouTubu, da bi Googlu preprečil sledenje","infoTextUnblockVideo":"Googlu (ki je lastnik YouTuba) smo preprečili, da bi vam sledil, ko se je stran naložila. Če odblokirate ta videoposnetek, bo Google izvedel za vašo dejavnost.","infoPreviewToggleText":"Predogledi so zaradi dodatne zasebnosti onemogočeni","infoPreviewToggleEnabledText":"Predogledi so omogočeni","infoPreviewToggleEnabledDuckDuckGoText":"YouTubovi predogledi so omogočeni v DuckDuckGo.","infoPreviewInfoText":"Več o vgrajeni zaščiti družbenih medijev DuckDuckGo"}},"sv":{"facebook.json":{"informationalModalMessageTitle":"Om du loggar in med Facebook kan de spåra dig","informationalModalMessageBody":"När du väl är inloggad kan DuckDuckGo inte hindra Facebooks innehåll från att spåra dig på den här webbplatsen.","informationalModalConfirmButtonText":"Logga in","informationalModalRejectButtonText":"Gå tillbaka","loginButtonText":"Logga in med Facebook","loginBodyText":"Facebook spårar din aktivitet på en webbplats om du använder det för att logga in.","buttonTextUnblockContent":"Avblockera Facebook-innehåll","buttonTextUnblockComment":"Avblockera Facebook-kommentar","buttonTextUnblockComments":"Avblockera Facebook-kommentarer","buttonTextUnblockPost":"Avblockera Facebook-inlägg","buttonTextUnblockVideo":"Avblockera Facebook-video","buttonTextUnblockLogin":"Avblockera Facebook-inloggning","infoTitleUnblockContent":"DuckDuckGo blockerade det här innehållet för att förhindra att Facebook spårar dig","infoTitleUnblockComment":"DuckDuckGo blockerade den här kommentaren för att förhindra att Facebook spårar dig","infoTitleUnblockComments":"DuckDuckGo blockerade de här kommentarerna för att förhindra att Facebook spårar dig","infoTitleUnblockPost":"DuckDuckGo blockerade det här inlägget för att förhindra att Facebook spårar dig","infoTitleUnblockVideo":"DuckDuckGo blockerade den här videon för att förhindra att Facebook spårar dig","infoTextUnblockContent":"Vi hindrade Facebook från att spåra dig när sidan lästes in. Om du avblockerar det här innehållet kommer Facebook att känna till din aktivitet."},"shared.json":{"learnMore":"Läs mer","readAbout":"Läs mer om detta integritetsskydd","shareFeedback":"Berätta vad du tycker"},"youtube.json":{"informationalModalMessageTitle":"Aktivera alla förhandsvisningar för YouTube?","informationalModalMessageBody":"Genom att visa förhandsvisningar kan Google (som äger YouTube) se en del av enhetens information, men det är ändå mer privat än att spela upp videon.","informationalModalConfirmButtonText":"Aktivera alla förhandsvisningar","informationalModalRejectButtonText":"Nej tack","buttonTextUnblockVideo":"Avblockera YouTube-video","infoTitleUnblockVideo":"DuckDuckGo blockerade den här YouTube-videon för att förhindra att Google spårar dig","infoTextUnblockVideo":"Vi hindrade Google (som äger YouTube) från att spåra dig när sidan laddades. Om du tar bort blockeringen av videon kommer Google att känna till din aktivitet.","infoPreviewToggleText":"Förhandsvisningar har inaktiverats för ytterligare integritet","infoPreviewToggleEnabledText":"Förhandsvisningar aktiverade","infoPreviewToggleEnabledDuckDuckGoText":"YouTube-förhandsvisningar aktiverade i DuckDuckGo.","infoPreviewInfoText":"Läs mer om DuckDuckGos skydd mot inbäddade sociala medier"}},"tr":{"facebook.json":{"informationalModalMessageTitle":"Facebook ile giriş yapmak, sizi takip etmelerini sağlar","informationalModalMessageBody":"Giriş yaptıktan sonra, DuckDuckGo Facebook içeriğinin sizi bu sitede izlemesini engelleyemez.","informationalModalConfirmButtonText":"Oturum Aç","informationalModalRejectButtonText":"Geri dön","loginButtonText":"Facebook ile giriş yapın","loginBodyText":"Facebook, giriş yapmak için kullandığınızda bir sitedeki etkinliğinizi izler.","buttonTextUnblockContent":"Facebook İçeriğinin Engelini Kaldır","buttonTextUnblockComment":"Facebook Yorumunun Engelini Kaldır","buttonTextUnblockComments":"Facebook Yorumlarının Engelini Kaldır","buttonTextUnblockPost":"Facebook Gönderisinin Engelini Kaldır","buttonTextUnblockVideo":"Facebook Videosunun Engelini Kaldır","buttonTextUnblockLogin":"Facebook Girişinin Engelini Kaldır","infoTitleUnblockContent":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu içeriği engelledi","infoTitleUnblockComment":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu yorumu engelledi","infoTitleUnblockComments":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu yorumları engelledi","infoTitleUnblockPost":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu gönderiyi engelledi","infoTitleUnblockVideo":"DuckDuckGo, Facebook'un sizi izlemesini önlemek için bu videoyu engelledi","infoTextUnblockContent":"Sayfa yüklendiğinde Facebook'un sizi izlemesini engelledik. Bu içeriğin engelini kaldırırsanız Facebook etkinliğinizi öğrenecektir."},"shared.json":{"learnMore":"Daha Fazla Bilgi","readAbout":"Bu gizlilik koruması hakkında bilgi edinin","shareFeedback":"Geri Bildirim Paylaş"},"youtube.json":{"informationalModalMessageTitle":"Tüm YouTube önizlemeleri etkinleştirilsin mi?","informationalModalMessageBody":"Önizlemelerin gösterilmesi Google'ın (YouTube'un sahibi) cihazınızın bazı bilgilerini görmesine izin verir, ancak yine de videoyu oynatmaktan daha özeldir.","informationalModalConfirmButtonText":"Tüm Önizlemeleri Etkinleştir","informationalModalRejectButtonText":"Hayır Teşekkürler","buttonTextUnblockVideo":"YouTube Videosunun Engelini Kaldır","infoTitleUnblockVideo":"DuckDuckGo, Google'ın sizi izlemesini önlemek için bu YouTube videosunu engelledi","infoTextUnblockVideo":"Sayfa yüklendiğinde Google'ın (YouTube'un sahibi) sizi izlemesini engelledik. Bu videonun engelini kaldırırsanız, Google etkinliğinizi öğrenecektir.","infoPreviewToggleText":"Ek gizlilik için önizlemeler devre dışı bırakıldı","infoPreviewToggleEnabledText":"Önizlemeler etkinleştirildi","infoPreviewToggleEnabledDuckDuckGoText":"DuckDuckGo'da YouTube önizlemeleri etkinleştirildi.","infoPreviewInfoText":"DuckDuckGo Yerleşik Sosyal Medya Koruması hakkında daha fazla bilgi edinin"}}}`; @@ -7177,9 +7382,10 @@ * (e.g. fonts.) * @param {import('../../content-feature.js').AssetConfig} [assets] */ - function getStyles (assets) { + function getStyles(assets) { let fontStyle = ''; - let regularFontFamily = "system, -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'"; + let regularFontFamily = + "system, -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'"; let boldFontFamily = regularFontFamily; if (assets?.regularFontUrl && assets?.boldFontUrl) { fontStyle = ` @@ -7229,8 +7435,8 @@ `, inactive: ` background-color: #666666; - ` - } + `, + }, }, lightMode: { background: ` @@ -7263,8 +7469,8 @@ `, inactive: ` background-color: #666666; - ` - } + `, + }, }, loginMode: { buttonBackground: ` @@ -7272,7 +7478,7 @@ `, buttonFont: ` color: #FFFFFF; - ` + `, }, cancelMode: { buttonBackground: ` @@ -7286,7 +7492,7 @@ `, buttonBackgroundPress: ` background: rgba(0, 0, 0, 0.18); - ` + `, }, button: ` border-radius: 8px; @@ -7671,7 +7877,7 @@ `, inactive: ` left: 1px; - ` + `, }, placeholderWrapperDiv: ` position: relative; @@ -7777,14 +7983,14 @@ `, youTubePreviewInfoText: ` color: #ABABAB; - ` - } + `, + }; } /** * @param {string} locale UI locale */ - function getConfig (locale) { + function getConfig(locale) { const allLocales = JSON.parse(localesJSON); const localeStrings = allLocales[locale] || allLocales.en; @@ -7799,199 +8005,187 @@ messageTitle: fbStrings.informationalModalMessageTitle, messageBody: fbStrings.informationalModalMessageBody, confirmButtonText: fbStrings.informationalModalConfirmButtonText, - rejectButtonText: fbStrings.informationalModalRejectButtonText + rejectButtonText: fbStrings.informationalModalRejectButtonText, }, elementData: { 'FB Like Button': { - selectors: [ - '.fb-like' - ], + selectors: ['.fb-like'], replaceSettings: { - type: 'blank' - } + type: 'blank', + }, }, 'FB Button iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/like.php']", "iframe[src*='//www.facebook.com/v2.0/plugins/like.php']", "iframe[src*='//www.facebook.com/plugins/share_button.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/share_button.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/share_button.php']", ], replaceSettings: { - type: 'blank' - } + type: 'blank', + }, }, 'FB Save Button': { - selectors: [ - '.fb-save' - ], + selectors: ['.fb-save'], replaceSettings: { - type: 'blank' - } + type: 'blank', + }, }, 'FB Share Button': { - selectors: [ - '.fb-share-button' - ], + selectors: ['.fb-share-button'], replaceSettings: { - type: 'blank' - } + type: 'blank', + }, }, 'FB Page iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/page.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/page.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/page.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockContent, infoTitle: fbStrings.infoTitleUnblockContent, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Page Div': { - selectors: [ - '.fb-page' - ], + selectors: ['.fb-page'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockContent, infoTitle: fbStrings.infoTitleUnblockContent, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'iFrame', - targetURL: 'https://www.facebook.com/plugins/page.php?href=data-href&tabs=data-tabs&width=data-width&height=data-height', + targetURL: + 'https://www.facebook.com/plugins/page.php?href=data-href&tabs=data-tabs&width=data-width&height=data-height', urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-tabs': { - default: 'timeline' + default: 'timeline', }, 'data-height': { - default: '500' + default: '500', }, 'data-width': { - default: '500' - } + default: '500', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' + unit: 'px', }, height: { name: 'data-height', - unit: 'px' - } - } - } + unit: 'px', + }, + }, + }, }, 'FB Comment iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/comment_embed.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/comment_embed.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/comment_embed.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockComment, infoTitle: fbStrings.infoTitleUnblockComment, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Comments': { - selectors: [ - '.fb-comments', - 'fb\\:comments' - ], + selectors: ['.fb-comments', 'fb\\:comments'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockComments, infoTitle: fbStrings.infoTitleUnblockComments, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'allowFull', - targetURL: 'https://www.facebook.com/v9.0/plugins/comments.php?href=data-href&numposts=data-numposts&sdk=joey&version=v9.0&width=data-width', + targetURL: + 'https://www.facebook.com/v9.0/plugins/comments.php?href=data-href&numposts=data-numposts&sdk=joey&version=v9.0&width=data-width', urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-numposts': { - default: 10 + default: 10, }, 'data-width': { - default: '500' - } - } - } + default: '500', + }, + }, + }, }, 'FB Embedded Comment Div': { - selectors: [ - '.fb-comment-embed' - ], + selectors: ['.fb-comment-embed'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockComment, infoTitle: fbStrings.infoTitleUnblockComment, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'iFrame', - targetURL: 'https://www.facebook.com/v9.0/plugins/comment_embed.php?href=data-href&sdk=joey&width=data-width&include_parent=data-include-parent', + targetURL: + 'https://www.facebook.com/v9.0/plugins/comment_embed.php?href=data-href&sdk=joey&width=data-width&include_parent=data-include-parent', urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' + default: '500', }, 'data-include-parent': { - default: 'false' - } + default: 'false', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' - } - } - } + unit: 'px', + }, + }, + }, }, 'FB Post iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/post.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/post.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/post.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockPost, infoTitle: fbStrings.infoTitleUnblockPost, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Posts Div': { - selectors: [ - '.fb-post' - ], + selectors: ['.fb-post'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockPost, infoTitle: fbStrings.infoTitleUnblockPost, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'allowFull', @@ -7999,49 +8193,47 @@ urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' - } + default: '500', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' + unit: 'px', }, height: { name: 'data-height', unit: 'px', - fallbackAttribute: 'data-width' - } - } - } + fallbackAttribute: 'data-width', + }, + }, + }, }, 'FB Video iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/video.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/video.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/video.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockVideo, infoTitle: fbStrings.infoTitleUnblockVideo, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Video': { - selectors: [ - '.fb-video' - ], + selectors: ['.fb-video'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockVideo, infoTitle: fbStrings.infoTitleUnblockVideo, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'iFrame', @@ -8049,49 +8241,47 @@ urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' - } + default: '500', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' + unit: 'px', }, height: { name: 'data-height', unit: 'px', - fallbackAttribute: 'data-width' - } - } - } + fallbackAttribute: 'data-width', + }, + }, + }, }, 'FB Group iFrames': { selectors: [ "iframe[src*='//www.facebook.com/plugins/group.php']", - "iframe[src*='//www.facebook.com/v2.0/plugins/group.php']" + "iframe[src*='//www.facebook.com/v2.0/plugins/group.php']", ], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockContent, infoTitle: fbStrings.infoTitleUnblockContent, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { - type: 'originalElement' - } + type: 'originalElement', + }, }, 'FB Group': { - selectors: [ - '.fb-group' - ], + selectors: ['.fb-group'], replaceSettings: { type: 'dialog', buttonText: fbStrings.buttonTextUnblockContent, infoTitle: fbStrings.infoTitleUnblockContent, - infoText: fbStrings.infoTextUnblockContent + infoText: fbStrings.infoTextUnblockContent, }, clickAction: { type: 'iFrame', @@ -8099,49 +8289,48 @@ urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' - } + default: '500', + }, }, styleDataAttributes: { width: { name: 'data-width', - unit: 'px' - } - } - } + unit: 'px', + }, + }, + }, }, 'FB Login Button': { - selectors: [ - '.fb-login-button' - ], + selectors: ['.fb-login-button'], replaceSettings: { type: 'loginButton', icon: blockedFBLogo, buttonText: fbStrings.loginButtonText, buttonTextUnblockLogin: fbStrings.buttonTextUnblockLogin, - popupBodyText: fbStrings.loginBodyText + popupBodyText: fbStrings.loginBodyText, }, clickAction: { type: 'allowFull', - targetURL: 'https://www.facebook.com/v9.0/plugins/login_button.php?app_id=app_id_replace&auto_logout_link=false&button_type=continue_with&sdk=joey&size=large&use_continue_as=false&width=', + targetURL: + 'https://www.facebook.com/v9.0/plugins/login_button.php?app_id=app_id_replace&auto_logout_link=false&button_type=continue_with&sdk=joey&size=large&use_continue_as=false&width=', urlDataAttributesToPreserve: { 'data-href': { default: '', - required: true + required: true, }, 'data-width': { - default: '500' + default: '500', }, app_id_replace: { - default: 'null' - } - } - } - } - } + default: 'null', + }, + }, + }, + }, + }, }, Youtube: { informationalModal: { @@ -8149,7 +8338,7 @@ messageTitle: ytStrings.informationalModalMessageTitle, messageBody: ytStrings.informationalModalMessageBody, confirmButtonText: ytStrings.informationalModalConfirmButtonText, - rejectButtonText: ytStrings.informationalModalRejectButtonText + rejectButtonText: ytStrings.informationalModalRejectButtonText, }, elementData: { 'YouTube embedded video': { @@ -8161,7 +8350,7 @@ "iframe[data-src*='//youtube.com/embed']", "iframe[data-src*='//youtube-nocookie.com/embed']", "iframe[data-src*='//www.youtube.com/embed']", - "iframe[data-src*='//www.youtube-nocookie.com/embed']" + "iframe[data-src*='//www.youtube-nocookie.com/embed']", ], replaceSettings: { type: 'youtube-video', @@ -8175,13 +8364,13 @@ previewToggleEnabledDuckDuckGoText: ytStrings.infoPreviewToggleEnabledText, videoPlayIcon: { lightMode: videoPlayLight, - darkMode: videoPlayDark - } - } + darkMode: videoPlayDark, + }, + }, }, clickAction: { - type: 'youtube-video' - } + type: 'youtube-video', + }, }, 'YouTube embedded subscription button': { selectors: [ @@ -8192,24 +8381,24 @@ "iframe[data-src*='//youtube.com/subscribe_embed']", "iframe[data-src*='//youtube-nocookie.com/subscribe_embed']", "iframe[data-src*='//www.youtube.com/subscribe_embed']", - "iframe[data-src*='//www.youtube-nocookie.com/subscribe_embed']" + "iframe[data-src*='//www.youtube-nocookie.com/subscribe_embed']", ], replaceSettings: { - type: 'blank' - } - } - } - } + type: 'blank', + }, + }, + }, + }, }; - return { config, sharedStrings } + return { config, sharedStrings }; } /** * The following code is originally from https://github.com/mozilla-extensions/secure-proxy/blob/db4d1b0e2bfe0abae416bf04241916f9e4768fd2/src/commons/template.js */ class Template { - constructor (strings, values) { + constructor(strings, values) { this.values = values; this.strings = strings; } @@ -8221,35 +8410,35 @@ * The string to escape. * @return {string} The escaped string. */ - escapeXML (str) { + escapeXML(str) { const replacements = { '&': '&', '"': '"', "'": ''', '<': '<', '>': '>', - '/': '/' + '/': '/', }; - return String(str).replace(/[&"'<>/]/g, m => replacements[m]) + return String(str).replace(/[&"'<>/]/g, (m) => replacements[m]); } - potentiallyEscape (value) { + potentiallyEscape(value) { if (typeof value === 'object') { if (value instanceof Array) { - return value.map(val => this.potentiallyEscape(val)).join('') + return value.map((val) => this.potentiallyEscape(val)).join(''); } // If we are an escaped template let join call toString on it if (value instanceof Template) { - return value + return value; } - throw new Error('Unknown object to escape') + throw new Error('Unknown object to escape'); } - return this.escapeXML(value) + return this.escapeXML(value); } - toString () { + toString() { const result = []; for (const [i, string] of this.strings.entries()) { @@ -8258,33 +8447,33 @@ result.push(this.potentiallyEscape(this.values[i])); } } - return result.join('') + return result.join(''); } } - function html (strings, ...values) { - return new Template(strings, values) + function html(strings, ...values) { + return new Template(strings, values); } /** * @param {string} string * @return {Template} */ - function trustedUnsafe (string) { - return html([string]) + function trustedUnsafe(string) { + return html([string]); } /** * Use a policy if trustedTypes is available * @return {{createHTML: (s: string) => any}} */ - function createPolicy () { + function createPolicy() { if (globalThis.trustedTypes) { - return globalThis.trustedTypes?.createPolicy?.('ddg-default', { createHTML: (s) => s }) + return globalThis.trustedTypes?.createPolicy?.('ddg-default', { createHTML: (s) => s }); } return { - createHTML: (s) => s - } + createHTML: (s) => s, + }; } var cssVars = ":host {\n /* Color palette */\n --ddg-shade-06: rgba(0, 0, 0, 0.06);\n --ddg-shade-12: rgba(0, 0, 0, 0.12);\n --ddg-shade-18: rgba(0, 0, 0, 0.18);\n --ddg-shade-36: rgba(0, 0, 0, 0.36);\n --ddg-shade-84: rgba(0, 0, 0, 0.84);\n --ddg-tint-12: rgba(255, 255, 255, 0.12);\n --ddg-tint-18: rgba(255, 255, 255, 0.18);\n --ddg-tint-24: rgba(255, 255, 255, 0.24);\n --ddg-tint-84: rgba(255, 255, 255, 0.84);\n /* Tokens */\n --ddg-color-primary: #3969ef;\n --ddg-color-bg-01: #ffffff;\n --ddg-color-bg-02: #ababab;\n --ddg-color-border: var(--ddg-shade-12);\n --ddg-color-txt: var(--ddg-shade-84);\n --ddg-color-txt-link-02: #ababab;\n}\n@media (prefers-color-scheme: dark) {\n :host {\n --ddg-color-primary: #7295f6;\n --ddg-color-bg-01: #222222;\n --ddg-color-bg-02: #444444;\n --ddg-color-border: var(--ddg-tint-12);\n --ddg-color-txt: var(--ddg-tint-84);\n }\n}\n\n/* SHARED STYLES */\n/* Text Link */\n.ddg-text-link {\n line-height: 1.4;\n font-size: 14px;\n font-weight: 700;\n cursor: pointer;\n text-decoration: none;\n color: var(--ddg-color-primary);\n}\n\n/* Button */\n.DuckDuckGoButton {\n border-radius: 8px;\n padding: 8px 16px;\n border-color: var(--ddg-color-primary);\n border: none;\n min-height: 36px;\n\n position: relative;\n cursor: pointer;\n box-shadow: none;\n z-index: 2147483646;\n}\n.DuckDuckGoButton > div {\n display: flex;\n flex-direction: row;\n align-items: center;\n border: none;\n padding: 0;\n margin: 0;\n}\n.DuckDuckGoButton,\n.DuckDuckGoButton > div {\n font-size: 14px;\n font-family: DuckDuckGoPrivacyEssentialsBold;\n font-weight: 600;\n}\n.DuckDuckGoButton.tertiary {\n color: var(--ddg-color-txt);\n background-color: transparent;\n display: flex;\n justify-content: center;\n align-items: center;\n border: 1px solid var(--ddg-color-border);\n border-radius: 8px;\n}\n.DuckDuckGoButton.tertiary:hover {\n background: var(--ddg-shade-06);\n border-color: var(--ddg-shade-18);\n}\n@media (prefers-color-scheme: dark) {\n .DuckDuckGoButton.tertiary:hover {\n background: var(--ddg-tint-18);\n border-color: var(--ddg-tint-24);\n }\n}\n.DuckDuckGoButton.tertiary:active {\n background: var(--ddg-shade-12);\n border-color: var(--ddg-shade-36);\n}\n@media (prefers-color-scheme: dark) {\n .DuckDuckGoButton.tertiary:active {\n background: var(--ddg-tint-24);\n border-color: var(--ddg-tint-24);\n }\n}\n"; @@ -8322,26 +8511,26 @@ * This is currently only used in our Mobile Apps, but can be expanded in the future. */ class DDGCtlPlaceholderBlockedElement extends HTMLElement { - static CUSTOM_TAG_NAME = 'ddg-ctl-placeholder-blocked' + static CUSTOM_TAG_NAME = 'ddg-ctl-placeholder-blocked'; /** * Min height that the placeholder needs to have in order to * have enough room to display content. */ - static MIN_CONTENT_HEIGHT = 110 - static MAX_CONTENT_WIDTH_SMALL = 480 - static MAX_CONTENT_WIDTH_MEDIUM = 650 + static MIN_CONTENT_HEIGHT = 110; + static MAX_CONTENT_WIDTH_SMALL = 480; + static MAX_CONTENT_WIDTH_MEDIUM = 650; /** * Set observed attributes that will trigger attributeChangedCallback() */ - static get observedAttributes () { - return ['style'] + static get observedAttributes() { + return ['style']; } /** * Placeholder element for blocked content * @type {HTMLDivElement} */ - placeholderBlocked + placeholderBlocked; /** * Size variant of the latest calculated size of the placeholder. @@ -8349,7 +8538,7 @@ * and adapt the layout for each size. * @type {placeholderSize} */ - size = null + size = null; /** * @param {object} params - Params for building a custom element @@ -8365,7 +8554,7 @@ * @param {WithFeedbackParams=} params.withFeedback - Shows feedback link on tablet and desktop sizes, * @param {(originalElement: HTMLIFrameElement | HTMLElement, replacementElement: HTMLElement) => (e: any) => void} params.onButtonClick */ - constructor (params) { + constructor(params) { super(); this.params = params; /** @@ -8373,7 +8562,7 @@ * @type {ShadowRoot} */ const shadow = this.attachShadow({ - mode: this.params.devMode ? 'open' : 'closed' + mode: this.params.devMode ? 'open' : 'closed', }); /** @@ -8401,6 +8590,7 @@ /** * Append both to the shadow root */ + // eslint-disable-next-line @typescript-eslint/no-unused-expressions feedbackLink && this.placeholderBlocked.appendChild(feedbackLink); shadow.appendChild(this.placeholderBlocked); shadow.appendChild(style); @@ -8420,15 +8610,13 @@ container.classList.add('DuckDuckGoSocialContainer'); const cardClassNames = [ ['slim-card', !!useSlimCard], - ['with-feedback-link', !!withFeedback] + ['with-feedback-link', !!withFeedback], ] .map(([className, active]) => (active ? className : '')) .join(' '); // Only add a card footer if we have the toggle button to display - const cardFooterSection = withToggle - ? html` ` - : ''; + const cardFooterSection = withToggle ? html` ` : ''; const learnMoreLink = this.createLearnMoreLink(); container.innerHTML = html` @@ -8447,8 +8635,8 @@ `.toString(); - return container - } + return container; + }; /** * Creates a template string for Learn More link. @@ -8462,8 +8650,8 @@ href="https://help.duckduckgo.com/duckduckgo-help-pages/privacy/embedded-content-protection/" target="_blank" >${learnMore.learnMore}` - } + >`; + }; /** * Creates a Feedback Link container row @@ -8479,15 +8667,15 @@ `.toString(); - return container - } + return container; + }; /** * Creates a template string for a toggle button with text. */ createToggleButton = () => { const { withToggle } = this.params; - if (!withToggle) return + if (!withToggle) return; const { isActive, dataKey, label, size: toggleSize = 'md' } = withToggle; @@ -8505,8 +8693,8 @@
${label}
`; - return toggleButton - } + return toggleButton; + }; /** * @@ -8516,19 +8704,15 @@ setupEventListeners = (containerElement, feedbackLink) => { const { withToggle, withFeedback, originalElement, onButtonClick } = this.params; - containerElement - .querySelector('button.ddg-ctl-unblock-btn') - ?.addEventListener('click', onButtonClick(originalElement, this)); + containerElement.querySelector('button.ddg-ctl-unblock-btn')?.addEventListener('click', onButtonClick(originalElement, this)); if (withToggle) { - containerElement - .querySelector('.ddg-toggle-button-container') - ?.addEventListener('click', withToggle.onClick); + containerElement.querySelector('.ddg-toggle-button-container')?.addEventListener('click', withToggle.onClick); } if (withFeedback && feedbackLink) { feedbackLink.querySelector('.ddg-ctl-feedback-link')?.addEventListener('click', withFeedback.onClick); } - } + }; /** * Use JS to calculate the width and height of the root element placeholder. We could use a CSS Container Query, but full @@ -8559,14 +8743,14 @@ this.placeholderBlocked.classList.add(newSize); this.size = newSize; } - } + }; /** * Web Component lifecycle function. * When element is first added to the DOM, trigger this callback and * update the element CSS size class. */ - connectedCallback () { + connectedCallback() { this.updatePlaceholderSize(); } @@ -8579,7 +8763,7 @@ * @param {*} _ Attribute old value, ignored * @param {*} newValue Attribute new value */ - attributeChangedCallback (attr, _, newValue) { + attributeChangedCallback(attr, _, newValue) { if (attr === 'style') { this.placeholderBlocked[attr].cssText = newValue; this.updatePlaceholderSize(); @@ -8606,7 +8790,7 @@ * Placeholder container element for blocked login button * @type {HTMLDivElement} */ - #element + #element; /** * @param {object} params - Params for building a custom element with @@ -8620,7 +8804,7 @@ * @param {LearnMoreParams} params.learnMore - Localized strings for "Learn More" link. * @param {(originalElement: HTMLIFrameElement | HTMLElement, replacementElement: HTMLElement) => (e: any) => void} params.onClick */ - constructor (params) { + constructor(params) { this.params = params; /** @@ -8634,7 +8818,7 @@ * @type {ShadowRoot} */ const shadow = this.element.attachShadow({ - mode: this.params.devMode ? 'open' : 'closed' + mode: this.params.devMode ? 'open' : 'closed', }); /** @@ -8665,14 +8849,14 @@ /** * @returns {HTMLDivElement} */ - get element () { - return this.#element + get element() { + return this.#element; } /** * @param {HTMLDivElement} el - New placeholder element */ - set element (el) { + set element(el) { this.#element = el; } @@ -8682,7 +8866,7 @@ * proceed. * @returns {HTMLDivElement} */ - _createLoginButton () { + _createLoginButton() { const { label, hoverText, logoIcon, learnMore } = this.params; const { popoverStyle, arrowStyle } = this._calculatePopoverPosition(); @@ -8726,7 +8910,7 @@ `.toString(); - return container + return container; } /** @@ -8739,7 +8923,7 @@ * arrowStyle: string, // CSS styles to be applied in the Popover arrow * }} */ - _calculatePopoverPosition () { + _calculatePopoverPosition() { const { originalElement } = this.params; const rect = originalElement.getBoundingClientRect(); const textBubbleWidth = 360; // Should match the width rule in .ddg-popover @@ -8766,19 +8950,17 @@ arrowStyle = `left: ${arrowDefaultLocationPercent}%;`; } - return { popoverStyle, arrowStyle } + return { popoverStyle, arrowStyle }; } /** * * @param {HTMLElement} loginButton */ - _setupEventListeners (loginButton) { + _setupEventListeners(loginButton) { const { originalElement, onClick } = this.params; - loginButton - .querySelector('.ddg-ctl-fb-login-btn') - ?.addEventListener('click', onClick(originalElement, this.element)); + loginButton.querySelector('.ddg-ctl-fb-login-btn')?.addEventListener('click', onClick(originalElement, this.element)); } } @@ -8786,7 +8968,7 @@ * Register custom elements in this wrapper function to be called only when we need to * and also to allow remote-config later if needed. */ - function registerCustomElements$1 () { + function registerCustomElements$1() { if (!customElements.get(DDGCtlPlaceholderBlockedElement.CUSTOM_TAG_NAME)) { customElements.define(DDGCtlPlaceholderBlockedElement.CUSTOM_TAG_NAME, DDGCtlPlaceholderBlockedElement); } @@ -8842,7 +9024,7 @@ // finished its work, enough that it's now safe to replace elements with // placeholders. let readyToDisplayPlaceholdersResolver; - const readyToDisplayPlaceholders = new Promise(resolve => { + const readyToDisplayPlaceholders = new Promise((resolve) => { readyToDisplayPlaceholdersResolver = resolve; }); @@ -8850,7 +9032,9 @@ // readyToDisplayPlaceholders has resolved). Wait for this before sending // essential messages to surrogate scripts. let afterPageLoadResolver; - const afterPageLoad = new Promise(resolve => { afterPageLoadResolver = resolve; }); + const afterPageLoad = new Promise((resolve) => { + afterPageLoadResolver = resolve; + }); // Messaging layer for Click to Load. The messaging instance is initialized in // ClickToLoad.init() and updated here to be used outside ClickToLoad class @@ -8863,15 +9047,15 @@ /** * @return {import("@duckduckgo/messaging").Messaging} */ - get messaging () { - if (!_messagingModuleScope) throw new Error('Messaging not initialized') - return _messagingModuleScope + get messaging() { + if (!_messagingModuleScope) throw new Error('Messaging not initialized'); + return _messagingModuleScope; }, - addDebugFlag () { - if (!_addDebugFlag) throw new Error('addDebugFlag not initialized') - return _addDebugFlag() - } + addDebugFlag() { + if (!_addDebugFlag) throw new Error('addDebugFlag not initialized'); + return _addDebugFlag(); + }, }; /********************************************************* @@ -8888,7 +9072,7 @@ * @param {import('../utils').Platform} platform * The platform where Click to Load and the Duck Widget is running on (ie Extension, Android App, etc) */ - constructor (widgetData, originalElement, entity, platform) { + constructor(widgetData, originalElement, entity, platform) { this.clickAction = { ...widgetData.clickAction }; // shallow copy this.replaceSettings = widgetData.replaceSettings; this.originalElement = originalElement; @@ -8909,17 +9093,15 @@ * @param {EventTarget} eventTarget * @param {string} eventName */ - dispatchEvent (eventTarget, eventName) { + dispatchEvent(eventTarget, eventName) { eventTarget.dispatchEvent( - createCustomEvent( - eventName, { - detail: { - entity: this.entity, - replaceSettings: this.replaceSettings, - widgetID: this.widgetID - } - } - ) + createCustomEvent(eventName, { + detail: { + entity: this.entity, + replaceSettings: this.replaceSettings, + widgetID: this.widgetID, + }, + }), ); } @@ -8928,9 +9110,9 @@ * clickAction.urlDataAttributesToPreserve) and store those in * this.dataElement. */ - gatherDataElements () { + gatherDataElements() { if (!this.clickAction.urlDataAttributesToPreserve) { - return + return; } for (const [attrName, attrSettings] of Object.entries(this.clickAction.urlDataAttributesToPreserve)) { let value = this.originalElement.getAttribute(attrName); @@ -8945,16 +9127,15 @@ if (attrName === 'data-width') { const windowWidth = window.innerWidth; const { parentElement } = this.originalElement; - const parentStyles = parentElement - ? window.getComputedStyle(parentElement) - : null; + const parentStyles = parentElement ? window.getComputedStyle(parentElement) : null; let parentInnerWidth = null; // We want to calculate the inner width of the parent element as the iframe, when added back, // should not be bigger than the space available in the parent element. There is no straightforward way of // doing this. We need to get the parent's .clientWidth and remove the paddings size from it. if (parentElement && parentStyles && parentStyles.display !== 'inline') { - parentInnerWidth = parentElement.clientWidth - parseFloat(parentStyles.paddingLeft) - parseFloat(parentStyles.paddingRight); + parentInnerWidth = + parentElement.clientWidth - parseFloat(parentStyles.paddingLeft) - parseFloat(parentStyles.paddingRight); } if (parentInnerWidth && parentInnerWidth < windowWidth) { @@ -8977,26 +9158,26 @@ * Load placeholder has been clicked by the user. * @returns {string} */ - getTargetURL () { + getTargetURL() { // Copying over data fields should be done lazily, since some required data may not be // captured until after page scripts run. this.copySocialDataFields(); - return this.clickAction.targetURL + return this.clickAction.targetURL; } /** * Determines which display mode the placeholder element should render in. * @returns {displayMode} */ - getMode () { + getMode() { // Login buttons are always the login style types if (this.replaceSettings.type === 'loginButton') { - return 'loginMode' + return 'loginMode'; } if (window?.matchMedia('(prefers-color-scheme: dark)')?.matches) { - return 'darkMode' + return 'darkMode'; } - return 'lightMode' + return 'lightMode'; } /** @@ -9005,7 +9186,7 @@ * * @returns {string} */ - getStyle () { + getStyle() { let styleString = 'border: none;'; if (this.clickAction.styleDataAttributes) { @@ -9027,7 +9208,7 @@ } } - return styleString + return styleString; } /** @@ -9035,9 +9216,9 @@ * placeholder element styling, and when restoring the original tracking * element. */ - copySocialDataFields () { + copySocialDataFields() { if (!this.clickAction.urlDataAttributesToPreserve) { - return + return; } // App ID may be set by client scripts, and is required for some elements. @@ -9049,7 +9230,7 @@ let attrValue = this.dataElements[key]; if (!attrValue) { - continue + continue; } // The URL for Facebook videos are specified as the data-href @@ -9060,10 +9241,7 @@ attrValue = window.location.protocol + attrValue; } - this.clickAction.targetURL = - this.clickAction.targetURL.replace( - key, encodeURIComponent(attrValue) - ); + this.clickAction.targetURL = this.clickAction.targetURL.replace(key, encodeURIComponent(attrValue)); } } @@ -9072,13 +9250,13 @@ * * @returns {HTMLIFrameElement} */ - createFBIFrame () { + createFBIFrame() { const frame = document.createElement('iframe'); frame.setAttribute('src', this.getTargetURL()); frame.setAttribute('style', this.getStyle()); - return frame + return frame; } /** @@ -9089,11 +9267,11 @@ * @returns {EventListener?} onError * Function to be called if the video fails to load. */ - adjustYouTubeVideoElement (videoElement) { + adjustYouTubeVideoElement(videoElement) { let onError = null; if (!videoElement.src) { - return onError + return onError; } const url = new URL(videoElement.src); const { hostname: originalHostname } = url; @@ -9116,7 +9294,7 @@ // Configure auto-play correctly depending on if the video's preview // loaded, otherwise it doesn't allow autoplay. let allowString = videoElement.getAttribute('allow') || ''; - const allowed = new Set(allowString.split(';').map(s => s.trim())); + const allowed = new Set(allowString.split(';').map((s) => s.trim())); if (this.autoplay) { allowed.add('autoplay'); url.searchParams.set('autoplay', '1'); @@ -9128,7 +9306,7 @@ videoElement.setAttribute('allow', allowString); videoElement.src = url.href; - return onError + return onError; } /** @@ -9142,8 +9320,8 @@ * @returns {Promise} * Promise that resolves when the fade in/out is complete. */ - fadeElement (element, interval, fadeIn) { - return new Promise(resolve => { + fadeElement(element, interval, fadeIn) { + return new Promise((resolve) => { let opacity = fadeIn ? 0 : 1; const originStyle = element.style.cssText; const fadeOut = setInterval(function () { @@ -9154,7 +9332,7 @@ resolve(); } }, interval); - }) + }); } /** @@ -9164,8 +9342,8 @@ * @returns {Promise} * Promise that resolves when the fade out is complete. */ - fadeOutElement (element) { - return this.fadeElement(element, 10, false) + fadeOutElement(element) { + return this.fadeElement(element, 10, false); } /** @@ -9175,8 +9353,8 @@ * @returns {Promise} * Promise that resolves when the fade in is complete. */ - fadeInElement (element) { - return this.fadeElement(element, 10, true) + fadeInElement(element) { + return this.fadeElement(element, 10, true); } /** @@ -9188,9 +9366,9 @@ * @param {HTMLElement} replacementElement * The placeholder element. */ - clickFunction (originalElement, replacementElement) { + clickFunction(originalElement, replacementElement) { let clicked = false; - const handleClick = e => { + const handleClick = (e) => { // Ensure that the click is created by a user event & prevent double clicks from adding more animations if (e.isTrusted && !clicked) { e.stopPropagation(); @@ -9208,21 +9386,21 @@ unblockClickToLoadContent({ entity: this.entity, action, isLogin, isSurrogateLogin }).then((response) => { // If user rejected confirmation modal and content was not unblocked, inform surrogate and stop. if (response && response.type === 'ddg-ctp-user-cancel') { - return abortSurrogateConfirmation(this.entity) + return abortSurrogateConfirmation(this.entity); } const parent = replacementElement.parentNode; // The placeholder was removed from the DOM while we loaded // the original content, give up. - if (!parent) return + if (!parent) return; // If we allow everything when this element is clicked, // notify surrogate to enable SDK and replace original element. if (this.clickAction.type === 'allowFull') { parent.replaceChild(originalElement, replacementElement); this.dispatchEvent(window, 'ddg-ctp-load-sdk'); - return + return; } // Create a container for the new FB element const fbContainer = document.createElement('div'); @@ -9257,16 +9435,16 @@ let fbElement; let onError = null; switch (this.clickAction.type) { - case 'iFrame': - fbElement = this.createFBIFrame(); - break - case 'youtube-video': - onError = this.adjustYouTubeVideoElement(originalElement); - fbElement = originalElement; - break - default: - fbElement = originalElement; - break + case 'iFrame': + fbElement = this.createFBIFrame(); + break; + case 'youtube-video': + onError = this.adjustYouTubeVideoElement(originalElement); + fbElement = originalElement; + break; + default: + fbElement = originalElement; + break; } // Modify the overlay to include a Facebook iFrame, which @@ -9275,14 +9453,18 @@ parent.replaceChild(fbContainer, replacementElement); fbContainer.appendChild(replacementElement); fadeIn.appendChild(fbElement); - fbElement.addEventListener('load', async () => { - await this.fadeOutElement(replacementElement); - fbContainer.replaceWith(fbElement); - this.dispatchEvent(fbElement, 'ddg-ctp-placeholder-clicked'); - await this.fadeInElement(fadeIn); - // Focus on new element for screen readers. - fbElement.focus(); - }, { once: true }); + fbElement.addEventListener( + 'load', + async () => { + await this.fadeOutElement(replacementElement); + fbContainer.replaceWith(fbElement); + this.dispatchEvent(fbElement, 'ddg-ctp-placeholder-clicked'); + await this.fadeInElement(fadeIn); + // Focus on new element for screen readers. + fbElement.focus(); + }, + { once: true }, + ); // Note: This event only fires on Firefox, on Chrome the frame's // load event will always fire. if (onError) { @@ -9293,19 +9475,17 @@ }; // If this is a login button, show modal if needed if (this.replaceSettings.type === 'loginButton' && entityData[this.entity].shouldShowLoginModal) { - return e => { + return (e) => { // Even if the user cancels the login attempt, consider Facebook Click to // Load to have been active on the page if the user reports the page as broken. if (this.entity === 'Facebook, Inc.') { notifyFacebookLogin(); } - handleUnblockConfirmation( - this.platform.name, this.entity, handleClick, e - ); - } + handleUnblockConfirmation(this.platform.name, this.entity, handleClick, e); + }; } - return handleClick + return handleClick; } /** @@ -9313,8 +9493,8 @@ * return if the new layout using Web Components is supported or not. * @returns {boolean} */ - shouldUseCustomElement () { - return platformsWithWebComponentsEnabled.includes(this.platform.name) + shouldUseCustomElement() { + return platformsWithWebComponentsEnabled.includes(this.platform.name); } /** @@ -9323,8 +9503,8 @@ * define which layout to use between Mobile and Desktop Platforms variations. * @returns {boolean} */ - isMobilePlatform () { - return mobilePlatforms.includes(this.platform.name) + isMobilePlatform() { + return mobilePlatforms.includes(this.platform.name); } } @@ -9358,7 +9538,7 @@ * @param {HTMLElement} placeholderElement * The placeholder element that should be shown instead. */ - function replaceTrackingElement (widget, trackingElement, placeholderElement) { + function replaceTrackingElement(widget, trackingElement, placeholderElement) { // In some situations (e.g. YouTube Click to Load previews are // enabled/disabled), a second placeholder will be shown for a tracking // element. @@ -9370,16 +9550,11 @@ // First hide the element, since we need to keep it in the DOM until the // events have been dispatched. - const originalDisplay = [ - elementToReplace.style.getPropertyValue('display'), - elementToReplace.style.getPropertyPriority('display') - ]; + const originalDisplay = [elementToReplace.style.getPropertyValue('display'), elementToReplace.style.getPropertyPriority('display')]; elementToReplace.style.setProperty('display', 'none', 'important'); // Add the placeholder element to the page. - elementToReplace.parentElement.insertBefore( - placeholderElement, elementToReplace - ); + elementToReplace.parentElement.insertBefore(placeholderElement, elementToReplace); // While the placeholder is shown (and original element hidden) // synchronously, the events are dispatched (and original element removed @@ -9406,7 +9581,7 @@ * @param {HTMLIFrameElement} trackingElement * The tracking element on the page that should be replaced with a placeholder. */ - function createPlaceholderElementAndReplace (widget, trackingElement) { + function createPlaceholderElementAndReplace(widget, trackingElement) { if (widget.replaceSettings.type === 'blank') { replaceTrackingElement(widget, trackingElement, document.createElement('div')); } @@ -9421,19 +9596,23 @@ hoverText: widget.replaceSettings.popupBodyText, logoIcon: facebookLogo, originalElement: trackingElement, - learnMore: { // Localized strings for "Learn More" link. + learnMore: { + // Localized strings for "Learn More" link. readAbout: sharedStrings.readAbout, - learnMore: sharedStrings.learnMore + learnMore: sharedStrings.learnMore, }, - onClick: widget.clickFunction.bind(widget) + onClick: widget.clickFunction.bind(widget), }).element; facebookLoginButton.classList.add('fb-login-button', 'FacebookLogin__button'); facebookLoginButton.appendChild(makeFontFaceStyleElement()); replaceTrackingElement(widget, trackingElement, facebookLoginButton); } else { const { button, container } = makeLoginButton( - widget.replaceSettings.buttonText, widget.getMode(), - widget.replaceSettings.popupBodyText, icon, trackingElement + widget.replaceSettings.buttonText, + widget.getMode(), + widget.replaceSettings.popupBodyText, + icon, + trackingElement, ); button.addEventListener('click', widget.clickFunction(trackingElement, container)); replaceTrackingElement(widget, trackingElement, container); @@ -9457,11 +9636,12 @@ unblockBtnText: widget.replaceSettings.buttonText, // Unblock button text useSlimCard: false, // Flag for using less padding on card (ie YT CTL on mobile) originalElement: trackingElement, // The original element this placeholder is replacing. - learnMore: { // Localized strings for "Learn More" link. + learnMore: { + // Localized strings for "Learn More" link. readAbout: sharedStrings.readAbout, - learnMore: sharedStrings.learnMore + learnMore: sharedStrings.learnMore, }, - onButtonClick: widget.clickFunction.bind(widget) + onButtonClick: widget.clickFunction.bind(widget), }); mobileBlockedPlaceholder.appendChild(makeFontFaceStyleElement()); @@ -9471,9 +9651,7 @@ const icon = widget.replaceSettings.icon; const button = makeButton(widget.replaceSettings.buttonText, widget.getMode()); const textButton = makeTextButton(widget.replaceSettings.buttonText, widget.getMode()); - const { contentBlock, shadowRoot } = createContentBlock( - widget, button, textButton, icon - ); + const { contentBlock, shadowRoot } = createContentBlock(widget, button, textButton, icon); button.addEventListener('click', widget.clickFunction(trackingElement, contentBlock)); textButton.addEventListener('click', widget.clickFunction(trackingElement, contentBlock)); @@ -9490,13 +9668,10 @@ // Subscribe to changes to youtubePreviewsEnabled setting // and update the CTL state - ctl.messaging.subscribe( - 'setYoutubePreviewsEnabled', - ({ value }) => { - isYoutubePreviewsEnabled = value; - replaceYouTubeCTL(trackingElement, widget); - } - ); + ctl.messaging.subscribe('setYoutubePreviewsEnabled', ({ value }) => { + isYoutubePreviewsEnabled = value; + replaceYouTubeCTL(trackingElement, widget); + }); } } @@ -9506,10 +9681,10 @@ * @param {DuckWidget} widget * The CTL 'widget' associated with the tracking element. */ - function replaceYouTubeCTL (trackingElement, widget) { + function replaceYouTubeCTL(trackingElement, widget) { // Skip replacing tracking element if it has already been unblocked if (widget.isUnblocked) { - return + return; } if (isYoutubePreviewsEnabled === true) { @@ -9537,22 +9712,24 @@ unblockBtnText: widget.replaceSettings.buttonText, // Unblock button text useSlimCard: true, // Flag for using less padding on card (ie YT CTL on mobile) originalElement: trackingElement, // The original element this placeholder is replacing. - learnMore: { // Localized strings for "Learn More" link. + learnMore: { + // Localized strings for "Learn More" link. readAbout: sharedStrings.readAbout, - learnMore: sharedStrings.learnMore + learnMore: sharedStrings.learnMore, }, - withToggle: { // Toggle config to be displayed in the bottom of the placeholder + withToggle: { + // Toggle config to be displayed in the bottom of the placeholder isActive: false, // Toggle state dataKey: 'yt-preview-toggle', // data-key attribute for button label: widget.replaceSettings.previewToggleText, // Text to be presented with toggle size: widget.isMobilePlatform() ? 'lg' : 'md', - onClick: () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }) // Toggle click callback + onClick: () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }), // Toggle click callback }, withFeedback: { label: sharedStrings.shareFeedback, - onClick: () => openShareFeedbackPage() + onClick: () => openShareFeedbackPage(), }, - onButtonClick: widget.clickFunction.bind(widget) + onButtonClick: widget.clickFunction.bind(widget), }); mobileBlockedPlaceholderElement.appendChild(makeFontFaceStyleElement()); mobileBlockedPlaceholderElement.id = trackingElement.id; @@ -9577,9 +9754,9 @@ * @param {ShadowRoot?} shadowRoot * @param {HTMLElement} placeholder Placeholder for tracking element */ - function showExtraUnblockIfShortPlaceholder (shadowRoot, placeholder) { + function showExtraUnblockIfShortPlaceholder(shadowRoot, placeholder) { if (!placeholder.parentElement) { - return + return; } const parentStyles = window.getComputedStyle(placeholder.parentElement); // Inline elements, like span or p, don't have a height value that we can use because they're @@ -9587,15 +9764,12 @@ // parents, it might be necessary to traverse up the DOM tree until we find the nearest non // "inline" parent to get a reliable height for this check. if (parentStyles.display === 'inline') { - return + return; } const { height: placeholderHeight } = placeholder.getBoundingClientRect(); const { height: parentHeight } = placeholder.parentElement.getBoundingClientRect(); - if ( - (placeholderHeight > 0 && placeholderHeight <= 200) || - (parentHeight > 0 && parentHeight <= 230) - ) { + if ((placeholderHeight > 0 && placeholderHeight <= 200) || (parentHeight > 0 && parentHeight <= 230)) { if (shadowRoot) { /** @type {HTMLElement?} */ const titleRowTextButton = shadowRoot.querySelector(`#${titleID + 'TextButton'}`); @@ -9624,11 +9798,10 @@ * Maximum placeholder width (in pixels) for the placeholder to be considered * narrow. */ - function hideInfoTextIfNarrowPlaceholder (shadowRoot, placeholder, narrowWidth) { + function hideInfoTextIfNarrowPlaceholder(shadowRoot, placeholder, narrowWidth) { const { width: placeholderWidth } = placeholder.getBoundingClientRect(); if (placeholderWidth > 0 && placeholderWidth <= narrowWidth) { - const buttonContainer = - shadowRoot.querySelector('.DuckDuckGoButton.primary')?.parentElement; + const buttonContainer = shadowRoot.querySelector('.DuckDuckGoButton.primary')?.parentElement; const contentTitle = shadowRoot.getElementById('contentTitle'); const infoText = shadowRoot.getElementById('infoText'); /** @type {HTMLElement?} */ @@ -9636,7 +9809,7 @@ // These elements will exist, but this check keeps TypeScript happy. if (!buttonContainer || !contentTitle || !infoText || !learnMoreLink) { - return + return; } // Remove the information text. @@ -9680,8 +9853,8 @@ * @see {@link ddg-ctp-unblockClickToLoadContent-complete} for the response handler. * @returns {Promise} */ - function unblockClickToLoadContent (message) { - return ctl.messaging.request('unblockClickToLoadContent', message) + function unblockClickToLoadContent(message) { + return ctl.messaging.request('unblockClickToLoadContent', message); } /** @@ -9698,14 +9871,14 @@ * @param {...any} acceptFunctionParams * The parameters passed to acceptFunction when it is called. */ - function handleUnblockConfirmation (platformName, entity, acceptFunction, ...acceptFunctionParams) { + function handleUnblockConfirmation(platformName, entity, acceptFunction, ...acceptFunctionParams) { // In our mobile platforms, we want to show a native UI to request user unblock // confirmation. In these cases we send directly the unblock request to the platform // and the platform chooses how to best handle it. if (platformsWithNativeModalSupport.includes(platformName)) { acceptFunction(...acceptFunctionParams); - // By default, for other platforms (ie Extension), we show a web modal with a - // confirmation request to the user before we proceed to unblock the content. + // By default, for other platforms (ie Extension), we show a web modal with a + // confirmation request to the user before we proceed to unblock the content. } else { makeModal(entity, acceptFunction, ...acceptFunctionParams); } @@ -9716,7 +9889,7 @@ * Facebook Click to Load login flow had started if the user should then report * the website as broken. */ - function notifyFacebookLogin () { + function notifyFacebookLogin() { ctl.addDebugFlag(); ctl.messaging.notify('updateFacebookCTLBreakageFlags', { ctlFacebookLogin: true }); } @@ -9727,7 +9900,7 @@ * shown. * @param {string} entity */ - async function runLogin (entity) { + async function runLogin(entity) { if (entity === 'Facebook, Inc.') { notifyFacebookLogin(); } @@ -9736,15 +9909,15 @@ const response = await unblockClickToLoadContent({ entity, action, isLogin: true, isSurrogateLogin: true }); // If user rejected confirmation modal and content was not unblocked, inform surrogate and stop. if (response && response.type === 'ddg-ctp-user-cancel') { - return abortSurrogateConfirmation(this.entity) + return abortSurrogateConfirmation(this.entity); } // Communicate with surrogate to run login originalWindowDispatchEvent( createCustomEvent('ddg-ctp-run-login', { detail: { - entity - } - }) + entity, + }, + }), ); } @@ -9753,17 +9926,17 @@ * Called after the user cancel from a warning dialog. * @param {string} entity */ - function abortSurrogateConfirmation (entity) { + function abortSurrogateConfirmation(entity) { originalWindowDispatchEvent( createCustomEvent('ddg-ctp-cancel-modal', { detail: { - entity - } - }) + entity, + }, + }), ); } - function openShareFeedbackPage () { + function openShareFeedbackPage() { ctl.messaging.notify('openShareFeedbackPage'); } @@ -9776,7 +9949,7 @@ * @param {displayMode} [mode='lightMode'] * @returns {HTMLAnchorElement} */ - function getLearnMoreLink (mode = 'lightMode') { + function getLearnMoreLink(mode = 'lightMode') { const linkElement = document.createElement('a'); linkElement.style.cssText = styles.generalLink + styles[mode].linkFont; linkElement.ariaLabel = sharedStrings.readAbout; @@ -9784,7 +9957,7 @@ linkElement.target = '_blank'; linkElement.textContent = sharedStrings.learnMore; linkElement.id = 'learnMoreLink'; - return linkElement + return linkElement; } /** @@ -9792,10 +9965,9 @@ * @param {HTMLElement} sourceElement * @param {HTMLElement} targetElement */ - function resizeElementToMatch (sourceElement, targetElement) { + function resizeElementToMatch(sourceElement, targetElement) { const computedStyle = window.getComputedStyle(sourceElement); - const stylesToCopy = ['position', 'top', 'bottom', 'left', 'right', - 'transform', 'margin']; + const stylesToCopy = ['position', 'top', 'bottom', 'left', 'right', 'transform', 'margin']; // It's apparently preferable to use the source element's size relative to // the current viewport, when resizing the target element. However, the @@ -9832,13 +10004,13 @@ * to be attached to DDG wrapper elements * @returns HTMLStyleElement */ - function makeFontFaceStyleElement () { + function makeFontFaceStyleElement() { // Put our custom font-faces inside the wrapper element, since // @font-face does not work inside a shadowRoot. // See https://github.com/mdn/interactive-examples/issues/887. const fontFaceStyleElement = document.createElement('style'); fontFaceStyleElement.textContent = styles.fontStyle; - return fontFaceStyleElement + return fontFaceStyleElement; } /** @@ -9848,7 +10020,7 @@ * @param {displayMode} [mode='lightMode'] * @returns {{wrapperClass: string, styleElement: HTMLStyleElement; }} */ - function makeBaseStyleElement (mode = 'lightMode') { + function makeBaseStyleElement(mode = 'lightMode') { // Style element includes our font & overwrites page styles const styleElement = document.createElement('style'); const wrapperClass = 'DuckDuckGoSocialContainer'; @@ -9892,7 +10064,7 @@ ${styles.cancelMode.buttonBackgroundPress} } `; - return { wrapperClass, styleElement } + return { wrapperClass, styleElement }; } /** @@ -9902,11 +10074,11 @@ * @param {displayMode} mode * @returns {HTMLAnchorElement} */ - function makeTextButton (linkText, mode = 'lightMode') { + function makeTextButton(linkText, mode = 'lightMode') { const linkElement = document.createElement('a'); linkElement.style.cssText = styles.headerLink + styles[mode].linkFont; linkElement.textContent = linkText; - return linkElement + return linkElement; } /** @@ -9919,7 +10091,7 @@ * action. * @returns {HTMLButtonElement} Button element */ - function makeButton (buttonText, mode = 'lightMode') { + function makeButton(buttonText, mode = 'lightMode') { const button = document.createElement('button'); button.classList.add('DuckDuckGoButton'); button.classList.add(mode === 'cancelMode' ? 'secondary' : 'primary'); @@ -9928,7 +10100,7 @@ textContainer.textContent = buttonText; button.appendChild(textContainer); } - return button + return button; } /** @@ -9942,7 +10114,7 @@ * Value to assign to the button's 'data-key' attribute. * @returns {HTMLButtonElement} */ - function makeToggleButton (mode, isActive = false, classNames = '', dataKey = '') { + function makeToggleButton(mode, isActive = false, classNames = '', dataKey = '') { const toggleButton = document.createElement('button'); toggleButton.className = classNames; toggleButton.style.cssText = styles.toggleButton; @@ -9953,17 +10125,15 @@ const activeKey = isActive ? 'active' : 'inactive'; const toggleBg = document.createElement('div'); - toggleBg.style.cssText = - styles.toggleButtonBg + styles[mode].toggleButtonBgState[activeKey]; + toggleBg.style.cssText = styles.toggleButtonBg + styles[mode].toggleButtonBgState[activeKey]; const toggleKnob = document.createElement('div'); - toggleKnob.style.cssText = - styles.toggleButtonKnob + styles.toggleButtonKnobState[activeKey]; + toggleKnob.style.cssText = styles.toggleButtonKnob + styles.toggleButtonKnobState[activeKey]; toggleButton.appendChild(toggleBg); toggleButton.appendChild(toggleKnob); - return toggleButton + return toggleButton; } /** @@ -9982,7 +10152,7 @@ * Value to assign to the button's 'data-key' attribute. * @returns {HTMLDivElement} */ - function makeToggleButtonWithText (text, mode, isActive = false, toggleClassNames = '', textCssStyles = '', dataKey = '') { + function makeToggleButtonWithText(text, mode, isActive = false, toggleClassNames = '', textCssStyles = '', dataKey = '') { const wrapper = document.createElement('div'); wrapper.style.cssText = styles.toggleButtonWrapper; @@ -9994,27 +10164,27 @@ wrapper.appendChild(toggleButton); wrapper.appendChild(textDiv); - return wrapper + return wrapper; } /** * Create the default block symbol, for when the image isn't available. * @returns {HTMLDivElement} */ - function makeDefaultBlockIcon () { + function makeDefaultBlockIcon() { const blockedIcon = document.createElement('div'); const dash = document.createElement('div'); blockedIcon.appendChild(dash); blockedIcon.style.cssText = styles.circle; dash.style.cssText = styles.rectangle; - return blockedIcon + return blockedIcon; } /** * Creates a share feedback link element. * @returns {HTMLAnchorElement} */ - function makeShareFeedbackLink () { + function makeShareFeedbackLink() { const feedbackLink = document.createElement('a'); feedbackLink.style.cssText = styles.feedbackLink; feedbackLink.target = '_blank'; @@ -10026,21 +10196,21 @@ openShareFeedbackPage(); }); - return feedbackLink + return feedbackLink; } /** * Creates a share feedback link element, wrapped in a styled div. * @returns {HTMLDivElement} */ - function makeShareFeedbackRow () { + function makeShareFeedbackRow() { const feedbackRow = document.createElement('div'); feedbackRow.style.cssText = styles.feedbackRow; const feedbackLink = makeShareFeedbackLink(); feedbackRow.appendChild(feedbackLink); - return feedbackRow + return feedbackRow; } /** @@ -10060,7 +10230,7 @@ * expected to do that. * @returns {{ container: HTMLDivElement, button: HTMLButtonElement }} */ - function makeLoginButton (buttonText, mode, hoverTextBody, icon, originalElement) { + function makeLoginButton(buttonText, mode, hoverTextBody, icon, originalElement) { const container = document.createElement('div'); container.style.cssText = 'position: relative;'; container.appendChild(makeFontFaceStyleElement()); @@ -10124,14 +10294,14 @@ if (rect.left < styles.textBubbleLeftShift) { const leftShift = -rect.left + 10; // 10px away from edge of the screen hoverBox.style.cssText += `left: ${leftShift}px;`; - const change = (1 - (rect.left / styles.textBubbleLeftShift)) * (100 - styles.arrowDefaultLocationPercent); + const change = (1 - rect.left / styles.textBubbleLeftShift) * (100 - styles.arrowDefaultLocationPercent); arrow.style.cssText += `left: ${Math.max(10, styles.arrowDefaultLocationPercent - change)}%;`; } else if (rect.left + styles.textBubbleWidth - styles.textBubbleLeftShift > window.innerWidth) { const rightShift = rect.left + styles.textBubbleWidth - styles.textBubbleLeftShift; const diff = Math.min(rightShift - window.innerWidth, styles.textBubbleLeftShift); const rightMargin = 20; // Add some margin to the page, so scrollbar doesn't overlap. hoverBox.style.cssText += `left: -${styles.textBubbleLeftShift + diff + rightMargin}px;`; - const change = ((diff / styles.textBubbleLeftShift)) * (100 - styles.arrowDefaultLocationPercent); + const change = (diff / styles.textBubbleLeftShift) * (100 - styles.arrowDefaultLocationPercent); arrow.style.cssText += `left: ${Math.max(10, styles.arrowDefaultLocationPercent + change)}%;`; } else { hoverBox.style.cssText += `left: -${styles.textBubbleLeftShift}px;`; @@ -10140,8 +10310,8 @@ return { button, - container - } + container, + }; } /** @@ -10156,7 +10326,7 @@ * The parameters passed to acceptFunction when it is called. * TODO: Have the caller bind these arguments to the function instead. */ - function makeModal (entity, acceptFunction, ...acceptFunctionParams) { + function makeModal(entity, acceptFunction, ...acceptFunctionParams) { const icon = entityData[entity].modalIcon; const modalContainer = document.createElement('div'); @@ -10214,7 +10384,7 @@ const allowButton = makeButton(entityData[entity].modalAcceptText, 'lightMode'); allowButton.style.cssText += styles.modalButton + 'margin-bottom: 8px;'; allowButton.setAttribute('data-key', 'allow'); - allowButton.addEventListener('click', function doLogin () { + allowButton.addEventListener('click', function doLogin() { acceptFunction(...acceptFunctionParams); document.body.removeChild(modalContainer); }); @@ -10245,7 +10415,7 @@ * If provided, a close button is added that calls this function when clicked. * @returns {HTMLDivElement} */ - function createTitleRow (message, textButton, closeBtnFn) { + function createTitleRow(message, textButton, closeBtnFn) { // Create row container const row = document.createElement('div'); row.style.cssText = styles.titleBox; @@ -10286,7 +10456,7 @@ row.appendChild(textButton); } - return row + return row; } /** @@ -10305,7 +10475,7 @@ * Bottom row to append to the placeholder, if any. * @returns {{ contentBlock: HTMLDivElement, shadowRoot: ShadowRoot }} */ - function createContentBlock (widget, button, textButton, img, bottomRow) { + function createContentBlock(widget, button, textButton, img, bottomRow) { const contentBlock = document.createElement('div'); contentBlock.style.cssText = styles.wrapperDiv; @@ -10380,7 +10550,7 @@ shadowRoot.appendChild(feedbackRow); } - return { contentBlock, shadowRoot } + return { contentBlock, shadowRoot }; } /** @@ -10389,7 +10559,7 @@ * @param {DuckWidget} widget * @returns {{ blockingDialog: HTMLElement, shadowRoot: ShadowRoot }} */ - function createYouTubeBlockingDialog (trackingElement, widget) { + function createYouTubeBlockingDialog(trackingElement, widget) { const button = makeButton(widget.replaceSettings.buttonText, widget.getMode()); const textButton = makeTextButton(widget.replaceSettings.buttonText, widget.getMode()); @@ -10401,17 +10571,14 @@ false, '', '', - 'yt-preview-toggle' + 'yt-preview-toggle', ); - previewToggle.addEventListener( - 'click', - () => makeModal(widget.entity, () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }), widget.entity) + previewToggle.addEventListener('click', () => + makeModal(widget.entity, () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }), widget.entity), ); bottomRow.appendChild(previewToggle); - const { contentBlock, shadowRoot } = createContentBlock( - widget, button, textButton, null, bottomRow - ); + const { contentBlock, shadowRoot } = createContentBlock(widget, button, textButton, null, bottomRow); contentBlock.id = trackingElement.id; contentBlock.style.cssText += styles.wrapperDiv + styles.youTubeWrapperDiv; @@ -10420,8 +10587,8 @@ return { blockingDialog: contentBlock, - shadowRoot - } + shadowRoot, + }; } /** @@ -10435,7 +10602,7 @@ * @returns {{ youTubePreview: HTMLElement, shadowRoot: ShadowRoot }} * Object containing the YouTube Preview element and its shadowRoot. */ - function createYouTubePreview (originalElement, widget) { + function createYouTubePreview(originalElement, widget) { const youTubePreview = document.createElement('div'); youTubePreview.id = originalElement.id; youTubePreview.style.cssText = styles.wrapperDiv + styles.placeholderWrapperDiv; @@ -10483,10 +10650,7 @@ const textButton = makeTextButton(widget.replaceSettings.buttonText, 'darkMode'); textButton.id = titleID + 'TextButton'; - textButton.addEventListener( - 'click', - widget.clickFunction(originalElement, youTubePreview) - ); + textButton.addEventListener('click', widget.clickFunction(originalElement, youTubePreview)); topSection.appendChild(textButton); /** Play Button */ @@ -10501,10 +10665,7 @@ videoPlayImg.setAttribute('src', videoPlayIcon); playButton.appendChild(videoPlayImg); - playButton.addEventListener( - 'click', - widget.clickFunction(originalElement, youTubePreview) - ); + playButton.addEventListener('click', widget.clickFunction(originalElement, youTubePreview)); playButtonRow.appendChild(playButton); innerDiv.appendChild(playButtonRow); @@ -10520,12 +10681,9 @@ true, '', styles.youTubePreviewToggleText, - 'yt-preview-toggle' - ); - previewToggle.addEventListener( - 'click', - () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: false }) + 'yt-preview-toggle', ); + previewToggle.addEventListener('click', () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: false })); /** Preview Info Text */ const previewText = document.createElement('div'); @@ -10537,9 +10695,7 @@ // Ideally, the translation system would allow only certain element // types to be included, and would avoid the URLs for links being // included in the translations. - previewText.insertAdjacentHTML( - 'beforeend', widget.replaceSettings.placeholder.previewInfoText - ); + previewText.insertAdjacentHTML('beforeend', widget.replaceSettings.placeholder.previewInfoText); const previewTextLink = previewText.querySelector('a'); if (previewTextLink) { const newPreviewTextLink = getLearnMoreLink(widget.getMode()); @@ -10556,10 +10712,13 @@ // We use .then() instead of await here to show the placeholder right away // while the YouTube endpoint takes it time to respond. const videoURL = originalElement.src || originalElement.getAttribute('data-src'); - ctl.messaging.request('getYouTubeVideoDetails', { videoURL }) + ctl.messaging + .request('getYouTubeVideoDetails', { videoURL }) // eslint-disable-next-line promise/prefer-await-to-then .then(({ videoURL: videoURLResp, status, title, previewImage }) => { - if (!status || videoURLResp !== videoURL) { return } + if (!status || videoURLResp !== videoURL) { + return; + } if (status === 'success') { titleElement.innerText = title; titleElement.title = title; @@ -10574,7 +10733,7 @@ const feedbackRow = makeShareFeedbackRow(); shadowRoot.appendChild(feedbackRow); - return { youTubePreview, shadowRoot } + return { youTubePreview, shadowRoot }; } /** @@ -10583,15 +10742,15 @@ class ClickToLoad extends ContentFeature { /** @type {MessagingContext} */ - #messagingContext + #messagingContext; - async init (args) { + async init(args) { /** * Bail if no messaging backend - this is a debugging feature to ensure we don't * accidentally enabled this */ if (!this.messaging) { - throw new Error('Cannot operate click to load without a messaging backend') + throw new Error('Cannot operate click to load without a messaging backend'); } _messagingModuleScope = this.messaging; _addDebugFlag = this.addDebugFlag.bind(this); @@ -10615,11 +10774,9 @@ for (const entity of Object.keys(config)) { // Strip config entities that are first-party, or aren't enabled in the // extension's clickToLoad settings. - if ((websiteOwner && entity === websiteOwner) || - !settings[entity] || - settings[entity].state !== 'enabled') { + if ((websiteOwner && entity === websiteOwner) || !settings[entity] || settings[entity].state !== 'enabled') { delete config[entity]; - continue + continue; } // Populate the entities and entityData data structures. @@ -10644,12 +10801,12 @@ // Listen for window events from "surrogate" scripts. window.addEventListener('ddg-ctp', (/** @type {CustomEvent} */ event) => { - if (!('detail' in event)) return + if (!('detail' in event)) return; const entity = event.detail?.entity; if (!entities.includes(entity)) { // Unknown entity, reject - return + return; } if (event.detail?.appID) { appID = JSON.stringify(event.detail.appID).replace(/"/g, ''); @@ -10671,13 +10828,13 @@ }); // Listen to message from Platform letting CTL know that we're ready to // replace elements in the page - // eslint-disable-next-line promise/prefer-await-to-then + this.messaging.subscribe( 'displayClickToLoadPlaceholders', // TODO: Pass `message.options.ruleAction` through, that way only // content corresponding to the entity for that ruleAction need to // be replaced with a placeholder. - () => this.replaceClickToLoadElements() + () => this.replaceClickToLoadElements(), ); // Request the current state of Click to Load from the platform. @@ -10699,11 +10856,9 @@ // dispatched too early, before the listener is ready to receive it. // To counter that, catch "ddg-ctp-surrogate-load" events dispatched // _after_ page, so the "ddg-ctp-ready" event can be dispatched again. - window.addEventListener( - 'ddg-ctp-surrogate-load', () => { - originalWindowDispatchEvent(createCustomEvent('ddg-ctp-ready')); - } - ); + window.addEventListener('ddg-ctp-surrogate-load', () => { + originalWindowDispatchEvent(createCustomEvent('ddg-ctp-ready')); + }); // Then wait for any in-progress element replacements, before letting // the surrogate scripts know to start. @@ -10718,21 +10873,21 @@ * SendMessageMessagingTransport that wraps this communication. * This can be removed once they have their own Messaging integration. */ - update (message) { + update(message) { // TODO: Once all Click to Load messages include the feature property, drop // messages that don't include the feature property too. - if (message?.feature && message?.feature !== 'clickToLoad') return + if (message?.feature && message?.feature !== 'clickToLoad') return; const messageType = message?.messageType; - if (!messageType) return + if (!messageType) return; if (!this._clickToLoadMessagingTransport) { - throw new Error('_clickToLoadMessagingTransport not ready. Cannot operate click to load without a messaging backend') + throw new Error('_clickToLoadMessagingTransport not ready. Cannot operate click to load without a messaging backend'); } // Send to Messaging layer the response or subscription message received // from the Platform. - return this._clickToLoadMessagingTransport.onResponse(message) + return this._clickToLoadMessagingTransport.onResponse(message); } /** @@ -10741,7 +10896,7 @@ * @param {boolean} state.devMode Developer or Production environment * @param {boolean} state.youtubePreviewsEnabled YouTube Click to Load - YT Previews enabled flag */ - onClickToLoadState (state) { + onClickToLoadState(state) { devMode = state.devMode; isYoutubePreviewsEnabled = state.youtubePreviewsEnabled; @@ -10757,7 +10912,7 @@ * one of the expected CSS selectors). If omitted, all matching elements * in the document will be replaced instead. */ - async replaceClickToLoadElements (targetElement) { + async replaceClickToLoadElements(targetElement) { await readyToDisplayPlaceholders; for (const entity of Object.keys(config)) { @@ -10773,16 +10928,18 @@ trackingElements = Array.from(document.querySelectorAll(selector)); } - await Promise.all(trackingElements.map(trackingElement => { - if (knownTrackingElements.has(trackingElement)) { - return Promise.resolve() - } + await Promise.all( + trackingElements.map((trackingElement) => { + if (knownTrackingElements.has(trackingElement)) { + return Promise.resolve(); + } - knownTrackingElements.add(trackingElement); + knownTrackingElements.add(trackingElement); - const widget = new DuckWidget(widgetData, trackingElement, entity, this.platform); - return createPlaceholderElementAndReplace(widget, trackingElement) - })); + const widget = new DuckWidget(widgetData, trackingElement, entity, this.platform); + return createPlaceholderElementAndReplace(widget, trackingElement); + }), + ); } } } @@ -10790,31 +10947,31 @@ /** * @returns {MessagingContext} */ - get messagingContext () { - if (this.#messagingContext) return this.#messagingContext + get messagingContext() { + if (this.#messagingContext) return this.#messagingContext; this.#messagingContext = this._createMessagingContext(); - return this.#messagingContext + return this.#messagingContext; } // Messaging layer between Click to Load and the Platform - get messaging () { - if (this._messaging) return this._messaging + get messaging() { + if (this._messaging) return this._messaging; if (this.platform.name === 'android' || this.platform.name === 'extension') { this._clickToLoadMessagingTransport = new SendMessageMessagingTransport(); const config = new TestTransportConfig(this._clickToLoadMessagingTransport); this._messaging = new Messaging(this.messagingContext, config); - return this._messaging + return this._messaging; } else if (this.platform.name === 'ios' || this.platform.name === 'macos') { const config = new WebkitMessagingConfig({ secret: '', hasModernWebkitAPI: true, - webkitMessageHandlerNames: ['contentScopeScriptsIsolated'] + webkitMessageHandlerNames: ['contentScopeScriptsIsolated'], }); this._messaging = new Messaging(this.messagingContext, config); - return this._messaging + return this._messaging; } else { - throw new Error('Messaging not supported yet on platform: ' + this.name) + throw new Error('Messaging not supported yet on platform: ' + this.name); } } } @@ -10822,21 +10979,21 @@ /** * @returns array of performance metrics */ - function getJsPerformanceMetrics () { + function getJsPerformanceMetrics() { const paintResources = performance.getEntriesByType('paint'); const firstPaint = paintResources.find((entry) => entry.name === 'first-contentful-paint'); - return firstPaint ? [firstPaint.startTime] : [] + return firstPaint ? [firstPaint.startTime] : []; } class BreakageReporting extends ContentFeature { - init () { + init() { this.messaging.subscribe('getBreakageReportValues', () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; this.messaging.notify('breakageReportResult', { jsPerformance, - referrer + referrer, }); }); } @@ -10869,7 +11026,7 @@ * @param {import('./overlays.js').Environment} environment * @internal */ - constructor (messaging, environment) { + constructor(messaging, environment) { /** * @internal */ @@ -10880,17 +11037,17 @@ /** * @returns {Promise} */ - initialSetup () { + initialSetup() { if (this.environment.isIntegrationMode()) { return Promise.resolve({ userValues: { overlayInteracted: false, - privatePlayerMode: { alwaysAsk: {} } + privatePlayerMode: { alwaysAsk: {} }, }, - ui: {} - }) + ui: {}, + }); } - return this.messaging.request(MSG_NAME_INITIAL_SETUP) + return this.messaging.request(MSG_NAME_INITIAL_SETUP); } /** @@ -10898,24 +11055,24 @@ * @param {import("../duck-player.js").UserValues} userValues * @returns {Promise} */ - setUserValues (userValues) { - return this.messaging.request(MSG_NAME_SET_VALUES, userValues) + setUserValues(userValues) { + return this.messaging.request(MSG_NAME_SET_VALUES, userValues); } /** * @returns {Promise} */ - getUserValues () { - return this.messaging.request(MSG_NAME_READ_VALUES, {}) + getUserValues() { + return this.messaging.request(MSG_NAME_READ_VALUES, {}); } /** * @param {Pixel} pixel */ - sendPixel (pixel) { + sendPixel(pixel) { this.messaging.notify(MSG_NAME_PIXEL, { pixelName: pixel.name(), - params: pixel.params() + params: pixel.params(), }); } @@ -10924,43 +11081,45 @@ * See {@link OpenInDuckPlayerMsg} for params * @param {OpenInDuckPlayerMsg} params */ - openDuckPlayer (params) { - return this.messaging.notify(MSG_NAME_OPEN_PLAYER, params) + openDuckPlayer(params) { + return this.messaging.notify(MSG_NAME_OPEN_PLAYER, params); } /** * This is sent when the user wants to open Duck Player. */ - openInfo () { - return this.messaging.notify(MSG_NAME_OPEN_INFO) + openInfo() { + return this.messaging.notify(MSG_NAME_OPEN_INFO); } /** * Get notification when preferences/state changed * @param {(userValues: import("../duck-player.js").UserValues) => void} cb */ - onUserValuesChanged (cb) { - return this.messaging.subscribe('onUserValuesChanged', cb) + onUserValuesChanged(cb) { + return this.messaging.subscribe('onUserValuesChanged', cb); } /** * Get notification when ui settings changed * @param {(userValues: import("../duck-player.js").UISettings) => void} cb */ - onUIValuesChanged (cb) { - return this.messaging.subscribe('onUIValuesChanged', cb) + onUIValuesChanged(cb) { + return this.messaging.subscribe('onUIValuesChanged', cb); } /** * This allows our SERP to interact with Duck Player settings. */ - serpProxy () { - function respond (kind, data) { - window.dispatchEvent(new CustomEvent(MSG_NAME_PROXY_RESPONSE, { - detail: { kind, data }, - composed: true, - bubbles: true - })); + serpProxy() { + function respond(kind, data) { + window.dispatchEvent( + new CustomEvent(MSG_NAME_PROXY_RESPONSE, { + detail: { kind, data }, + composed: true, + bubbles: true, + }), + ); } // listen for setting and forward to the SERP window @@ -10974,16 +11133,16 @@ assertCustomEvent(evt); if (evt.detail.kind === MSG_NAME_SET_VALUES) { return this.setUserValues(evt.detail.data) - .then(updated => respond(MSG_NAME_PUSH_DATA, updated)) - .catch(console.error) + .then((updated) => respond(MSG_NAME_PUSH_DATA, updated)) + .catch(console.error); } if (evt.detail.kind === MSG_NAME_READ_VALUES_SERP) { return this.getUserValues() - .then(updated => respond(MSG_NAME_PUSH_DATA, updated)) - .catch(console.error) + .then((updated) => respond(MSG_NAME_PUSH_DATA, updated)) + .catch(console.error); } if (evt.detail.kind === MSG_NAME_OPEN_INFO) { - return this.openInfo() + return this.openInfo(); } console.warn('unhandled event', evt); } catch (e) { @@ -10997,9 +11156,9 @@ * @param {any} event * @returns {asserts event is CustomEvent<{kind: string, data: any}>} */ - function assertCustomEvent (event) { - if (!('detail' in event)) throw new Error('none-custom event') - if (typeof event.detail.kind !== 'string') throw new Error('custom event requires detail.kind to be a string') + function assertCustomEvent(event) { + if (!('detail' in event)) throw new Error('none-custom event'); + if (typeof event.detail.kind !== 'string') throw new Error('custom event requires detail.kind to be a string'); } class Pixel { @@ -11010,23 +11169,26 @@ * | {name: "play.use.thumbnail"} * | {name: "play.do_not_use", remember: "0" | "1"}} input */ - constructor (input) { + constructor(input) { this.input = input; } - name () { - return this.input.name + name() { + return this.input.name; } - params () { + params() { switch (this.input.name) { - case 'overlay': return {} - case 'play.use.thumbnail': return {} - case 'play.use': - case 'play.do_not_use': { - return { remember: this.input.remember } - } - default: throw new Error('unreachable') + case 'overlay': + return {}; + case 'play.use.thumbnail': + return {}; + case 'play.use': + case 'play.do_not_use': { + return { remember: this.input.remember }; + } + default: + throw new Error('unreachable'); } } } @@ -11036,7 +11198,7 @@ * @param {object} params * @param {string} params.href */ - constructor (params) { + constructor(params) { this.href = params.href; } } @@ -11056,7 +11218,7 @@ * @param {string} targetSelector * @param {string} imageUrl */ - function appendImageAsBackground (parent, targetSelector, imageUrl) { + function appendImageAsBackground(parent, targetSelector, imageUrl) { /** * Make a HEAD request to see what the status of this image is, without @@ -11065,23 +11227,25 @@ * This is needed because YouTube returns a 404 + valid image file when there's no * thumbnail and you can't tell the difference through the 'onload' event alone */ - fetch(imageUrl, { method: 'HEAD' }).then(x => { - const status = String(x.status); - if (status.startsWith('2')) { - { - append(); + fetch(imageUrl, { method: 'HEAD' }) + .then((x) => { + const status = String(x.status); + if (status.startsWith('2')) { + { + append(); + } + } else { + markError(); } - } else { - markError(); - } - }).catch(() => { - console.error('e from fetch'); - }); + }) + .catch(() => { + console.error('e from fetch'); + }); /** * If loading fails, mark the parent with data-attributes */ - function markError () { + function markError() { parent.dataset.thumbLoaded = String(false); parent.dataset.error = String(true); } @@ -11089,9 +11253,11 @@ /** * If loading succeeds, try to append the image */ - function append () { + function append() { const targetElement = parent.querySelector(targetSelector); - if (!(targetElement instanceof HTMLElement)) return console.warn('could not find child with selector', targetSelector, 'from', parent) + if (!(targetElement instanceof HTMLElement)) { + return console.warn('could not find child with selector', targetSelector, 'from', parent); + } parent.dataset.thumbLoaded = String(true); parent.dataset.thumbSrc = imageUrl; const img = new Image(); @@ -11103,7 +11269,7 @@ img.onerror = function () { markError(); const targetElement = parent.querySelector(targetSelector); - if (!(targetElement instanceof HTMLElement)) return + if (!(targetElement instanceof HTMLElement)) return; targetElement.style.backgroundImage = ''; }; } @@ -11114,19 +11280,19 @@ * @param {object} params * @param {boolean} [params.debug] */ - constructor ({ debug = false } = { }) { + constructor({ debug = false } = {}) { this.debug = debug; } /** @type {{fn: () => void, name: string}[]} */ - _cleanups = [] + _cleanups = []; /** * Wrap a side-effecting operation for easier debugging * and teardown/release of resources * @param {string} name * @param {() => () => void} fn */ - add (name, fn) { + add(name, fn) { try { if (this.debug) { console.log('☢️', name); @@ -11143,7 +11309,7 @@ /** * Remove elements, event listeners etc */ - destroy () { + destroy() { for (const cleanup of this._cleanups) { if (typeof cleanup.fn === 'function') { try { @@ -11155,7 +11321,7 @@ console.error(`cleanup ${cleanup.name} threw`, e); } } else { - throw new Error('invalid cleanup') + throw new Error('invalid cleanup'); } } this._cleanups = []; @@ -11182,18 +11348,18 @@ * @param {string} id - the YouTube video ID * @param {string|null|undefined} time - an optional time */ - constructor (id, time) { + constructor(id, time) { this.id = id; this.time = time; } - static validVideoId = /^[a-zA-Z0-9-_]+$/ - static validTimestamp = /^[0-9hms]+$/ + static validVideoId = /^[a-zA-Z0-9-_]+$/; + static validTimestamp = /^[0-9hms]+$/; /** * @returns {string} */ - toPrivatePlayerUrl () { + toPrivatePlayerUrl() { // no try/catch because we already validated the ID // in Microsoft WebView2 v118+ changing from special protocol (https) to non-special one (duck) is forbidden // so we need to construct duck player this way @@ -11202,7 +11368,7 @@ if (this.time) { duckUrl.searchParams.set('t', this.time); } - return duckUrl.href + return duckUrl.href; } /** @@ -11211,17 +11377,17 @@ * @param {string} href * @returns {VideoParams|null} */ - static forWatchPage (href) { + static forWatchPage(href) { let url; try { url = new URL(href); } catch (e) { - return null + return null; } if (!url.pathname.startsWith('/watch')) { - return null + return null; } - return VideoParams.fromHref(url.href) + return VideoParams.fromHref(url.href); } /** @@ -11230,14 +11396,14 @@ * @param pathname * @returns {VideoParams|null} */ - static fromPathname (pathname) { + static fromPathname(pathname) { let url; try { url = new URL(pathname, window.location.origin); } catch (e) { - return null + return null; } - return VideoParams.fromHref(url.href) + return VideoParams.fromHref(url.href); } /** @@ -11247,12 +11413,12 @@ * @param href * @returns {VideoParams|null} */ - static fromHref (href) { + static fromHref(href) { let url; try { url = new URL(href); } catch (e) { - return null + return null; } let id = null; @@ -11265,7 +11431,7 @@ // valid: '/watch?v=321&list=123&index=1234' // invalid: '/watch?v=321&list=123' <- index absent if (url.searchParams.has('list') && !url.searchParams.has('index')) { - return null + return null; } let time = null; @@ -11275,7 +11441,7 @@ id = vParam; } else { // if the video ID is invalid, we cannot produce an instance of VideoParams - return null + return null; } // ensure timestamp is good, if set @@ -11283,7 +11449,7 @@ time = tParam; } - return new VideoParams(id, time) + return new VideoParams(id, time); } } @@ -11294,17 +11460,17 @@ * if the DOM is already loaded. */ class DomState { - loaded = false - loadedCallbacks = [] - constructor () { + loaded = false; + loadedCallbacks = []; + constructor() { window.addEventListener('DOMContentLoaded', () => { this.loaded = true; - this.loadedCallbacks.forEach(cb => cb()); + this.loadedCallbacks.forEach((cb) => cb()); }); } - onLoaded (loadedCallback) { - if (this.loaded) return loadedCallback() + onLoaded(loadedCallback) { + if (this.loaded) return loadedCallback(); this.loadedCallbacks.push(loadedCallback); } } @@ -11318,56 +11484,56 @@ */ const text = { playText: { - title: 'Duck Player' + title: 'Duck Player', }, videoOverlayTitle: { - title: 'Tired of targeted YouTube ads and recommendations?' + title: 'Tired of targeted YouTube ads and recommendations?', }, videoOverlayTitle2: { - title: 'Turn on Duck Player to watch without targeted ads' + title: 'Turn on Duck Player to watch without targeted ads', }, videoOverlayTitle3: { - title: 'Drowning in ads on YouTube? {newline} Turn on Duck Player.' + title: 'Drowning in ads on YouTube? {newline} Turn on Duck Player.', }, videoOverlaySubtitle: { - title: 'provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations.' + title: 'provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations.', }, videoOverlaySubtitle2: { - title: 'What you watch in DuckDuckGo won’t influence your recommendations on YouTube.' + title: 'What you watch in DuckDuckGo won’t influence your recommendations on YouTube.', }, videoButtonOpen: { - title: 'Watch in Duck Player' + title: 'Watch in Duck Player', }, videoButtonOpen2: { - title: 'Turn On Duck Player' + title: 'Turn On Duck Player', }, videoButtonOptOut: { - title: 'Watch Here' + title: 'Watch Here', }, videoButtonOptOut2: { - title: 'No Thanks' + title: 'No Thanks', }, rememberLabel: { - title: 'Remember my choice' - } + title: 'Remember my choice', + }, }; const i18n = { /** * @param {keyof text} name */ - t (name) { + t(name) { // eslint-disable-next-line no-prototype-builtins if (!text.hasOwnProperty(name)) { console.error(`missing key ${name}`); - return 'missing' + return 'missing'; } const match = text[name]; if (!match.title) { - return 'missing' + return 'missing'; } - return match.title - } + return match.title; + }, }; /** @@ -11392,8 +11558,8 @@ subtitle: i18n.t('videoOverlaySubtitle2'), buttonOptOut: i18n.t('videoButtonOptOut2'), buttonOpen: i18n.t('videoButtonOpen2'), - rememberLabel: i18n.t('rememberLabel') - } + rememberLabel: i18n.t('rememberLabel'), + }, }; /** @@ -11406,29 +11572,29 @@ subtitle: lookup.videoOverlaySubtitle2, buttonOptOut: lookup.videoButtonOptOut2, buttonOpen: lookup.videoButtonOpen2, - rememberLabel: lookup.rememberLabel - } + rememberLabel: lookup.rememberLabel, + }; }; class IconOverlay { - sideEffects = new SideEffects() - policy = createPolicy() + sideEffects = new SideEffects(); + policy = createPolicy(); /** @type {HTMLElement | null} */ - element = null + element = null; /** * Special class used for the overlay hover. For hovering, we use a * single element and move it around to the hovered video element. */ - HOVER_CLASS = 'ddg-overlay-hover' - OVERLAY_CLASS = 'ddg-overlay' + HOVER_CLASS = 'ddg-overlay-hover'; + OVERLAY_CLASS = 'ddg-overlay'; - CSS_OVERLAY_MARGIN_TOP = 5 - CSS_OVERLAY_HEIGHT = 32 + CSS_OVERLAY_MARGIN_TOP = 5; + CSS_OVERLAY_HEIGHT = 32; /** @type {HTMLElement | null} */ - currentVideoElement = null - hoverOverlayVisible = false + currentVideoElement = null; + hoverOverlayVisible = false; /** * Creates an Icon Overlay. @@ -11437,55 +11603,49 @@ * @param {string} [extraClass] - whether to add any extra classes, such as hover * @returns {HTMLElement} */ - create (size, href, extraClass) { + create(size, href, extraClass) { const overlayElement = document.createElement('div'); overlayElement.setAttribute('class', 'ddg-overlay' + (extraClass ? ' ' + extraClass : '')); overlayElement.setAttribute('data-size', size); const svgIcon = trustedUnsafe(dax); - const safeString = html` - -
- ${svgIcon} -
-
-
- ${i18n.t('playText')} -
-
-
`.toString(); + const safeString = html` +
${svgIcon}
+
+
${i18n.t('playText')}
+
+
`.toString(); overlayElement.innerHTML = this.policy.createHTML(safeString); overlayElement.querySelector('a.ddg-play-privately')?.setAttribute('href', href); - return overlayElement + return overlayElement; } /** * Util to return the hover overlay * @returns {HTMLElement | null} */ - getHoverOverlay () { - return document.querySelector('.' + this.HOVER_CLASS) + getHoverOverlay() { + return document.querySelector('.' + this.HOVER_CLASS); } /** * Moves the hover overlay to a specified videoElement * @param {HTMLElement} videoElement - which element to move it to */ - moveHoverOverlayToVideoElement (videoElement) { + moveHoverOverlayToVideoElement(videoElement) { const overlay = this.getHoverOverlay(); if (overlay === null || this.videoScrolledOutOfViewInPlaylist(videoElement)) { - return + return; } const videoElementOffset = this.getElementOffset(videoElement); - overlay.setAttribute('style', '' + - 'top: ' + videoElementOffset.top + 'px;' + - 'left: ' + videoElementOffset.left + 'px;' + - 'display:block;' + overlay.setAttribute( + 'style', + '' + 'top: ' + videoElementOffset.top + 'px;' + 'left: ' + videoElementOffset.left + 'px;' + 'display:block;', ); overlay.setAttribute('data-size', 'fixed ' + this.getThumbnailSize(videoElement)); @@ -11509,22 +11669,22 @@ * @param {HTMLElement} videoElement * @returns {boolean} */ - videoScrolledOutOfViewInPlaylist (videoElement) { + videoScrolledOutOfViewInPlaylist(videoElement) { const inPlaylist = videoElement.closest('#items.playlist-items'); if (inPlaylist) { const video = videoElement.getBoundingClientRect(); const playlist = inPlaylist.getBoundingClientRect(); - const videoOutsideTop = (video.top + this.CSS_OVERLAY_MARGIN_TOP) < playlist.top; - const videoOutsideBottom = ((video.top + this.CSS_OVERLAY_HEIGHT + this.CSS_OVERLAY_MARGIN_TOP) > playlist.bottom); + const videoOutsideTop = video.top + this.CSS_OVERLAY_MARGIN_TOP < playlist.top; + const videoOutsideBottom = video.top + this.CSS_OVERLAY_HEIGHT + this.CSS_OVERLAY_MARGIN_TOP > playlist.bottom; if (videoOutsideTop || videoOutsideBottom) { - return true + return true; } } - return false + return false; } /** @@ -11532,19 +11692,19 @@ * @param {HTMLElement} el * @returns {Object} */ - getElementOffset (el) { + getElementOffset(el) { const box = el.getBoundingClientRect(); const docElem = document.documentElement; return { top: box.top + window.pageYOffset - docElem.clientTop, - left: box.left + window.pageXOffset - docElem.clientLeft - } + left: box.left + window.pageXOffset - docElem.clientLeft, + }; } /** * Hides the hover overlay element, but only if mouse pointer is outside of the hover overlay element */ - hideHoverOverlay (event, force) { + hideHoverOverlay(event, force) { const overlay = this.getHoverOverlay(); const toElement = event.toElement; @@ -11553,7 +11713,7 @@ // Prevent hiding overlay if mouseleave is triggered by user is actually hovering it and that // triggered the mouseleave event if (toElement === overlay || overlay.contains(toElement) || force) { - return + return; } this.hideOverlay(overlay); @@ -11565,7 +11725,7 @@ * Util for hiding an overlay * @param {HTMLElement} overlay */ - hideOverlay (overlay) { + hideOverlay(overlay) { overlay.setAttribute('style', 'display:none;'); } @@ -11576,7 +11736,7 @@ * inside a video thumbnail when hovering the overlay. Nice. * @param {(href: string) => void} onClick */ - appendHoverOverlay (onClick) { + appendHoverOverlay(onClick) { this.sideEffects.add('Adding the re-usable overlay to the page ', () => { // add the CSS to the head const cleanUpCSS = this.loadCSS(); @@ -11590,11 +11750,11 @@ return () => { element.remove(); cleanUpCSS(); - } + }; }); } - loadCSS () { + loadCSS() { // add the CSS to the head const id = '__ddg__icon'; const style = document.head.querySelector(`#${id}`); @@ -11609,7 +11769,7 @@ if (style) { document.head.removeChild(style); } - } + }; } /** @@ -11617,7 +11777,7 @@ * @param {string} href * @param {(href: string) => void} onClick */ - appendSmallVideoOverlay (container, href, onClick) { + appendSmallVideoOverlay(container, href, onClick) { this.sideEffects.add('Adding a small overlay for the video player', () => { // add the CSS to the head const cleanUpCSS = this.loadCSS(); @@ -11632,30 +11792,31 @@ return () => { element?.remove(); cleanUpCSS(); - } + }; }); } - getThumbnailSize (videoElement) { + getThumbnailSize(videoElement) { const imagesByArea = {}; - Array.from(videoElement.querySelectorAll('img')).forEach(image => { - imagesByArea[(image.offsetWidth * image.offsetHeight)] = image; + Array.from(videoElement.querySelectorAll('img')).forEach((image) => { + imagesByArea[image.offsetWidth * image.offsetHeight] = image; }); const largestImage = Math.max.apply(this, Object.keys(imagesByArea).map(Number)); const getSizeType = (width, height) => { - if (width < (123 + 10)) { // match CSS: width of expanded overlay + twice the left margin. - return 'small' + if (width < 123 + 10) { + // match CSS: width of expanded overlay + twice the left margin. + return 'small'; } else if (width < 300 && height < 175) { - return 'medium' + return 'medium'; } else { - return 'large' + return 'large'; } }; - return getSizeType(imagesByArea[largestImage].offsetWidth, imagesByArea[largestImage].offsetHeight) + return getSizeType(imagesByArea[largestImage].offsetWidth, imagesByArea[largestImage].offsetHeight); } /** @@ -11665,11 +11826,11 @@ * @param {HTMLElement} element - the wrapping div * @param {(href: string) => void} callback - the function to execute following a click */ - addClickHandler (element, callback) { + addClickHandler(element, callback) { element.addEventListener('click', (event) => { event.preventDefault(); event.stopImmediatePropagation(); - const link = /** @type {HTMLElement} */(event.target).closest('a'); + const link = /** @type {HTMLElement} */ (event.target).closest('a'); const href = link?.getAttribute('href'); if (href) { callback(href); @@ -11677,7 +11838,7 @@ }); } - destroy () { + destroy() { this.sideEffects.destroy(); } } @@ -11748,11 +11909,11 @@ * This features covers the implementation */ class Thumbnails { - sideEffects = new SideEffects() + sideEffects = new SideEffects(); /** * @param {ThumbnailParams} params */ - constructor (params) { + constructor(params) { this.settings = params.settings; this.messages = params.messages; this.environment = params.environment; @@ -11761,7 +11922,7 @@ /** * Perform side effects */ - init () { + init() { this.sideEffects.add('showing overlays on hover', () => { const { selectors } = this.settings; const parentNode = document.documentElement || document.body; @@ -11812,33 +11973,33 @@ // detect hovers and decide to show hover icon, or not const mouseOverHandler = (e) => { - if (clicked) return + if (clicked) return; const hoverElement = findElementFromEvent(selectors.thumbLink, selectors.hoverExcluded, e); const validLink = isValidLink(hoverElement, selectors.excludedRegions); // if it's not an element we care about, bail early and remove the overlay if (!hoverElement || !validLink) { - return removeOverlay() + return removeOverlay(); } // ensure it doesn't contain sub-links if (hoverElement.querySelector('a[href]')) { - return removeOverlay() + return removeOverlay(); } // only add Dax when this link also contained an img if (!hoverElement.querySelector('img')) { - return removeOverlay() + return removeOverlay(); } // if the hover target is the match, or contains the match, all good if (e.target === hoverElement || hoverElement?.contains(e.target)) { - return appendOverlay(hoverElement) + return appendOverlay(hoverElement); } // finally, check the 'allowedEventTargets' to see if the hover occurred in an element // that we know to be a thumbnail overlay, like a preview - const matched = selectors.allowedEventTargets.find(css => e.target.matches(css)); + const matched = selectors.allowedEventTargets.find((css) => e.target.matches(css)); if (matched) { appendOverlay(hoverElement); } @@ -11850,21 +12011,21 @@ parentNode.removeEventListener('mouseover', mouseOverHandler, true); parentNode.removeEventListener('click', clickHandler, true); icon.destroy(); - } + }; }); } - destroy () { + destroy() { this.sideEffects.destroy(); } } class ClickInterception { - sideEffects = new SideEffects() + sideEffects = new SideEffects(); /** * @param {ThumbnailParams} params */ - constructor (params) { + constructor(params) { this.settings = params.settings; this.messages = params.messages; this.environment = params.environment; @@ -11873,7 +12034,7 @@ /** * Perform side effects */ - init () { + init() { this.sideEffects.add('intercepting clicks', () => { const { selectors } = this.settings; const parentNode = document.documentElement || document.body; @@ -11890,17 +12051,17 @@ // if there's no match, return early if (!validLink) { - return + return; } // if the hover target is the match, or contains the match, all good if (e.target === elementInStack || elementInStack?.contains(e.target)) { - return block(validLink) + return block(validLink); } // finally, check the 'allowedEventTargets' to see if the hover occurred in an element // that we know to be a thumbnail overlay, like a preview - const matched = selectors.allowedEventTargets.find(css => e.target.matches(css)); + const matched = selectors.allowedEventTargets.find((css) => e.target.matches(css)); if (matched) { block(validLink); } @@ -11910,11 +12071,11 @@ return () => { parentNode.removeEventListener('click', clickHandler, true); - } + }; }); } - destroy () { + destroy() { this.sideEffects.destroy(); } } @@ -11925,7 +12086,7 @@ * @param {MouseEvent} e * @return {HTMLElement|null} */ - function findElementFromEvent (selector, excludedSelectors, e) { + function findElementFromEvent(selector, excludedSelectors, e) { /** @type {HTMLElement | null} */ let matched = null; @@ -11933,8 +12094,8 @@ for (const element of document.elementsFromPoint(e.clientX, e.clientY)) { // bail early if this item was excluded anywhere in the element stack - if (excludedSelectors.some(ex => element.matches(ex))) { - return null + if (excludedSelectors.some((ex) => element.matches(ex))) { + return null; } // we cannot return this immediately, because another element in the stack @@ -11942,11 +12103,11 @@ if (element.matches(selector)) { // in lots of cases we can just return the element as soon as it's found, to prevent // checking the entire stack - matched = /** @type {HTMLElement} */(element); - if (fastPath) return matched + matched = /** @type {HTMLElement} */ (element); + if (fastPath) return matched; } } - return matched + return matched; } /** @@ -11954,36 +12115,36 @@ * @param {string[]} excludedRegions * @return {string | null | undefined} */ - function isValidLink (element, excludedRegions) { - if (!element) return null + function isValidLink(element, excludedRegions) { + if (!element) return null; /** * Does this element exist inside an excluded region? */ - const existsInExcludedParent = excludedRegions.some(selector => { + const existsInExcludedParent = excludedRegions.some((selector) => { for (const parent of document.querySelectorAll(selector)) { - if (parent.contains(element)) return true + if (parent.contains(element)) return true; } - return false + return false; }); /** * Does this element exist inside an excluded region? * If so, bail */ - if (existsInExcludedParent) return null + if (existsInExcludedParent) return null; /** * We shouldn't be able to get here, but this keeps Typescript happy * and is a good check regardless */ - if (!('href' in element)) return null + if (!('href' in element)) return null; /** * If we get here, we're trying to convert the `element.href` * into a valid Duck Player URL */ - return VideoParams.fromHref(element.href)?.toPrivatePlayerUrl() + return VideoParams.fromHref(element.href)?.toPrivatePlayerUrl(); } var css = "/* -- VIDEO PLAYER OVERLAY */\n:host {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n color: white;\n z-index: 10000;\n}\n:host * {\n font-family: system, -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n}\n.ddg-video-player-overlay {\n font-size: 13px;\n font-weight: 400;\n line-height: 16px;\n text-align: center;\n\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n color: white;\n z-index: 10000;\n}\n\n.ddg-eyeball svg {\n width: 60px;\n height: 60px;\n}\n\n.ddg-vpo-bg {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n color: white;\n text-align: center;\n background: black;\n}\n\n.ddg-vpo-bg:after {\n content: \" \";\n position: absolute;\n display: block;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0,0,0,1); /* this gets overriden if the background image can be found */\n color: white;\n text-align: center;\n}\n\n.ddg-video-player-overlay[data-thumb-loaded=\"true\"] .ddg-vpo-bg:after {\n background: rgba(0,0,0,0.75);\n}\n\n.ddg-vpo-content {\n position: relative;\n top: 50%;\n transform: translate(-50%, -50%);\n left: 50%;\n max-width: 90%;\n}\n\n.ddg-vpo-eyeball {\n margin-bottom: 18px;\n}\n\n.ddg-vpo-title {\n font-size: 22px;\n font-weight: 400;\n line-height: 26px;\n margin-top: 25px;\n}\n\n.ddg-vpo-text {\n margin-top: 16px;\n width: 496px;\n margin-left: auto;\n margin-right: auto;\n}\n\n.ddg-vpo-text b {\n font-weight: 600;\n}\n\n.ddg-vpo-buttons {\n margin-top: 25px;\n}\n.ddg-vpo-buttons > * {\n display: inline-block;\n margin: 0;\n padding: 0;\n}\n\n.ddg-vpo-button {\n color: white;\n padding: 9px 16px;\n font-size: 13px;\n border-radius: 8px;\n font-weight: 600;\n display: inline-block;\n text-decoration: none;\n}\n\n.ddg-vpo-button + .ddg-vpo-button {\n margin-left: 10px;\n}\n\n.ddg-vpo-cancel {\n background: #585b58;\n border: 0.5px solid rgba(40, 145, 255, 0.05);\n box-shadow: 0px 0px 0px 0.5px rgba(0, 0, 0, 0.1), 0px 0px 1px rgba(0, 0, 0, 0.05), 0px 1px 1px rgba(0, 0, 0, 0.2), inset 0px 0.5px 0px rgba(255, 255, 255, 0.2), inset 0px 1px 0px rgba(255, 255, 255, 0.05);\n}\n\n.ddg-vpo-open {\n background: #3969EF;\n border: 0.5px solid rgba(40, 145, 255, 0.05);\n box-shadow: 0px 0px 0px 0.5px rgba(0, 0, 0, 0.1), 0px 0px 1px rgba(0, 0, 0, 0.05), 0px 1px 1px rgba(0, 0, 0, 0.2), inset 0px 0.5px 0px rgba(255, 255, 255, 0.2), inset 0px 1px 0px rgba(255, 255, 255, 0.05);\n}\n\n.ddg-vpo-open:hover {\n background: #1d51e2;\n}\n.ddg-vpo-cancel:hover {\n cursor: pointer;\n background: #2f2f2f;\n}\n\n.ddg-vpo-remember {\n}\n.ddg-vpo-remember label {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-top: 25px;\n cursor: pointer;\n}\n.ddg-vpo-remember input {\n margin-right: 6px;\n}\n"; @@ -11993,9 +12154,9 @@ * over the YouTube player */ class DDGVideoOverlay extends HTMLElement { - policy = createPolicy() + policy = createPolicy(); - static CUSTOM_TAG_NAME = 'ddg-video-overlay' + static CUSTOM_TAG_NAME = 'ddg-video-overlay'; /** * @param {object} options * @param {import("../overlays.js").Environment} options.environment @@ -12003,9 +12164,9 @@ * @param {import("../../duck-player.js").UISettings} options.ui * @param {VideoOverlay} options.manager */ - constructor ({ environment, params, ui, manager }) { + constructor({ environment, params, ui, manager }) { super(); - if (!(manager instanceof VideoOverlay)) throw new Error('invalid arguments') + if (!(manager instanceof VideoOverlay)) throw new Error('invalid arguments'); this.environment = environment; this.ui = ui; this.params = params; @@ -12040,7 +12201,7 @@ /** * @returns {HTMLDivElement} */ - createOverlay () { + createOverlay() { const overlayCopy = overlayCopyVariants.default; const overlayElement = document.createElement('div'); overlayElement.classList.add('ddg-video-player-overlay'); @@ -12050,20 +12211,16 @@
${svgIcon}
${overlayCopy.title}
-
- ${overlayCopy.subtitle} -
+
${overlayCopy.subtitle}
${overlayCopy.buttonOpen}
- +
- `.toString(); + `.toString(); overlayElement.innerHTML = this.policy.createHTML(safeString); @@ -12084,14 +12241,14 @@ */ this.setupButtonsInsideOverlay(overlayElement, this.params); - return overlayElement + return overlayElement; } /** * @param {HTMLElement} overlayElement * @param {string} videoId */ - appendThumbnail (overlayElement, videoId) { + appendThumbnail(overlayElement, videoId) { const imageUrl = this.environment.getLargeThumbnailSrc(videoId); appendImageAsBackground(overlayElement, '.ddg-vpo-bg', imageUrl); } @@ -12100,15 +12257,15 @@ * @param {HTMLElement} containerElement * @param {import("../util").VideoParams} params */ - setupButtonsInsideOverlay (containerElement, params) { + setupButtonsInsideOverlay(containerElement, params) { const cancelElement = containerElement.querySelector('.ddg-vpo-cancel'); const watchInPlayer = containerElement.querySelector('.ddg-vpo-open'); - if (!cancelElement) return console.warn('Could not access .ddg-vpo-cancel') - if (!watchInPlayer) return console.warn('Could not access .ddg-vpo-open') + if (!cancelElement) return console.warn('Could not access .ddg-vpo-cancel'); + if (!watchInPlayer) return console.warn('Could not access .ddg-vpo-open'); const optOutHandler = (e) => { if (e.isTrusted) { const remember = containerElement.querySelector('input[name="ddg-remember"]'); - if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input') + if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input'); this.manager.userOptOut(remember.checked, params); } }; @@ -12116,7 +12273,7 @@ if (e.isTrusted) { e.preventDefault(); const remember = containerElement.querySelector('input[name="ddg-remember"]'); - if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input') + if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input'); this.manager.userOptIn(remember.checked, params); } }; @@ -12139,22 +12296,22 @@ * over the YouTube player */ class DDGVideoOverlayMobile extends HTMLElement { - static CUSTOM_TAG_NAME = 'ddg-video-overlay-mobile' - static OPEN_INFO = 'open-info' - static OPT_IN = 'opt-in' - static OPT_OUT = 'opt-out' + static CUSTOM_TAG_NAME = 'ddg-video-overlay-mobile'; + static OPEN_INFO = 'open-info'; + static OPT_IN = 'opt-in'; + static OPT_OUT = 'opt-out'; - policy = createPolicy() + policy = createPolicy(); /** @type {boolean} */ - testMode = false + testMode = false; /** @type {Text | null} */ - text = null + text = null; - connectedCallback () { + connectedCallback() { this.createMarkupAndStyles(); } - createMarkupAndStyles () { + createMarkupAndStyles() { const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); const style = document.createElement('style'); style.innerText = mobilecss; @@ -12168,10 +12325,10 @@ /** * @returns {string} */ - mobileHtml () { + mobileHtml() { if (!this.text) { console.warn('missing `text`. Please assign before rendering'); - return '' + return ''; } const svgIcon = trustedUnsafe(dax); const infoIcon = trustedUnsafe(info); @@ -12182,24 +12339,18 @@
${this.text.title}
- -
-
- ${this.text.subtitle} +
+
${this.text.subtitle}
${this.text.buttonOpen}
- - ${this.text.rememberLabel} - + ${this.text.rememberLabel} - + @@ -12208,24 +12359,22 @@
- `.toString() + `.toString(); } /** * @param {HTMLElement} containerElement */ - setupEventHandlers (containerElement) { + setupEventHandlers(containerElement) { const switchElem = containerElement.querySelector('[role=switch]'); const infoButton = containerElement.querySelector('.button--info'); const remember = containerElement.querySelector('input[name="ddg-remember"]'); const cancelElement = containerElement.querySelector('.ddg-vpo-cancel'); const watchInPlayer = containerElement.querySelector('.ddg-vpo-open'); - if (!infoButton || - !cancelElement || - !watchInPlayer || - !switchElem || - !(remember instanceof HTMLInputElement)) return console.warn('missing elements') + if (!infoButton || !cancelElement || !watchInPlayer || !switchElem || !(remember instanceof HTMLInputElement)) { + return console.warn('missing elements'); + } infoButton.addEventListener('click', () => { this.dispatchEvent(new Event(DDGVideoOverlayMobile.OPEN_INFO)); @@ -12243,14 +12392,14 @@ }); cancelElement.addEventListener('click', (e) => { - if (!e.isTrusted) return + if (!e.isTrusted) return; e.preventDefault(); e.stopImmediatePropagation(); this.dispatchEvent(new CustomEvent(DDGVideoOverlayMobile.OPT_OUT, { detail: { remember: remember.checked } })); }); watchInPlayer.addEventListener('click', (e) => { - if (!e.isTrusted) return + if (!e.isTrusted) return; e.preventDefault(); e.stopImmediatePropagation(); this.dispatchEvent(new CustomEvent(DDGVideoOverlayMobile.OPT_IN, { detail: { remember: remember.checked } })); @@ -12291,13 +12440,13 @@ * + conduct any communications */ class VideoOverlay { - sideEffects = new SideEffects() + sideEffects = new SideEffects(); /** @type {string | null} */ - lastVideoId = null + lastVideoId = null; /** @type {boolean} */ - didAllowFirstVideo = false + didAllowFirstVideo = false; /** * @param {object} options @@ -12307,7 +12456,7 @@ * @param {import("./overlay-messages.js").DuckPlayerOverlayMessages} options.messages * @param {import("../duck-player.js").UISettings} options.ui */ - constructor ({ userValues, settings, environment, messages, ui }) { + constructor({ userValues, settings, environment, messages, ui }) { this.userValues = userValues; this.settings = settings; this.environment = environment; @@ -12318,7 +12467,7 @@ /** * @param {'page-load' | 'preferences-changed' | 'href-changed'} trigger */ - init (trigger) { + init(trigger) { if (trigger === 'page-load') { this.handleFirstPageLoad(); } else if (trigger === 'preferences-changed') { @@ -12331,13 +12480,13 @@ /** * Special handling of a first-page, an attempt to load our overlay as quickly as possible */ - handleFirstPageLoad () { + handleFirstPageLoad() { // don't continue unless we're in 'alwaysAsk' mode - if ('disabled' in this.userValues.privatePlayerMode) return + if ('disabled' in this.userValues.privatePlayerMode) return; // don't continue if we can't derive valid video params const validParams = VideoParams.forWatchPage(this.environment.getPlayerPageHref()); - if (!validParams) return + if (!validParams) return; /** * If we get here, we know the following: @@ -12359,7 +12508,7 @@ if (style.isConnected) { document.head.removeChild(style); } - } + }; }); /** @@ -12371,18 +12520,18 @@ }, 100); return () => { clearInterval(int); - } + }; }); } /** * @param {import("./util").VideoParams} params */ - addSmallDaxOverlay (params) { + addSmallDaxOverlay(params) { const containerElement = document.querySelector(this.settings.selectors.videoElementContainer); if (!containerElement || !(containerElement instanceof HTMLElement)) { console.error('no container element'); - return + return; } this.sideEffects.add('adding small dax 🐥 icon overlay', () => { const href = params.toPrivatePlayerUrl(); @@ -12395,14 +12544,14 @@ return () => { icon.destroy(); - } + }; }); } /** * @param {{ignoreCache?: boolean, via?: string}} [opts] */ - watchForVideoBeingAdded (opts = {}) { + watchForVideoBeingAdded(opts = {}) { const params = VideoParams.forWatchPage(this.environment.getPlayerPageHref()); if (!params) { @@ -12414,7 +12563,7 @@ this.destroy(); this.lastVideoId = null; } - return + return; } const conditions = [ @@ -12423,7 +12572,7 @@ // first visit !this.lastVideoId, // new video id - this.lastVideoId && this.lastVideoId !== params.id // different + this.lastVideoId && this.lastVideoId !== params.id, // different ]; if (conditions.some(Boolean)) { @@ -12433,7 +12582,7 @@ const videoElement = document.querySelector(this.settings.selectors.videoElement); const playerContainer = document.querySelector(this.settings.selectors.videoElementContainer); if (!videoElement || !playerContainer) { - return null + return null; } /** @@ -12451,22 +12600,22 @@ * When enabled, just show the small dax icon */ if ('enabled' in userValues.privatePlayerMode) { - return this.addSmallDaxOverlay(params) + return this.addSmallDaxOverlay(params); } if ('alwaysAsk' in userValues.privatePlayerMode) { // if there's a one-time-override (eg: a link from the serp), then do nothing - if (this.environment.hasOneTimeOverride()) return + if (this.environment.hasOneTimeOverride()) return; // should the first video be allowed to play? if (this.ui.allowFirstVideo === true && !this.didAllowFirstVideo) { this.didAllowFirstVideo = true; - return console.count('Allowing the first video') + return console.count('Allowing the first video'); } // if the user previously clicked 'watch here + remember', just add the small dax if (this.userValues.overlayInteracted) { - return this.addSmallDaxOverlay(params) + return this.addSmallDaxOverlay(params); } // if we get here, we're trying to prevent the video playing @@ -12480,24 +12629,22 @@ * @param {Element} targetElement * @param {import("./util").VideoParams} params */ - appendOverlayToPage (targetElement, params) { + appendOverlayToPage(targetElement, params) { this.sideEffects.add(`appending ${DDGVideoOverlay.CUSTOM_TAG_NAME} or ${DDGVideoOverlayMobile.CUSTOM_TAG_NAME} to the page`, () => { this.messages.sendPixel(new Pixel({ name: 'overlay' })); const controller = new AbortController(); const { environment } = this; if (this.environment.layout === 'mobile') { - const elem = /** @type {DDGVideoOverlayMobile} */(document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)); + const elem = /** @type {DDGVideoOverlayMobile} */ (document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)); elem.testMode = this.environment.isTestMode(); elem.text = mobileStrings(this.environment.strings); elem.addEventListener(DDGVideoOverlayMobile.OPEN_INFO, () => this.messages.openInfo()); - elem.addEventListener(DDGVideoOverlayMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */e) => { - return this.mobileOptOut(e.detail.remember) - .catch(console.error) + elem.addEventListener(DDGVideoOverlayMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { + return this.mobileOptOut(e.detail.remember).catch(console.error); }); - elem.addEventListener(DDGVideoOverlayMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */e) => { - return this.mobileOptIn(e.detail.remember, params) - .catch(console.error) + elem.addEventListener(DDGVideoOverlayMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { + return this.mobileOptIn(e.detail.remember, params).catch(console.error); }); targetElement.appendChild(elem); } else { @@ -12505,7 +12652,7 @@ environment, params, ui: this.ui, - manager: this + manager: this, }); targetElement.appendChild(elem); } @@ -12517,21 +12664,21 @@ document.querySelector(DDGVideoOverlay.CUSTOM_TAG_NAME)?.remove(); document.querySelector(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)?.remove(); controller.abort(); - } + }; }); } /** * Just brute-force calling video.pause() for as long as the user is seeing the overlay. */ - stopVideoFromPlaying () { + stopVideoFromPlaying() { this.sideEffects.add(`pausing the