diff --git a/.github/actions/assign-release-task/github_asana_mapping.yml b/.github/actions/assign-release-task/github_asana_mapping.yml index b4fb89396f86..3d8a34482079 100644 --- a/.github/actions/assign-release-task/github_asana_mapping.yml +++ b/.github/actions/assign-release-task/github_asana_mapping.yml @@ -10,4 +10,5 @@ lmac012: "1205617573940213" nalcalag: "1201807753392396" CrisBarreiro: "1204920898013507" 0nko: "1207418217763343" -mikescamell: "1207908161520961" \ No newline at end of file +mikescamell: "1207908161520961" +LukasPaczos: "1208671518759204" diff --git a/.github/actions/check-for-changes-since-tag/action.yml b/.github/actions/check-for-changes-since-tag/action.yml new file mode 100644 index 000000000000..dc28d5994dc4 --- /dev/null +++ b/.github/actions/check-for-changes-since-tag/action.yml @@ -0,0 +1,28 @@ +name: 'Check for code changes after a specific tag' +description: 'Checks if there are any new commits to the develop branch since the specified tag' + +inputs: + tag: + description: 'Tag to check' + required: true + +outputs: + has_changes: + description: Whether there are new commits since the last tag + value: ${{ steps.check_for_changes.outputs.has_changes }} + +runs: + using: 'composite' + steps: + - id: check_for_changes + shell: bash + run: | + # Check if there are any new commits since the tag + new_commits=$(git rev-list ${{ inputs.tag }}..develop --count) + echo "$new_commits commits since ${{ inputs.tag }} tag" + + if [ $new_commits -gt 0 ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi \ No newline at end of file diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index b2119558732e..bc7d2ed8d174 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -48,7 +48,22 @@ jobs: echo "Latest tag: $output" echo "latest_tag=$output" >> $GITHUB_OUTPUT + - name: Check for changes + id: check_for_changes + uses: ./.github/actions/check-for-changes-since-tag + with: + github_token: ${{ secrets.GT_DAXMOBILE }} + tag: ${{ steps.get_latest_tag.outputs.latest_tag }} + + - name: Notify if no changes + if: steps.check_for_changes.outputs.has_changes == 'false' + run: | + echo "No new commits since the last tag. Skipping nightly release." + echo "No new commits since the last tag. Skipping nightly release." >> $GITHUB_STEP_SUMMARY + exit 0 + - name: Decode upload keys + if: steps.check_for_changes.outputs.has_changes == 'true' uses: davidSchuppa/base64Secret-toFile-action@199e78f212c854d2284fada7f3cd3aba3e37d208 with: secret: ${{ secrets.UPLOAD_RELEASE_PROPERTIES }} @@ -56,6 +71,7 @@ jobs: destination-path: $HOME/jenkins_static/com.duckduckgo.mobile.android/ - name: Decode key file + if: steps.check_for_changes.outputs.has_changes == 'true' uses: davidSchuppa/base64Secret-toFile-action@199e78f212c854d2284fada7f3cd3aba3e37d208 with: secret: ${{ secrets.UPLOAD_RELEASE_KEY }} @@ -63,6 +79,7 @@ jobs: destination-path: $HOME/jenkins_static/com.duckduckgo.mobile.android/ - name: Decode Play Store credentials file + if: steps.check_for_changes.outputs.has_changes == 'true' uses: davidSchuppa/base64Secret-toFile-action@199e78f212c854d2284fada7f3cd3aba3e37d208 with: secret: ${{ secrets.UPLOAD_PLAY_CREDENTIALS }} @@ -70,6 +87,7 @@ jobs: destination-path: $HOME/jenkins_static/com.duckduckgo.mobile.android/ - name: Decode Firebase credentials file + if: steps.check_for_changes.outputs.has_changes == 'true' uses: davidSchuppa/base64Secret-toFile-action@199e78f212c854d2284fada7f3cd3aba3e37d208 with: secret: ${{ secrets.UPLOAD_FIREBASE_CREDENTIALS }} @@ -77,30 +95,36 @@ jobs: destination-path: $HOME/jenkins_static/com.duckduckgo.mobile.android/ - name: Clean project + if: steps.check_for_changes.outputs.has_changes == 'true' run: | gradle clean - name: Assemble the bundle - run: gradle bundleInternalRelease -PversionNameSuffix=-nightly -PuseUploadSigning -PlatestTag=${{ steps.get_latest_tag.outputs.latest_tag }} + if: steps.check_for_changes.outputs.has_changes == 'true' + run: gradle bundleInternalRelease -PversionNameSuffix=-nightly -PuseUploadSigning -PlatestTag=${{ steps.get_latest_tag.outputs.latest_tag }} -Pbuild-date-time - name: Generate nightly version name + if: steps.check_for_changes.outputs.has_changes == 'true' id: generate_version_name run: | output=$(gradle getBuildVersionName -PversionNameSuffix=-nightly -PlatestTag=${{ steps.get_latest_tag.outputs.latest_tag }} --quiet | tail -n 1) echo "version=$output" >> $GITHUB_OUTPUT - name: Capture App Bundle Path + if: steps.check_for_changes.outputs.has_changes == 'true' id: capture_output run: | output=$(find app/build/outputs/bundle/internalRelease -name "*.aab") echo "bundle_path=$output" >> $GITHUB_OUTPUT - name: Upload bundle to Play Store Internal track + if: steps.check_for_changes.outputs.has_changes == 'true' id: create_app_bundle run: | bundle exec fastlane deploy_dogfood aab_path:${{ steps.capture_output.outputs.bundle_path }} - name: Tag Nightly release + if: steps.check_for_changes.outputs.has_changes == 'true' id: tag_nightly_release run: | git checkout develop @@ -108,11 +132,17 @@ jobs: git push origin ${{ steps.generate_version_name.outputs.version }} - name: Upload APK as artifact + if: steps.check_for_changes.outputs.has_changes == 'true' uses: actions/upload-artifact@v4 with: name: duckduckgo-${{ steps.generate_version_name.outputs.version }}.apk path: duckduckgo.apk + - name: Set successful summary + if: steps.check_for_changes.outputs.has_changes == 'true' + run: | + echo "### Nightly release completed! :rocket:" >> $GITHUB_STEP_SUMMARY + - name: Create Asana task when workflow failed if: ${{ failure() }} uses: duckduckgo/native-github-asana-sync@v1.1 diff --git a/.github/workflows/release_upload_play_store.yml b/.github/workflows/release_upload_play_store.yml index 754904402463..e94172db1aa4 100644 --- a/.github/workflows/release_upload_play_store.yml +++ b/.github/workflows/release_upload_play_store.yml @@ -63,7 +63,7 @@ jobs: destination-path: $HOME/jenkins_static/com.duckduckgo.mobile.android/ - name: Assemble the bundle - run: ./gradleW bundleRelease -PuseUploadSigning + run: ./gradlew bundleRelease -PuseUploadSigning -Pbuild-date-time - name: Capture App Bundle Path id: capture_output @@ -72,21 +72,15 @@ jobs: echo "bundle_path=$output" >> $GITHUB_OUTPUT - name: Upload bundle to Play Store - id: create_app_bundle + id: upload_bundle_play run: | bundle exec fastlane deploy_playstore - name: Upload Universal APK to Github - id: create_app_bundle + id: upload_bundle_github 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 diff --git a/.gitignore b/.gitignore index df9a81758902..b09f4f52a9a9 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,5 @@ report !/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/ /node_modules/@duckduckgo/content-scope-scripts/build/android/pages/* !/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/ -!/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js \ No newline at end of file +!/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js +!/node_modules/@duckduckgo/content-scope-scripts/build/android/autofillPasswordImport.js \ No newline at end of file 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 063c7edfcc13..267ea28c0384 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 @@ -118,7 +118,6 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .flavorNameProvider({ appBuildConfig.flavor.name }) .featureName(%S) .appVariantProvider({ appBuildConfig.variantName }) - .localeProvider({ appBuildConfig.deviceLocale }) .callback(callback) // save empty variants will force the default variant to be set .forceDefaultVariantProvider({ variantManager.updateVariants(emptyList()) }) @@ -521,6 +520,8 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { variantKey = target.variantKey, localeCountry = target.localeCountry, localeLanguage = target.localeLanguage, + isReturningUser = target.isReturningUser, + isPrivacyProEligible = target.isPrivacyProEligible, ) } ?: emptyList() val cohorts = jsonToggle?.cohorts?.map { cohort -> @@ -727,11 +728,22 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .addParameter("variantKey", String::class.asClassName()) .addParameter("localeCountry", String::class.asClassName()) .addParameter("localeLanguage", String::class.asClassName()) + .addParameter("isReturningUser", Boolean::class.asClassName().copy(nullable = true)) + .addParameter("isPrivacyProEligible", Boolean::class.asClassName().copy(nullable = true)) .build(), ) .addProperty(PropertySpec.builder("variantKey", String::class.asClassName()).initializer("variantKey").build()) .addProperty(PropertySpec.builder("localeCountry", String::class.asClassName()).initializer("localeCountry").build()) .addProperty(PropertySpec.builder("localeLanguage", String::class.asClassName()).initializer("localeLanguage").build()) + .addProperty( + PropertySpec.builder("isReturningUser", Boolean::class.asClassName().copy(nullable = true)).initializer("isReturningUser").build(), + ) + .addProperty( + PropertySpec + .builder("isPrivacyProEligible", Boolean::class.asClassName().copy(nullable = true)) + .initializer("isPrivacyProEligible") + .build(), + ) .build() } diff --git a/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt b/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt index d1dc85328953..4d6f63edf828 100644 --- a/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt +++ b/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt @@ -32,6 +32,7 @@ interface AppBuildConfig { val model: String val deviceLocale: Locale val isDefaultVariantForced: Boolean + val buildDateTimeMillis: Long /** * You should call [variantName] in a background thread diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixelNames.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixelNames.kt index b071d578ecec..009c6dbe80de 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixelNames.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixelNames.kt @@ -39,6 +39,7 @@ enum class DeviceShieldPixelNames(override val pixelName: String, val enqueue: B ATP_DISABLE_DAILY("m_atp_ev_disabled_d"), ATP_ENABLE_UNIQUE("m_atp_ev_enabled_u"), + ATP_ENABLE_MONTHLY("m_atp_ev_enabled_monthly"), ATP_ENABLE_FROM_REMINDER_NOTIFICATION_UNIQUE("m_atp_ev_enabled_reminder_notification_u"), ATP_ENABLE_FROM_REMINDER_NOTIFICATION_DAILY("m_atp_ev_enabled_reminder_notification_d"), ATP_ENABLE_FROM_REMINDER_NOTIFICATION("m_atp_ev_enabled_reminder_notification_c"), diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixels.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixels.kt index 09a213d58733..73429a6738c5 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixels.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixels.kt @@ -24,10 +24,13 @@ import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import java.time.Instant +import java.time.LocalDate import java.time.ZoneOffset import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import java.util.* import javax.inject.Inject +import kotlin.math.absoluteValue interface DeviceShieldPixels { /** This pixel will be unique on a given day, no matter how many times we call this fun */ @@ -414,6 +417,7 @@ class RealDeviceShieldPixels @Inject constructor( override fun reportEnabled() { tryToFireUniquePixel(DeviceShieldPixelNames.ATP_ENABLE_UNIQUE) tryToFireDailyPixel(DeviceShieldPixelNames.ATP_ENABLE_DAILY) + tryToFireMonthlyPixel(DeviceShieldPixelNames.ATP_ENABLE_MONTHLY) } override fun reportDisabled() { @@ -934,6 +938,45 @@ class RealDeviceShieldPixels @Inject constructor( } } + private fun tryToFireMonthlyPixel( + pixel: DeviceShieldPixelNames, + payload: Map = emptyMap(), + ) { + tryToFireMonthlyPixel(pixel.pixelName, payload, pixel.enqueue) + } + + private fun tryToFireMonthlyPixel( + pixelName: String, + payload: Map = emptyMap(), + enqueue: Boolean = false, + ) { + fun isMoreThan28DaysApart(date1: String, date2: String): Boolean { + // Parse the strings into LocalDate objects + val firstDate = LocalDate.parse(date1) + val secondDate = LocalDate.parse(date2) + + // Calculate the difference in days + val daysBetween = ChronoUnit.DAYS.between(firstDate, secondDate).absoluteValue + + // Check if the difference is more than 28 days + return daysBetween > 28 + } + + val now = getUtcIsoLocalDate() + val timestamp = preferences.getString(pixelName.appendTimestampSuffix(), null) + + // check if pixel was already sent in the current day + if (timestamp == null || isMoreThan28DaysApart(now, timestamp)) { + if (enqueue) { + this.pixel.enqueueFire(pixelName, payload) + .also { preferences.edit { putString(pixelName.appendTimestampSuffix(), now) } } + } else { + this.pixel.fire(pixelName, payload) + .also { preferences.edit { putString(pixelName.appendTimestampSuffix(), now) } } + } + } + } + private fun tryToFireUniquePixel( pixel: DeviceShieldPixelNames, tag: String? = null, diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PProUpsellBannerPlugin.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PProUpsellBannerPlugin.kt index 9f1c388f542b..73f23ff2638c 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PProUpsellBannerPlugin.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PProUpsellBannerPlugin.kt @@ -89,10 +89,6 @@ class PProUpsellBannerPlugin @Inject constructor( startActivity(browserNav.openInNewTab(this, PPRO_UPSELL_URL)) } - private suspend fun Subscriptions.isUpsellEligible(): Boolean { - return getAccessToken() == null && isEligible() - } - companion object { internal const val PRIORITY_PPRO_UPSELL_BANNER = PRIORITY_ACTION_REQUIRED - 1 private const val PPRO_UPSELL_URL = "https://duckduckgo.com/pro?origin=funnel_pro_android_apptp_banner" diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellDisabledMessagePlugin.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellDisabledMessagePlugin.kt index 3e51d50a7a4f..d1fa22ad8f32 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellDisabledMessagePlugin.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellDisabledMessagePlugin.kt @@ -72,10 +72,6 @@ class PproUpsellDisabledMessagePlugin @Inject constructor( startActivity(browserNav.openInNewTab(this, PPRO_UPSELL_URL)) } - private suspend fun Subscriptions.isUpsellEligible(): Boolean { - return getAccessToken() == null && isEligible() - } - companion object { internal const val PRIORITY_PPRO_DISABLED = PRIORITY_DISABLED - 1 private const val PPRO_UPSELL_ANNOTATION = "ppro_upsell_link" diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellRevokedMessagePlugin.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellRevokedMessagePlugin.kt index c5d197166f17..859798f7e6d5 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellRevokedMessagePlugin.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellRevokedMessagePlugin.kt @@ -70,10 +70,6 @@ class PproUpsellRevokedMessagePlugin @Inject constructor( startActivity(browserNav.openInNewTab(this, PPRO_UPSELL_URL)) } - private suspend fun Subscriptions.isUpsellEligible(): Boolean { - return getAccessToken() == null && isEligible() - } - companion object { internal const val PRIORITY_PPRO_REVOKED = PRIORITY_REVOKED - 1 private const val PPRO_UPSELL_ANNOTATION = "ppro_upsell_link" diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/SubscriptionsExt.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/SubscriptionsExt.kt new file mode 100644 index 000000000000..fb388c59557f --- /dev/null +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/SubscriptionsExt.kt @@ -0,0 +1,23 @@ +/* + * 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.mobile.android.vpn.ui.tracker_activity.view.message + +import com.duckduckgo.subscriptions.api.Subscriptions + +suspend fun Subscriptions.isUpsellEligible(): Boolean { + return !isSignedIn() && isEligible() +} diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/pixels/RealDeviceShieldPixelsTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/pixels/RealDeviceShieldPixelsTest.kt index 0a093e0c4860..c71a7b48ff32 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/pixels/RealDeviceShieldPixelsTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/pixels/RealDeviceShieldPixelsTest.kt @@ -16,9 +16,14 @@ package com.duckduckgo.mobile.android.vpn.pixels +import androidx.core.content.edit import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.api.InMemorySharedPreferences import com.duckduckgo.data.store.api.SharedPreferencesProvider +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import java.util.* import org.junit.Before import org.junit.Test @@ -28,12 +33,12 @@ class RealDeviceShieldPixelsTest { private val pixel = mock() private val sharedPreferencesProvider = mock() + private val prefs = InMemorySharedPreferences() lateinit var deviceShieldPixels: DeviceShieldPixels @Before fun setup() { - val prefs = InMemorySharedPreferences() whenever( sharedPreferencesProvider.getSharedPreferences(eq("com.duckduckgo.mobile.android.device.shield.pixels"), eq(true), eq(true)), ).thenReturn(prefs) @@ -60,15 +65,48 @@ class RealDeviceShieldPixelsTest { } @Test - fun whenReportEnableThenFireUniqueAndDailyPixel() { + fun whenReportEnableThenFireUniqueAndMonthlyAndDailyPixel() { deviceShieldPixels.reportEnabled() deviceShieldPixels.reportEnabled() verify(pixel).fire(DeviceShieldPixelNames.ATP_ENABLE_UNIQUE) verify(pixel).fire(DeviceShieldPixelNames.ATP_ENABLE_DAILY.pixelName) + verify(pixel).fire(DeviceShieldPixelNames.ATP_ENABLE_MONTHLY.pixelName) verifyNoMoreInteractions(pixel) } + @Test + fun whenReportExactly28DaysApartThenDoNotFireMonthlyPixel() { + val pixelName = DeviceShieldPixelNames.ATP_ENABLE_MONTHLY.pixelName + + deviceShieldPixels.reportEnabled() + + val pastDate = Instant.now() + .minus(28, ChronoUnit.DAYS) + .atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_LOCAL_DATE) + prefs.edit(true) { putString("${pixelName}_timestamp", pastDate) } + + deviceShieldPixels.reportEnabled() + + verify(pixel).fire(pixelName) + } + + @Test + fun whenReportEnableMoreThan28DaysApartReportMonthlyPixel() { + val pixelName = DeviceShieldPixelNames.ATP_ENABLE_MONTHLY.pixelName + + deviceShieldPixels.reportEnabled() + + val pastDate = Instant.now() + .minus(29, ChronoUnit.DAYS) + .atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_LOCAL_DATE) + prefs.edit(true) { putString("${pixelName}_timestamp", pastDate) } + + deviceShieldPixels.reportEnabled() + + verify(pixel, times(2)).fire(pixelName) + } + @Test fun whenReportDisableThenFireDailyPixel() { deviceShieldPixels.reportDisabled() diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PProUpsellBannerPluginTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PProUpsellBannerPluginTest.kt index eb735e223848..2830df3f0d01 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PProUpsellBannerPluginTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PProUpsellBannerPluginTest.kt @@ -35,6 +35,7 @@ class PProUpsellBannerPluginTest { @Test fun whenVPNIsDisabledAndUserNotEligibleToPProThenGetViewReturnsNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(false) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = DISABLED)) {} @@ -44,7 +45,7 @@ class PProUpsellBannerPluginTest { @Test fun whenVPNIsEnabledAndUserIsSubscriberToPProThenGetViewReturnsNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn("123") + whenever(subscriptions.isSignedIn()).thenReturn(true) val result = plugin.getView(context, VpnState(state = ENABLED)) {} @@ -54,7 +55,7 @@ class PProUpsellBannerPluginTest { @Test fun whenBannerDismissedThenGetViewReturnsNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) vpnStore.dismissPproUpsellBanner() val result = plugin.getView(context, VpnState(state = ENABLED)) {} diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellDisabledMessagePluginTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellDisabledMessagePluginTest.kt index cafd73ddaaa2..9c2f5fcedd5d 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellDisabledMessagePluginTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellDisabledMessagePluginTest.kt @@ -39,6 +39,7 @@ class PproUpsellDisabledMessagePluginTest { fun whenVPNIsDisabledWith3rdPartyOnAndUserNotEligibleToPProThenGetViewReturnsNull() = runTest { whenever(vpnDetector.isExternalVpnDetected()).thenReturn(true) whenever(subscriptions.isEligible()).thenReturn(false) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = DISABLED, stopReason = SELF_STOP())) {} @@ -49,7 +50,7 @@ class PproUpsellDisabledMessagePluginTest { fun whenVPNIsDisabledWith3rdPartyOnAndUserIsSubscriberToPProThenGetViewReturnsNull() = runTest { whenever(vpnDetector.isExternalVpnDetected()).thenReturn(true) whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn("123") + whenever(subscriptions.isSignedIn()).thenReturn(true) val result = plugin.getView(context, VpnState(state = DISABLED, stopReason = SELF_STOP())) {} @@ -60,7 +61,7 @@ class PproUpsellDisabledMessagePluginTest { fun whenVPNIsDisabledWith3rdPartyOnAndUserIsEligibleToPproThenGetViewReturnsNotNull() = runTest { whenever(vpnDetector.isExternalVpnDetected()).thenReturn(true) whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = DISABLED, stopReason = SELF_STOP())) {} @@ -71,7 +72,7 @@ class PproUpsellDisabledMessagePluginTest { fun whenVPNIsDisabledBy3rdPartyOnAndUserIsEligibleToPproThenGetViewReturnsNull() = runTest { whenever(vpnDetector.isExternalVpnDetected()).thenReturn(true) whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = DISABLED, stopReason = REVOKED)) {} @@ -82,7 +83,7 @@ class PproUpsellDisabledMessagePluginTest { fun whenVPNIsEnablingWith3rdPartyOnAndUserIsEligibleToPproThenGetViewReturnsNull() = runTest { whenever(vpnDetector.isExternalVpnDetected()).thenReturn(true) whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = ENABLING)) {} @@ -93,7 +94,7 @@ class PproUpsellDisabledMessagePluginTest { fun whenVPNIsEnabledAndUserIsEligibleToPproThenGetViewReturnsNull() = runTest { whenever(vpnDetector.isExternalVpnDetected()).thenReturn(true) whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = ENABLED)) {} diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellRevokedMessagePluginTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellRevokedMessagePluginTest.kt index 4d452a29e99a..5f1b5590214b 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellRevokedMessagePluginTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/tracker_activity/view/message/PproUpsellRevokedMessagePluginTest.kt @@ -36,6 +36,7 @@ class PproUpsellRevokedMessagePluginTest { @Test fun whenVPNIsDisabledBy3rdPartyAndUserNotEligibleToPProThenGetViewReturnsNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(false) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = DISABLED, stopReason = REVOKED)) {} @@ -45,7 +46,7 @@ class PproUpsellRevokedMessagePluginTest { @Test fun whenVPNIsDisabledBy3rdPartyOnAndUserIsSubscriberToPProThenGetViewReturnsNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn("123") + whenever(subscriptions.isSignedIn()).thenReturn(true) val result = plugin.getView(context, VpnState(state = DISABLED, stopReason = REVOKED)) {} @@ -55,7 +56,7 @@ class PproUpsellRevokedMessagePluginTest { @Test fun whenVPNIsDisabledBy3rdPartyOnAndUserIsEligibleToPproThenGetViewReturnsNotNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = DISABLED, stopReason = REVOKED)) {} @@ -65,7 +66,7 @@ class PproUpsellRevokedMessagePluginTest { @Test fun whenVPNIsDisabledByUserAndUserIsEligibleToPproThenGetViewReturnsNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = DISABLED, stopReason = SELF_STOP())) {} @@ -75,7 +76,7 @@ class PproUpsellRevokedMessagePluginTest { @Test fun whenVPNIsEnablingAndUserIsEligibleToPproThenGetViewReturnsNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = ENABLING)) {} @@ -85,7 +86,7 @@ class PproUpsellRevokedMessagePluginTest { @Test fun whenVPNIsEnabledAndUserIsEligibleToPproThenGetViewReturnsNull() = runTest { whenever(subscriptions.isEligible()).thenReturn(true) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) val result = plugin.getView(context, VpnState(state = ENABLED)) {} diff --git a/app/build.gradle b/app/build.gradle index cd23d66cb5d4..6dfdeba02996 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -57,6 +57,12 @@ android { } else { buildConfigField "boolean", "FORCE_DEFAULT_VARIANT", "false" } + if (project.hasProperty('build-date-time')) { + buildConfigField "long", "BUILD_DATE_MILLIS", "${System.currentTimeMillis()}" + } else { + buildConfigField "long", "BUILD_DATE_MILLIS", "0" + } + namespace 'com.duckduckgo.app.browser' } buildFeatures { @@ -209,6 +215,8 @@ fladle { } dependencies { + implementation project(":malicious-site-protection-impl") + implementation project(":malicious-site-protection-api") implementation project(":custom-tabs-impl") implementation project(":custom-tabs-api") implementation project(":duckplayer-impl") @@ -384,9 +392,6 @@ dependencies { implementation project(':breakage-reporting-impl') - implementation project(":auth-jwt-api") - implementation project(":auth-jwt-impl") - // Deprecated. TODO: Stop using this artifact. implementation "androidx.legacy:legacy-support-v4:_" debugImplementation Square.leakCanary.android diff --git a/app/src/androidTest/java/com/duckduckgo/app/TestExtensions.kt b/app/src/androidTest/java/com/duckduckgo/app/TestExtensions.kt index afa35bfc7e8f..8e16d24d8d9d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/TestExtensions.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/TestExtensions.kt @@ -16,28 +16,9 @@ package com.duckduckgo.app -import androidx.annotation.UiThread -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.di.AppComponent import com.duckduckgo.app.global.DuckDuckGoApplication -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -// from https://stackoverflow.com/a/44991770/73479 -@UiThread -fun LiveData.blockingObserve(): T? { - var value: T? = null - val latch = CountDownLatch(1) - val innerObserver = Observer { - value = it - latch.countDown() - } - observeForever(innerObserver) - latch.await(2, TimeUnit.SECONDS) - return value -} fun getApp(): DuckDuckGoApplication { return InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as DuckDuckGoApplication 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 2bd56dd64447..238dbaff52d9 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -58,8 +58,6 @@ 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 @@ -77,6 +75,7 @@ import com.duckduckgo.app.browser.camera.CameraHardwareChecker 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.HideBrokenSitePromptCta import com.duckduckgo.app.browser.commands.Command.HideOnboardingDaxDialog import com.duckduckgo.app.browser.commands.Command.LaunchPrivacyPro import com.duckduckgo.app.browser.commands.Command.LoadExtractedUrl @@ -112,6 +111,7 @@ import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender import com.duckduckgo.app.browser.remotemessage.RemoteMessagingModel import com.duckduckgo.app.browser.session.WebViewSessionStorage +import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER import com.duckduckgo.app.browser.viewstate.BrowserViewState import com.duckduckgo.app.browser.viewstate.CtaViewState import com.duckduckgo.app.browser.viewstate.FindInPageViewState @@ -125,6 +125,7 @@ import com.duckduckgo.app.cta.model.CtaId.DAX_DIALOG_NETWORK import com.duckduckgo.app.cta.model.CtaId.DAX_DIALOG_TRACKERS_FOUND import com.duckduckgo.app.cta.model.CtaId.DAX_END import com.duckduckgo.app.cta.model.DismissedCta +import com.duckduckgo.app.cta.ui.BrokenSitePromptDialogCta import com.duckduckgo.app.cta.ui.Cta import com.duckduckgo.app.cta.ui.CtaViewModel import com.duckduckgo.app.cta.ui.DaxBubbleCta @@ -148,6 +149,7 @@ import com.duckduckgo.app.onboarding.store.AppStage.ESTABLISHED import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingFeatureToggles +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingPixelsPlugin import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.HighlightsOnboardingExperimentManager import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_BANNER_SHOWN @@ -173,6 +175,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelValues.DAX_FIRE_DIALOG_CT import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabStatsBucketing import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.app.trackerdetection.model.TrackerStatus import com.duckduckgo.app.trackerdetection.model.TrackerType @@ -185,6 +188,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor +import com.duckduckgo.brokensite.api.BrokenSitePrompt import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.browser.api.brokensite.BrokenSiteContext import com.duckduckgo.common.test.CoroutineTestRule @@ -209,6 +213,8 @@ import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.FeatureToggles import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.history.api.HistoryEntry.VisitedPage @@ -463,7 +469,10 @@ class BrowserTabViewModelTest { private val mockEnabledToggle: Toggle = mock { on { it.isEnabled() } doReturn true } - private val mockDisabledToggle: Toggle = mock { on { it.isEnabled() } doReturn false } + private val mockDisabledToggle: Toggle = mock { + on { it.isEnabled() } doReturn false + on { it.isEnabled(any()) } doReturn false + } private val mockPrivacyProtectionsPopupManager: PrivacyProtectionsPopupManager = mock() @@ -490,6 +499,11 @@ class BrowserTabViewModelTest { private var fakeAndroidConfigBrowserFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val mockAutocompleteTabsFeature: AutocompleteTabsFeature = mock() private val fakeCustomHeadersPlugin = FakeCustomHeadersProvider(emptyMap()) + private val mockBrokenSitePrompt: BrokenSitePrompt = mock() + private val mockTabStatsBucketing: TabStatsBucketing = mock() + private val extendedOnboardingFeatureToggles = FeatureToggles.Builder(FakeToggleStore(), featureName = "extendedOnboarding").build() + .create(ExtendedOnboardingFeatureToggles::class.java) + private val extendedOnboardingPixelsPlugin = ExtendedOnboardingPixelsPlugin(extendedOnboardingFeatureToggles) @Before fun before() = runTest { @@ -517,6 +531,7 @@ class BrowserTabViewModelTest { lazyFaviconManager, ) + whenever(mockExtendedOnboardingFeatureToggles.testPrivacyProOnboardingCopyNov24()).thenReturn(mockDisabledToggle) whenever(mockHighlightsOnboardingExperimentManager.isHighlightsEnabled()).thenReturn(false) whenever(mockDuckPlayer.observeUserPreferences()).thenReturn(flowOf(UserPreferences(false, Disabled))) whenever(mockDismissedCtaDao.dismissedCtas()).thenReturn(dismissedCtaDaoChannel.consumeAsFlow()) @@ -557,6 +572,9 @@ class BrowserTabViewModelTest { subscriptions = mock(), duckPlayer = mockDuckPlayer, highlightsOnboardingExperimentManager = mockHighlightsOnboardingExperimentManager, + brokenSitePrompt = mockBrokenSitePrompt, + extendedOnboardingPixelsPlugin = extendedOnboardingPixelsPlugin, + userBrowserProperties = mockUserBrowserProperties, ) val siteFactory = SiteFactoryImpl( @@ -655,6 +673,8 @@ class BrowserTabViewModelTest { privacyProtectionTogglePlugin = protectionTogglePluginPoint, showOnAppLaunchOptionHandler = mockShowOnAppLaunchHandler, customHeadersProvider = fakeCustomHeadersPlugin, + brokenSitePrompt = mockBrokenSitePrompt, + tabStatsBucketing = mockTabStatsBucketing, ) testee.loadData("abc", null, false, false) @@ -2425,6 +2445,13 @@ class BrowserTabViewModelTest { verify(mockTabRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) } + @Test + fun whenCloseAndSelectSourceTabSelectedThenTabDeletedFromRepository() = runTest { + givenOneActiveTabSelected() + testee.closeAndSelectSourceTab() + verify(mockTabRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) + } + @Test fun whenUserPressesBackAndSkippingHomeThenWebViewPreviewGenerated() { setupNavigation(isBrowsing = true, canGoBack = false, skipHome = true) @@ -2564,10 +2591,15 @@ class BrowserTabViewModelTest { @Test fun whenUserClickedLearnMoreExperimentBubbleCtaButtonThenLaunchPrivacyPro() { - val cta = DaxBubbleCta.DaxPrivacyProCta(mockOnboardingStore, mockAppInstallStore) + val cta = DaxBubbleCta.DaxPrivacyProCta( + mockOnboardingStore, + mockAppInstallStore, + R.string.onboardingPrivacyProDaxDialogTitle, + R.string.onboardingPrivacyProDaxDialogDescription, + ) setCta(cta) testee.onUserClickCtaOkButton(cta) - assertCommandIssued() + assertCommandIssued() } @Test @@ -3302,7 +3334,7 @@ class BrowserTabViewModelTest { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val command = commandCaptor.lastValue as Navigate - assertEquals(TEST_VALUE, command.headers[X_DUCKDUCKGO_ANDROID_HEADER]) + assertEquals("TEST_VALUE", command.headers[X_DUCKDUCKGO_ANDROID_HEADER]) } @Test @@ -4999,6 +5031,18 @@ class BrowserTabViewModelTest { } } + @Test + fun whenRefreshIsTriggeredByUserThenIncrementRefreshCount() = runTest { + val url = "http://example.com" + givenCurrentSite(url) + + testee.onRefreshRequested(triggeredByUser = false) + verify(mockBrokenSitePrompt, never()).pageRefreshed(any()) + + testee.onRefreshRequested(triggeredByUser = true) + verify(mockBrokenSitePrompt).pageRefreshed(url.toUri()) + } + @Test fun whenRefreshIsTriggeredByUserThenPrivacyProtectionsPopupManagerIsNotifiedWithTopPosition() = runTest { testee.onRefreshRequested(triggeredByUser = false) @@ -5311,6 +5355,26 @@ class BrowserTabViewModelTest { assertTrue(browserViewState().showPrivacyShield.isHighlighted()) } + @Test + fun givenTrackersBlockedCtaShownWhenLaunchingTabSwitcherThenCtaIsDismissed() = runTest { + val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) + testee.ctaViewState.value = ctaViewState().copy(cta = cta) + + testee.userLaunchingTabSwitcher() + + verify(mockDismissedCtaDao).insert(DismissedCta(cta.ctaId)) + } + + @Test + fun givenTrackersBlockedCtaShownWhenUserRequestOpeningNewTabThenCtaIsDismissed() = runTest { + val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) + testee.ctaViewState.value = ctaViewState().copy(cta = cta) + + testee.userRequestedOpeningNewTab() + + verify(mockDismissedCtaDao).insert(DismissedCta(cta.ctaId)) + } + @Test fun givenPrivacyShieldHighlightedWhenShieldIconSelectedThenStopPulse() = runTest { val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) @@ -5417,12 +5481,32 @@ class BrowserTabViewModelTest { } @Test - fun whenUserLaunchingTabSwitcherThenLaunchTabSwitcherCommandSentAndPixelFired() { + fun whenUserLaunchingTabSwitcherThenLaunchTabSwitcherCommandSentAndPixelFired() = runTest { + val tabCount = "61-80" + val active7d = "21+" + val inactive1w = "11-20" + val inactive2w = "6-10" + val inactive3w = "0" + + whenever(mockTabStatsBucketing.getNumberOfOpenTabs()).thenReturn(tabCount) + whenever(mockTabStatsBucketing.getTabsActiveLastWeek()).thenReturn(active7d) + whenever(mockTabStatsBucketing.getTabsActiveOneWeekAgo()).thenReturn(inactive1w) + whenever(mockTabStatsBucketing.getTabsActiveTwoWeeksAgo()).thenReturn(inactive2w) + whenever(mockTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo()).thenReturn(inactive3w) + + val params = mapOf( + PixelParameter.TAB_COUNT to tabCount, + PixelParameter.TAB_ACTIVE_7D to active7d, + PixelParameter.TAB_INACTIVE_1W to inactive1w, + PixelParameter.TAB_INACTIVE_2W to inactive2w, + PixelParameter.TAB_INACTIVE_3W to inactive3w, + ) + testee.userLaunchingTabSwitcher() assertCommandIssued() verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED) - verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, emptyMap(), emptyMap(), Daily()) + verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, params, emptyMap(), Daily()) } @Test @@ -5626,6 +5710,16 @@ class BrowserTabViewModelTest { assertTrue(browserViewState().showDuckPlayerIcon) } + @Test + fun whenNewPageAndBrokenSitePromptVisibleThenHideCta() = runTest { + setCta(BrokenSitePromptDialogCta()) + + testee.browserViewState.value = browserViewState().copy(browserShowing = true) + testee.navigationStateChanged(buildWebNavigation("https://example.com")) + + assertCommandIssued() + } + @Test fun whenUrlUpdatedWithUrlYouTubeNoCookieThenReplaceUrlWithDuckPlayer() = runTest { whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie("https://youtube-nocookie.com/?videoID=1234".toUri())).thenReturn(true) @@ -5772,7 +5866,7 @@ class BrowserTabViewModelTest { } private fun givenCustomHeadersProviderReturnsAndroidFeaturesHeader() { - fakeCustomHeadersPlugin.headers = mapOf(X_DUCKDUCKGO_ANDROID_HEADER to TEST_VALUE) + fakeCustomHeadersPlugin.headers = mapOf(X_DUCKDUCKGO_ANDROID_HEADER to "TEST_VALUE") } private suspend fun givenFireButtonPulsing() { @@ -6002,6 +6096,7 @@ class BrowserTabViewModelTest { override suspend fun onToggleOff(origin: PrivacyToggleOrigin) { toggleOff++ } + override suspend fun onToggleOn(origin: PrivacyToggleOrigin) { toggleOn++ } 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 aa51829145a6..c96b3fd19478 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -57,8 +57,10 @@ 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.trafficquality.AndroidFeaturesHeaderPlugin import com.duckduckgo.app.browser.uriloaded.UriLoadedManager import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autoconsent.api.Autoconsent import com.duckduckgo.autofill.api.BrowserAutofill @@ -148,7 +150,8 @@ class BrowserWebViewClientTest { private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private val openInNewTabFlow: MutableSharedFlow = MutableSharedFlow() private val mockUriLoadedManager: UriLoadedManager = mock() - private val mockAndroidFeaturesHeaderPlugin = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mock()) + private val mockAndroidBrowserConfigFeature: AndroidBrowserConfigFeature = mock() + private val mockAndroidFeaturesHeaderPlugin = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mockAndroidBrowserConfigFeature, mock()) @UiThreadTest @Before diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/BrowserTabFireproofDialogsEventHandlerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/BrowserTabFireproofDialogsEventHandlerTest.kt index 2272e2d50997..7a79e415790e 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/BrowserTabFireproofDialogsEventHandlerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/BrowserTabFireproofDialogsEventHandlerTest.kt @@ -19,7 +19,6 @@ package com.duckduckgo.app.browser.logindetection import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.blockingObserve import com.duckduckgo.app.browser.logindetection.FireproofDialogsEventHandler.Event import com.duckduckgo.app.browser.logindetection.FireproofDialogsEventHandler.Event.FireproofWebSiteSuccess import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao @@ -33,6 +32,7 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.blockingObserve import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertNull 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 05f60543c4fc..c254d2dd19ab 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 @@ -34,6 +34,8 @@ import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingFeatureToggles +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingFeatureToggles.Cohorts +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingPixelsPlugin import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.HighlightsOnboardingExperimentManager import com.duckduckgo.app.pixels.AppPixelName.* import com.duckduckgo.app.privacy.db.UserAllowListRepository @@ -45,11 +47,14 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.trackerdetection.blocklist.BlockList.Cohorts.CONTROL import com.duckduckgo.app.trackerdetection.model.Entity import com.duckduckgo.app.trackerdetection.model.TrackerStatus import com.duckduckgo.app.trackerdetection.model.TrackerType import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.widget.ui.WidgetCapabilities +import com.duckduckgo.brokensite.api.BrokenSitePrompt +import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.duckplayer.api.DuckPlayer @@ -57,7 +62,10 @@ import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.FeatureToggles import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort import com.duckduckgo.subscriptions.api.Subscriptions import java.util.concurrent.TimeUnit import kotlinx.coroutines.FlowPreview @@ -115,6 +123,10 @@ class CtaViewModelTest { private val mockHighlightsOnboardingExperimentManager: HighlightsOnboardingExperimentManager = mock() + private val mockBrokenSitePrompt: BrokenSitePrompt = mock() + + private val mockUserBrowserProperties: UserBrowserProperties = mock() + private val requiredDaxOnboardingCtas: List = listOf( CtaId.DAX_INTRO, CtaId.DAX_DIALOG_SERP, @@ -124,6 +136,10 @@ class CtaViewModelTest { CtaId.DAX_END, ) + private val extendedOnboardingFeatureToggles = FeatureToggles.Builder(FakeToggleStore(), featureName = "extendedOnboarding").build() + .create(ExtendedOnboardingFeatureToggles::class.java) + private val extendedOnboardingPixelsPlugin = ExtendedOnboardingPixelsPlugin(extendedOnboardingFeatureToggles) + private lateinit var testee: CtaViewModel val context: Context = InstrumentationRegistry.getInstrumentation().targetContext @@ -138,7 +154,7 @@ class CtaViewModelTest { val mockDisabledToggle: Toggle = mock { on { it.isEnabled() } doReturn false } whenever(mockExtendedOnboardingFeatureToggles.noBrowserCtas()).thenReturn(mockDisabledToggle) - whenever(mockExtendedOnboardingFeatureToggles.privacyProCta()).thenReturn(mockDisabledToggle) + whenever(mockExtendedOnboardingFeatureToggles.testPrivacyProOnboardingCopyNov24()).thenReturn(mockDisabledToggle) whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) whenever(mockUserAllowListRepository.isDomainInUserAllowList(any())).thenReturn(false) whenever(mockDismissedCtaDao.dismissedCtas()).thenReturn(db.dismissedCtaDao().dismissedCtas()) @@ -148,6 +164,9 @@ class CtaViewModelTest { whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(false, AlwaysAsk)) whenever(mockDuckPlayer.isYouTubeUrl(any())).thenReturn(false) whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) + whenever(mockBrokenSitePrompt.shouldShowBrokenSitePrompt(any())).thenReturn(false) + whenever(mockBrokenSitePrompt.isFeatureEnabled()).thenReturn(false) + whenever(mockSubscriptions.isEligible()).thenReturn(false) testee = CtaViewModel( appInstallStore = mockAppInstallStore, @@ -165,6 +184,9 @@ class CtaViewModelTest { subscriptions = mockSubscriptions, duckPlayer = mockDuckPlayer, highlightsOnboardingExperimentManager = mockHighlightsOnboardingExperimentManager, + brokenSitePrompt = mockBrokenSitePrompt, + extendedOnboardingPixelsPlugin = extendedOnboardingPixelsPlugin, + userBrowserProperties = mockUserBrowserProperties, ) } @@ -174,26 +196,38 @@ class CtaViewModelTest { } @Test - fun whenCtaShownAndCtaIsDaxAndCanNotSendPixelThenPixelIsNotFired() { + fun whenCtaShownAndCtaIsDaxAndCanNotSendPixelThenPixelIsNotFired() = runTest { testee.onCtaShown(DaxBubbleCta.DaxIntroSearchOptionsCta(mockOnboardingStore, mockAppInstallStore)) verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(Count)) } @Test - fun whenCtaShownAndCtaIsDaxAndCanSendPixelThenPixelIsFired() { + fun whenBrokenSitePromptDialogCtaIsShownThenPixelIsFired() = runTest { + testee.onCtaShown(BrokenSitePromptDialogCta()) + verify(mockPixel).fire(eq(SITE_NOT_WORKING_SHOWN), any(), any(), eq(Count)) + } + + @Test + fun whenUserClicksReportBrokenSiteThenPixelIsFired() = runTest { + testee.onUserClickCtaOkButton(BrokenSitePromptDialogCta()) + verify(mockPixel).fire(eq(SITE_NOT_WORKING_WEBSITE_BROKEN), any(), any(), eq(Count)) + } + + @Test + fun whenCtaShownAndCtaIsDaxAndCanSendPixelThenPixelIsFired() = runTest { whenever(mockOnboardingStore.onboardingDialogJourney).thenReturn("s:0") testee.onCtaShown(DaxBubbleCta.DaxEndCta(mockOnboardingStore, mockAppInstallStore)) verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(Count)) } @Test - fun whenCtaShownAndCtaIsNotDaxThenPixelIsFired() { + fun whenCtaShownAndCtaIsNotDaxThenPixelIsFired() = runTest { testee.onCtaShown(HomePanelCta.AddWidgetAuto) verify(mockPixel).fire(eq(WIDGET_CTA_SHOWN), any(), any(), eq(Count)) } @Test - fun whenCtaLaunchedPixelIsFired() { + fun whenCtaLaunchedPixelIsFired() = runTest { testee.onUserClickCtaOkButton(HomePanelCta.AddWidgetAuto) verify(mockPixel).fire(eq(WIDGET_CTA_LAUNCHED), any(), any(), eq(Count)) } @@ -275,6 +309,16 @@ class CtaViewModelTest { assertNull(value) } + @Test + fun whenRefreshCtaWhileBrowsingAndHideTipsIsTrueAndShouldShowBrokenSitePromptThenReturnBrokenSitePrompt() = runTest { + whenever(mockSettingsDataStore.hideTips).thenReturn(true) + whenever(mockBrokenSitePrompt.shouldShowBrokenSitePrompt(any())).thenReturn(true) + val site = site(url = "http://www.facebook.com", entity = TestEntity("Facebook", "Facebook", 9.0)) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) + assertTrue(value is BrokenSitePromptDialogCta) + } + @Test fun whenRefreshCtaOnHomeTabAndHideTipsIsTrueAndWidgetCompatibleThenReturnWidgetCta() = runTest { whenever(mockSettingsDataStore.hideTips).thenReturn(true) @@ -669,14 +713,14 @@ class CtaViewModelTest { } @Test - fun whenCtaShownIfCtaIsNotMarkedAsReadOnShowThenCtaNotInsertedInDatabase() { + fun whenCtaShownIfCtaIsNotMarkedAsReadOnShowThenCtaNotInsertedInDatabase() = runTest { testee.onCtaShown(OnboardingDaxDialogCta.DaxSerpCta(mockOnboardingStore, mockAppInstallStore)) verify(mockDismissedCtaDao, never()).insert(DismissedCta(CtaId.DAX_DIALOG_SERP)) } @Test - fun whenCtaShownIfCtaIsMarkedAsReadOnShowThenCtaInsertedInDatabase() { + fun whenCtaShownIfCtaIsMarkedAsReadOnShowThenCtaInsertedInDatabase() = runTest { testee.onCtaShown(OnboardingDaxDialogCta.DaxEndCta(mockOnboardingStore, mockAppInstallStore, mockSettingsDataStore)) verify(mockDismissedCtaDao).insert(DismissedCta(CtaId.DAX_END)) @@ -716,8 +760,10 @@ class CtaViewModelTest { @Test fun givenPrivacyProCtaExperimentWhenRefreshCtaOnHomeTabThenReturnPrivacyProCta() = runTest { givenDaxOnboardingActive() + whenever(mockSubscriptions.isEligible()).thenReturn(true) whenever(mockExtendedOnboardingFeatureToggles.noBrowserCtas()).thenReturn(mockEnabledToggle) whenever(mockExtendedOnboardingFeatureToggles.privacyProCta()).thenReturn(mockEnabledToggle) + whenever(mockExtendedOnboardingFeatureToggles.testPrivacyProOnboardingCopyNov24()).thenReturn(mockEnabledToggle) whenever(mockDismissedCtaDao.exists(CtaId.DAX_INTRO)).thenReturn(true) whenever(mockDismissedCtaDao.exists(CtaId.DAX_INTRO_VISIT_SITE)).thenReturn(true) whenever(mockDismissedCtaDao.exists(CtaId.DAX_END)).thenReturn(true) @@ -819,6 +865,19 @@ class CtaViewModelTest { assertTrue(value is OnboardingDaxDialogCta.DaxExperimentFireButtonCta) } + @Test + fun givenPrivacyProExperimentWhenControlCohortThenAppendItToOriginForPrivacyProSubscriptionURL() = runTest { + val controlCohort = Cohort(Cohorts.CONTROL.cohortName, 1) + whenever(mockExtendedOnboardingFeatureToggles.testPrivacyProOnboardingCopyNov24().getCohort()).thenReturn(controlCohort) + + assertEquals(testee.getCohortOrigin(), "_control") + } + + @Test + fun whenPrivacyProExperimentIsDisabledThenCohortIsNotAppendToPrivacyProSubscriptionURL() = runTest { + assertEquals(testee.getCohortOrigin(), "") + } + private suspend fun givenDaxOnboardingActive() { whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepositoryImplTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepositoryImplTest.kt index 7037fe355408..476d2645be22 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepositoryImplTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepositoryImplTest.kt @@ -19,10 +19,10 @@ package com.duckduckgo.app.fire.fireproofwebsite.data import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.blockingObserve import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.blockingObserve import dagger.Lazy import kotlinx.coroutines.test.runTest import org.junit.After diff --git a/app/src/androidTest/java/com/duckduckgo/app/location/data/LocationPermissionsRepositoryImplTest.kt b/app/src/androidTest/java/com/duckduckgo/app/location/data/LocationPermissionsRepositoryImplTest.kt index 2f2e66410a7c..e7b4d1d26665 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/location/data/LocationPermissionsRepositoryImplTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/location/data/LocationPermissionsRepositoryImplTest.kt @@ -19,10 +19,10 @@ package com.duckduckgo.app.location.data import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.blockingObserve import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.blockingObserve import dagger.Lazy import kotlinx.coroutines.test.runTest import org.junit.After diff --git a/app/src/androidTest/java/com/duckduckgo/app/privacy/db/NetworkLeaderboardDaoTest.kt b/app/src/androidTest/java/com/duckduckgo/app/privacy/db/NetworkLeaderboardDaoTest.kt index 2b4b52b20175..095daaf0c8d4 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/privacy/db/NetworkLeaderboardDaoTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/privacy/db/NetworkLeaderboardDaoTest.kt @@ -19,8 +19,8 @@ package com.duckduckgo.app.privacy.db import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.blockingObserve import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.common.test.blockingObserve import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/java/com/duckduckgo/app/privacy/db/UserAllowListDaoTest.kt b/app/src/androidTest/java/com/duckduckgo/app/privacy/db/UserAllowListDaoTest.kt index 77094232ab3a..9e2784942785 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/privacy/db/UserAllowListDaoTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/privacy/db/UserAllowListDaoTest.kt @@ -19,9 +19,9 @@ package com.duckduckgo.app.privacy.db import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.blockingObserve import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.blockingObserve import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After diff --git a/app/src/internal/AndroidManifest.xml b/app/src/internal/AndroidManifest.xml index b89dff89ff6f..82091edb2c82 100644 --- a/app/src/internal/AndroidManifest.xml +++ b/app/src/internal/AndroidManifest.xml @@ -7,6 +7,10 @@ android:name="com.duckduckgo.app.dev.settings.DevSettingsActivity" android:label="@string/devSettingsTitle" android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" /> + sendTdsIntent() - is Command.OpenUASelector -> showUASelector() - is Command.ShowSavedSitesClearedConfirmation -> showSavedSitesClearedConfirmation() - is Command.ChangePrivacyConfigUrl -> showChangePrivacyUrl() - is Command.CustomTabs -> showCustomTabs() - else -> TODO() + is SendTdsIntent -> sendTdsIntent() + is OpenUASelector -> showUASelector() + is ShowSavedSitesClearedConfirmation -> showSavedSitesClearedConfirmation() + is ChangePrivacyConfigUrl -> showChangePrivacyUrl() + is CustomTabs -> showCustomTabs() + Notifications -> showNotifications() } } @@ -167,6 +175,10 @@ class DevSettingsActivity : DuckDuckGoActivity() { startActivity(CustomTabsInternalSettingsActivity.intent(this), options) } + private fun showNotifications() { + startActivity(NotificationsActivity.intent(this)) + } + companion object { fun intent(context: Context): Intent { return Intent(context, DevSettingsActivity::class.java) diff --git a/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsViewModel.kt b/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsViewModel.kt index e4328b2ca6a3..bb08f10b8076 100644 --- a/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsViewModel.kt +++ b/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsViewModel.kt @@ -60,6 +60,7 @@ class DevSettingsViewModel @Inject constructor( object ShowSavedSitesClearedConfirmation : Command() object ChangePrivacyConfigUrl : Command() object CustomTabs : Command() + data object Notifications : Command() } private val viewState = MutableStateFlow(ViewState()) @@ -137,4 +138,8 @@ class DevSettingsViewModel @Inject constructor( command.send(Command.ShowSavedSitesClearedConfirmation) } } + + fun notificationsClicked() { + viewModelScope.launch { command.send(Command.Notifications) } + } } diff --git a/app/src/internal/java/com/duckduckgo/app/dev/settings/notifications/NotificationsActivity.kt b/app/src/internal/java/com/duckduckgo/app/dev/settings/notifications/NotificationsActivity.kt new file mode 100644 index 000000000000..04e8e32f8f8b --- /dev/null +++ b/app/src/internal/java/com/duckduckgo/app/dev/settings/notifications/NotificationsActivity.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.dev.settings.notifications + +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.databinding.ActivityNotificationsBinding +import com.duckduckgo.app.dev.settings.notifications.NotificationViewModel.Command.TriggerNotification +import com.duckduckgo.app.dev.settings.notifications.NotificationViewModel.ViewState +import com.duckduckgo.app.notification.NotificationFactory +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.view.listitem.TwoLineListItem +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify +import com.duckduckgo.di.scopes.ActivityScope +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@InjectWith(ActivityScope::class) +class NotificationsActivity : DuckDuckGoActivity() { + + @Inject + lateinit var viewModel: NotificationViewModel + + @Inject + lateinit var factory: NotificationFactory + + private val binding: ActivityNotificationsBinding by viewBinding() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setupToolbar(binding.includeToolbar.toolbar) + + observeViewState() + observeCommands() + } + + private fun observeViewState() { + viewModel.viewState.flowWithLifecycle(lifecycle, STARTED).onEach { render(it) } + .launchIn(lifecycleScope) + } + + private fun observeCommands() { + viewModel.command.flowWithLifecycle(lifecycle, STARTED).onEach { command -> + when (command) { + is TriggerNotification -> addNotification(id = command.notificationItem.id, notification = command.notificationItem.notification) + } + }.launchIn(lifecycleScope) + } + + private fun render(viewState: ViewState) { + viewState.scheduledNotifications.forEach { notificationItem -> + buildNotificationItem( + title = notificationItem.title, + subtitle = notificationItem.subtitle, + onClick = { viewModel.onNotificationItemClick(notificationItem) }, + ).also { + binding.scheduledNotificationsContainer.addView(it) + } + } + + viewState.vpnNotifications.forEach { notificationItem -> + buildNotificationItem( + title = notificationItem.title, + subtitle = notificationItem.subtitle, + onClick = { viewModel.onNotificationItemClick(notificationItem) }, + ).also { + binding.vpnNotificationsContainer.addView(it) + } + } + } + + private fun buildNotificationItem( + title: String, + subtitle: String, + onClick: () -> Unit, + ): TwoLineListItem { + return TwoLineListItem(this).apply { + setPrimaryText(title) + setSecondaryText(subtitle) + setOnClickListener { onClick() } + } + } + + private fun addNotification( + id: Int, + notification: Notification, + ) { + NotificationManagerCompat.from(this) + .checkPermissionAndNotify(context = this, id = id, notification = notification) + } + + companion object { + + fun intent(context: Context): Intent { + return Intent(context, NotificationsActivity::class.java) + } + } +} diff --git a/app/src/internal/java/com/duckduckgo/app/dev/settings/notifications/NotificationsViewModel.kt b/app/src/internal/java/com/duckduckgo/app/dev/settings/notifications/NotificationsViewModel.kt new file mode 100644 index 000000000000..ea7fa2e67062 --- /dev/null +++ b/app/src/internal/java/com/duckduckgo/app/dev/settings/notifications/NotificationsViewModel.kt @@ -0,0 +1,132 @@ +/* + * 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.dev.settings.notifications + +import android.app.Notification +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.dev.settings.notifications.NotificationViewModel.ViewState.NotificationItem +import com.duckduckgo.app.notification.NotificationFactory +import com.duckduckgo.app.notification.model.SchedulableNotificationPlugin +import com.duckduckgo.app.survey.api.SurveyRepository +import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.survey.model.Survey.Status.SCHEDULED +import com.duckduckgo.app.survey.notification.SurveyAvailableNotification +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.networkprotection.impl.notification.NetPDisabledNotificationBuilder +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.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@ContributesViewModel(ActivityScope::class) +class NotificationViewModel @Inject constructor( + private val applicationContext: Context, + private val dispatcher: DispatcherProvider, + private val schedulableNotificationPluginPoint: PluginPoint, + private val factory: NotificationFactory, + private val surveyRepository: SurveyRepository, + private val netPDisabledNotificationBuilder: NetPDisabledNotificationBuilder, +) : ViewModel() { + + data class ViewState( + val scheduledNotifications: List = emptyList(), + val vpnNotifications: List = emptyList(), + ) { + + data class NotificationItem( + val id: Int, + val title: String, + val subtitle: String, + val notification: Notification, + ) + } + + sealed class Command { + data class TriggerNotification(val notificationItem: NotificationItem) : Command() + } + + private val _viewState = MutableStateFlow(ViewState()) + val viewState = _viewState.asStateFlow() + + private val _command = Channel(1, BufferOverflow.DROP_OLDEST) + val command = _command.receiveAsFlow() + + init { + viewModelScope.launch { + val scheduledNotificationItems = schedulableNotificationPluginPoint.getPlugins().map { plugin -> + + // The survey notification will crash if we do not have a survey in the database + if (plugin.getSchedulableNotification().javaClass == SurveyAvailableNotification::class.java) { + withContext(dispatcher.io()) { + addTestSurvey() + } + } + + // the survey intent hits the DB, so we need to do this on IO + val launchIntent = withContext(dispatcher.io()) { plugin.getLaunchIntent() } + + NotificationItem( + id = plugin.getSpecification().systemId, + title = plugin.getSpecification().title, + subtitle = plugin.getSpecification().description, + notification = factory.createNotification(plugin.getSpecification(), launchIntent, null), + ) + } + + val netPDisabledNotificationItem = NotificationItem( + id = 0, + title = "NetP Disabled", + subtitle = "NetP is disabled", + notification = netPDisabledNotificationBuilder.buildVpnAccessRevokedNotification(applicationContext), + ) + + _viewState.update { + it.copy( + scheduledNotifications = scheduledNotificationItems, + vpnNotifications = listOf(netPDisabledNotificationItem), + ) + } + } + } + + private fun addTestSurvey() { + surveyRepository.persistSurvey( + Survey( + "testSurveyId", + "https://youtu.be/dQw4w9WgXcQ?si=iztopgFbXoWUnoOE", + daysInstalled = 1, + status = SCHEDULED, + ), + ) + } + + fun onNotificationItemClick(notificationItem: NotificationItem) { + viewModelScope.launch { + _command.send(Command.TriggerNotification(notificationItem)) + } + } +} diff --git a/app/src/internal/res/layout/activity_dev_settings.xml b/app/src/internal/res/layout/activity_dev_settings.xml index bc48624014ef..52027f50670b 100644 --- a/app/src/internal/res/layout/activity_dev_settings.xml +++ b/app/src/internal/res/layout/activity_dev_settings.xml @@ -88,6 +88,13 @@ app:secondaryText="@string/devSettingsScreenCustomTabsSubtitle" /> + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/internal/res/values/donottranslate.xml b/app/src/internal/res/values/donottranslate.xml index 88938dcba1e3..8f0c14a1338a 100644 --- a/app/src/internal/res/values/donottranslate.xml +++ b/app/src/internal/res/values/donottranslate.xml @@ -38,6 +38,8 @@ Override UserAgent Override Privacy Remote Config URL Custom Tabs + Notifications + Trigger notifications for testing Click here to customize the Privacy Remote Config URL Load a Custom Tab for a specified URL UserAgent @@ -87,4 +89,8 @@ Enter a URL Default Browser + + Scheduled Notifications + VPN Notifications + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 534d53d49c85..86b74a6e7283 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -325,6 +325,24 @@ + + + + + + + + + + .setProtectionsState(state: SiteProtection private fun ReportFlow.mapToBrokenSiteModelReportFlow(): BrokenSiteModelReportFlow = when (this) { MENU -> BrokenSiteModelReportFlow.MENU DASHBOARD -> BrokenSiteModelReportFlow.DASHBOARD + PROMPT -> BrokenSiteModelReportFlow.PROMPT } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt index 3fc40120dbcf..56f96a490403 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt @@ -32,6 +32,7 @@ import com.duckduckgo.brokensite.api.BrokenSiteSender import com.duckduckgo.brokensite.api.ReportFlow import com.duckduckgo.brokensite.api.ReportFlow.DASHBOARD import com.duckduckgo.brokensite.api.ReportFlow.MENU +import com.duckduckgo.brokensite.api.ReportFlow.PROMPT import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.absoluteString @@ -220,4 +221,5 @@ class BrokenSiteSubmitter @Inject constructor( private fun ReportFlow.toStringValue(): String = when (this) { DASHBOARD -> "dashboard" MENU -> "menu" + PROMPT -> "prompt" } 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 f9a1c5199cbf..200ec9a02c5e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -272,6 +272,8 @@ open class BrowserActivity : DuckDuckGoActivity() { lastActiveTabs.add(tab.tabId) + viewModel.onTabActivated(tab.tabId) + val fragment = supportFragmentManager.findFragmentByTag(tab.tabId) as? BrowserTabFragment if (fragment == null) { openNewTab(tab.tabId, tab.url, tab.skipHome, intent?.getBooleanExtra(LAUNCH_FROM_EXTERNAL_EXTRA, false) ?: false) 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 73ba576b7c78..8118b1449597 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -166,6 +166,7 @@ import com.duckduckgo.app.browser.webshare.WebShareChooser import com.duckduckgo.app.browser.webview.WebContentDebugging import com.duckduckgo.app.browser.webview.WebViewBlobDownloadFeature import com.duckduckgo.app.browser.webview.safewebview.SafeWebViewFeature +import com.duckduckgo.app.cta.ui.BrokenSitePromptDialogCta import com.duckduckgo.app.cta.ui.Cta import com.duckduckgo.app.cta.ui.CtaViewModel import com.duckduckgo.app.cta.ui.DaxBubbleCta @@ -224,6 +225,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.api.emailprotection.EmailInjector import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.browser.api.brokensite.BrokenSiteData +import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.PROMPT import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.store.BrowserAppTheme import com.duckduckgo.common.ui.view.DaxDialog @@ -271,6 +273,7 @@ import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.navigation.api.GlobalActivityStarter.DeeplinkActivityParams import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreenParams import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreenParams.BrokenSiteForm +import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreenParams.BrokenSiteForm.BrokenSiteFormReportFlow import com.duckduckgo.privacy.dashboard.api.ui.WebBrokenSiteForm import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopup import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupFactory @@ -1706,6 +1709,7 @@ class BrowserTabFragment : is Command.HideSSLError -> hideSSLWarning() is Command.LaunchScreen -> launchScreen(it.screen, it.payload) is Command.HideOnboardingDaxDialog -> hideOnboardingDaxDialog(it.onboardingCta) + is Command.HideBrokenSitePromptCta -> hideBrokenSitePromptCta(it.brokenSitePromptDialogCta) is Command.ShowRemoveSearchSuggestionDialog -> showRemoveSearchSuggestionDialog(it.suggestion) is Command.AutocompleteItemRemoved -> autocompleteItemRemoved() is Command.OpenDuckPlayerSettings -> globalActivityStarter.start(binding.root.context, DuckPlayerSettingsNoParams) @@ -1904,7 +1908,11 @@ class BrowserTabFragment : val context = context ?: return if (webBrokenSiteForm.shouldUseWebBrokenSiteForm()) { - globalActivityStarter.startIntent(context, BrokenSiteForm(tabId)) + val reportFlow = when (data.reportFlow) { + PROMPT -> BrokenSiteFormReportFlow.PROMPT + else -> BrokenSiteFormReportFlow.MENU + } + globalActivityStarter.startIntent(context, BrokenSiteForm(tabId, reportFlow)) ?.let { startActivity(it) } } else { val options = ActivityOptions.makeSceneTransitionAnimation(browserActivity).toBundle() @@ -2578,6 +2586,10 @@ class BrowserTabFragment : onboardingCta.hideOnboardingCta(binding) } + private fun hideBrokenSitePromptCta(brokenSitePromptDialogCta: BrokenSitePromptDialogCta) { + brokenSitePromptDialogCta.hideOnboardingCta(binding) + } + private fun hideDaxBubbleCta() { newBrowserTab.browserBackground.setImageResource(0) daxDialogIntroBubbleCta.root.gone() @@ -3847,10 +3859,11 @@ class BrowserTabFragment : when (configuration) { is HomePanelCta -> showHomeCta(configuration) is DaxBubbleCta.DaxExperimentIntroSearchOptionsCta, is DaxBubbleCta.DaxExperimentIntroVisitSiteOptionsCta, - is DaxBubbleCta.DaxExperimentEndCta, + is DaxBubbleCta.DaxExperimentEndCta, is DaxBubbleCta.DaxExperimentPrivacyProCta, -> showDaxExperimentOnboardingBubbleCta(configuration as DaxBubbleCta) is DaxBubbleCta -> showDaxOnboardingBubbleCta(configuration) is OnboardingDaxDialogCta -> showOnboardingDialogCta(configuration) + is BrokenSitePromptDialogCta -> showBrokenSitePromptCta(configuration) } } @@ -3922,6 +3935,17 @@ class BrowserTabFragment : viewModel.onCtaShown() } + @SuppressLint("ClickableViewAccessibility") + private fun showBrokenSitePromptCta(configuration: BrokenSitePromptDialogCta) { + hideNewTab() + configuration.showBrokenSitePromptCta( + binding, + onReportBrokenSiteClicked = { viewModel.onUserClickCtaOkButton(configuration) }, + onDismissCtaClicked = { viewModel.onUserClickCtaSecondaryButton(configuration) }, + onCtaShown = { viewModel.onCtaShown() }, + ) + } + private fun showHomeCta( configuration: HomePanelCta, ) { 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 bf77ed639b95..d4851400f5d1 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -98,6 +98,7 @@ import com.duckduckgo.app.browser.commands.Command.ExtractUrlFromCloakedAmpLink import com.duckduckgo.app.browser.commands.Command.FindInPageCommand import com.duckduckgo.app.browser.commands.Command.GenerateWebViewPreviewImage import com.duckduckgo.app.browser.commands.Command.HandleNonHttpAppLink +import com.duckduckgo.app.browser.commands.Command.HideBrokenSitePromptCta import com.duckduckgo.app.browser.commands.Command.HideKeyboard import com.duckduckgo.app.browser.commands.Command.HideOnboardingDaxDialog import com.duckduckgo.app.browser.commands.Command.HideSSLError @@ -198,6 +199,7 @@ import com.duckduckgo.app.browser.viewstate.OmnibarViewState import com.duckduckgo.app.browser.viewstate.PrivacyShieldViewState import com.duckduckgo.app.browser.viewstate.SavedSiteChangedViewState import com.duckduckgo.app.browser.webview.SslWarningLayout.Action +import com.duckduckgo.app.cta.ui.BrokenSitePromptDialogCta import com.duckduckgo.app.cta.ui.Cta import com.duckduckgo.app.cta.ui.CtaViewModel import com.duckduckgo.app.cta.ui.DaxBubbleCta @@ -230,6 +232,7 @@ import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_SEARCH_WEBSITE_SELECT import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_DAX_CTA_CANCEL_BUTTON import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_SEARCH_CUSTOM import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_VISIT_SITE_CUSTOM +import com.duckduckgo.app.pixels.AppPixelName.TAB_MANAGER_CLICKED_DAILY import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.db.UserAllowListRepository @@ -244,6 +247,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelValues.DAX_FIRE_DIALOG_CT import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabStatsBucketing import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.autofill.api.AutofillCapabilityChecker @@ -251,9 +255,11 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor +import com.duckduckgo.brokensite.api.BrokenSitePrompt import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU +import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.PROMPT import com.duckduckgo.common.utils.AppUrl import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider @@ -340,6 +346,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -425,6 +432,8 @@ class BrowserTabViewModel @Inject constructor( private val privacyProtectionTogglePlugin: PluginPoint, private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, private val customHeadersProvider: CustomHeadersProvider, + private val brokenSitePrompt: BrokenSitePrompt, + private val tabStatsBucketing: TabStatsBucketing, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -820,6 +829,11 @@ class BrowserTabViewModel @Inject constructor( } fun onViewHidden() { + ctaViewState.value?.cta.let { + if (it is BrokenSitePromptDialogCta) { + command.value = HideBrokenSitePromptCta(it) + } + } skipHome = false viewModelScope.launch { downloadCallback @@ -985,6 +999,12 @@ class BrowserTabViewModel @Inject constructor( pixel.fire(ONBOARDING_VISIT_SITE_CUSTOM, type = Unique()) } } + + is BrokenSitePromptDialogCta -> { + viewModelScope.launch(dispatchers.main()) { + command.value = HideBrokenSitePromptCta(currentCtaViewState().cta as BrokenSitePromptDialogCta) + } + } } command.value = HideKeyboard @@ -1159,7 +1179,9 @@ class BrowserTabViewModel @Inject constructor( override fun isDesktopSiteEnabled(): Boolean = currentBrowserViewState().isDesktopBrowsingMode override fun closeCurrentTab() { - viewModelScope.launch { removeCurrentTabFromRepository() } + viewModelScope.launch { + removeCurrentTabFromRepository() + } } fun closeAndReturnToSourceIfBlankTab() { @@ -1169,7 +1191,9 @@ class BrowserTabViewModel @Inject constructor( } override fun closeAndSelectSourceTab() { - viewModelScope.launch { removeAndSelectTabFromRepository() } + viewModelScope.launch { + removeAndSelectTabFromRepository() + } } private suspend fun removeAndSelectTabFromRepository() { @@ -1200,6 +1224,9 @@ class BrowserTabViewModel @Inject constructor( if (triggeredByUser) { site?.realBrokenSiteContext?.onUserTriggeredRefresh() + site?.uri?.let { + brokenSitePrompt.pageRefreshed(it) + } privacyProtectionsPopupManager.onPageRefreshTriggeredByUser(isOmnibarAtTheTop = settingsDataStore.omnibarPosition == TOP) } } @@ -1353,6 +1380,13 @@ class BrowserTabViewModel @Inject constructor( pageChanged(stateChange.url, stateChange.title) } } + ctaViewState.value?.cta?.let { cta -> + if (cta is BrokenSitePromptDialogCta) { + withContext(dispatchers.main()) { + command.value = HideBrokenSitePromptCta(cta) + } + } + } } } @@ -1372,6 +1406,11 @@ class BrowserTabViewModel @Inject constructor( } } } + ctaViewState.value?.cta?.let { cta -> + if (cta is BrokenSitePromptDialogCta) { + command.value = HideBrokenSitePromptCta(cta) + } + } } is WebNavigationStateChange.PageNavigationCleared -> disableUserNavigation() @@ -2583,6 +2622,7 @@ class BrowserTabViewModel @Inject constructor( if (longPress) { pixel.fire(AppPixelName.TAB_MANAGER_NEW_TAB_LONG_PRESSED) } + onUserDismissedCta(ctaViewState.value?.cta) } fun onCtaShown() { @@ -2619,7 +2659,7 @@ class BrowserTabViewModel @Inject constructor( } private fun showOrHideKeyboard(cta: Cta?) { - val shouldHideKeyboard = cta is HomePanelCta || cta is DaxBubbleCta.DaxPrivacyProCta + val shouldHideKeyboard = cta is HomePanelCta || cta is DaxBubbleCta.DaxPrivacyProCta || cta is DaxBubbleCta.DaxExperimentPrivacyProCta command.value = if (shouldHideKeyboard) HideKeyboard else ShowKeyboard } @@ -2632,11 +2672,14 @@ class BrowserTabViewModel @Inject constructor( } fun onUserClickCtaOkButton(cta: Cta) { - ctaViewModel.onUserClickCtaOkButton(cta) + viewModelScope.launch { + ctaViewModel.onUserClickCtaOkButton(cta) + } val onboardingCommand = when (cta) { is HomePanelCta.AddWidgetAuto, is HomePanelCta.AddWidgetInstructions -> LaunchAddWidget is OnboardingDaxDialogCta -> onOnboardingCtaOkButtonClicked(cta) is DaxBubbleCta -> onDaxBubbleCtaOkButtonClicked(cta) + is BrokenSitePromptDialogCta -> onBrokenSiteCtaOkButtonClicked(cta) else -> null } onboardingCommand?.let { @@ -2647,9 +2690,12 @@ class BrowserTabViewModel @Inject constructor( fun onUserClickCtaSecondaryButton(cta: Cta) { viewModelScope.launch { ctaViewModel.onUserDismissedCta(cta) - if (cta is DaxBubbleCta.DaxPrivacyProCta) { + ctaViewModel.onUserClickCtaSkipButton(cta) + if (cta is DaxBubbleCta.DaxPrivacyProCta || cta is DaxBubbleCta.DaxExperimentPrivacyProCta) { val updatedCta = refreshCta() ctaViewState.value = currentCtaViewState().copy(cta = updatedCta) + } else if (cta is BrokenSitePromptDialogCta) { + onBrokenSiteCtaDismissButtonClicked(cta) } if (cta is OnboardingDaxDialogCta.DaxExperimentFireButtonCta) { pixel.fire(ONBOARDING_DAX_CTA_CANCEL_BUTTON, mapOf(PixelParameter.CTA_SHOWN to DAX_FIRE_DIALOG_CTA)) @@ -2659,9 +2705,11 @@ class BrowserTabViewModel @Inject constructor( } } - fun onUserDismissedCta(cta: Cta) { - viewModelScope.launch { - ctaViewModel.onUserDismissedCta(cta) + fun onUserDismissedCta(cta: Cta?) { + cta?.let { + viewModelScope.launch { + ctaViewModel.onUserDismissedCta(it) + } } } @@ -2786,7 +2834,27 @@ class BrowserTabViewModel @Inject constructor( fun userLaunchingTabSwitcher() { command.value = LaunchTabSwitcher pixel.fire(AppPixelName.TAB_MANAGER_CLICKED) - pixel.fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, emptyMap(), emptyMap(), Daily()) + fireDailyLaunchPixel() + onUserDismissedCta(ctaViewState.value?.cta) + } + + private fun fireDailyLaunchPixel() { + val tabCount = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getNumberOfOpenTabs() } + val activeTabCount = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveLastWeek() } + val inactive1w = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveOneWeekAgo() } + val inactive2w = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveTwoWeeksAgo() } + val inactive3w = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() } + + viewModelScope.launch(dispatchers.io()) { + val params = mapOf( + PixelParameter.TAB_COUNT to tabCount.await(), + PixelParameter.TAB_ACTIVE_7D to activeTabCount.await(), + PixelParameter.TAB_INACTIVE_1W to inactive1w.await(), + PixelParameter.TAB_INACTIVE_2W to inactive2w.await(), + PixelParameter.TAB_INACTIVE_3W to inactive3w.await(), + ) + pixel.fire(TAB_MANAGER_CLICKED_DAILY, params, emptyMap(), Daily()) + } } private fun isFireproofWebsite(domain: String? = site?.domain): Boolean { @@ -3390,6 +3458,21 @@ class BrowserTabViewModel @Inject constructor( } } + private fun onBrokenSiteCtaDismissButtonClicked(cta: BrokenSitePromptDialogCta): Command? { + viewModelScope.launch { + command.value = HideBrokenSitePromptCta(cta) + } + return null + } + + private fun onBrokenSiteCtaOkButtonClicked(cta: BrokenSitePromptDialogCta): Command? { + viewModelScope.launch { + command.value = BrokenSiteFeedback(BrokenSiteData.fromSite(site, reportFlow = PROMPT)) + command.value = HideBrokenSitePromptCta(cta) + } + return null + } + private fun onOnboardingCtaOkButtonClicked(onboardingCta: OnboardingDaxDialogCta): Command? { onUserDismissedCta(onboardingCta) return when (onboardingCta) { @@ -3433,7 +3516,12 @@ class BrowserTabViewModel @Inject constructor( private fun onDaxBubbleCtaOkButtonClicked(cta: DaxBubbleCta): Command? { onUserDismissedCta(cta) return when (cta) { - is DaxBubbleCta.DaxPrivacyProCta -> LaunchPrivacyPro("https://duckduckgo.com/pro?origin=funnel_pro_android_onboarding".toUri()) + is DaxBubbleCta.DaxPrivacyProCta, is DaxBubbleCta.DaxExperimentPrivacyProCta -> { + val cohortOrigin = ctaViewModel.getCohortOrigin() + LaunchPrivacyPro( + "https://duckduckgo.com/pro?origin=funnel_pro_android_onboarding$cohortOrigin".toUri(), + ) + } is DaxBubbleCta.DaxEndCta, is DaxBubbleCta.DaxExperimentEndCta -> { viewModelScope.launch { val updatedCta = refreshCta() @@ -3657,8 +3745,10 @@ class BrowserTabViewModel @Inject constructor( return when { lightModeEnabled && highlightsOnboardingExperimentManager.isHighlightsEnabled() -> R.drawable.onboarding_experiment_background_bitmap_light + !lightModeEnabled && highlightsOnboardingExperimentManager.isHighlightsEnabled() -> R.drawable.onboarding_experiment_background_bitmap_dark + lightModeEnabled -> R.drawable.onboarding_background_bitmap_light else -> R.drawable.onboarding_background_bitmap_dark } 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 fcf92bc0d38f..b21bb639abd3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -303,6 +303,12 @@ class BrowserViewModel @Inject constructor( } } } + + fun onTabActivated(tabId: String) { + viewModelScope.launch(dispatchers.io()) { + tabRepository.updateTabLastAccess(tabId) + } + } } /** 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 717ee910bb86..de04c2b54cc9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -58,6 +58,7 @@ 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.trafficquality.AndroidFeaturesHeaderPlugin import com.duckduckgo.app.browser.uriloaded.UriLoadedManager import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel diff --git a/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt b/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt index 0e4e74e89521..4d2314a377fd 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt @@ -24,12 +24,12 @@ import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.extensions.compareSemanticVersion -import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import kotlinx.coroutines.withContext -@ContributesBinding(FragmentScope::class) +@ContributesBinding(AppScope::class) class RealWebViewCapabilityChecker @Inject constructor( private val dispatchers: DispatcherProvider, private val webViewVersionProvider: WebViewVersionProvider, 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 6453bec1e34a..074cf95b471a 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 @@ -35,6 +35,7 @@ import com.duckduckgo.app.browser.history.NavigationHistoryEntry import com.duckduckgo.app.browser.model.BasicAuthenticationCredentials import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.viewstate.SavedSiteChangedViewState +import com.duckduckgo.app.cta.ui.BrokenSitePromptDialogCta import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.autofill.api.domain.app.LoginCredentials @@ -229,6 +230,7 @@ sealed class Command { val payload: String, ) : Command() data class HideOnboardingDaxDialog(val onboardingCta: OnboardingDaxDialogCta) : Command() + data class HideBrokenSitePromptCta(val brokenSitePromptDialogCta: BrokenSitePromptDialogCta) : Command() data class ShowRemoveSearchSuggestionDialog(val suggestion: AutoCompleteSuggestion) : Command() data object AutocompleteItemRemoved : Command() object OpenDuckPlayerSettings : Command() diff --git a/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidAppVersionPixelSender.kt similarity index 53% rename from app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSender.kt rename to app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidAppVersionPixelSender.kt index 30c8af2f3085..87a9cddd9d2b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSender.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidAppVersionPixelSender.kt @@ -20,23 +20,16 @@ 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, +class AndroidAppVersionPixelSender @Inject constructor( + private val appVersionProvider: QualityAppVersionProvider, private val pixel: Pixel, @AppCoroutineScope private val coroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, @@ -45,18 +38,12 @@ class AndroidFeaturesPixelSender @Inject constructor( 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) + params[PARAM_APP_VERSION] = appVersionProvider.provide() + pixel.fire(AppPixelName.APP_VERSION_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" + internal const val PARAM_APP_VERSION = "app_version" } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/AndroidFeaturesHeader.kt b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesHeader.kt similarity index 78% rename from app/src/main/java/com/duckduckgo/app/browser/AndroidFeaturesHeader.kt rename to app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesHeader.kt index 1f080123e6fc..2652d2e7b078 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/AndroidFeaturesHeader.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesHeader.kt @@ -14,8 +14,10 @@ * limitations under the License. */ -package com.duckduckgo.app.browser +package com.duckduckgo.app.browser.trafficquality +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.app.browser.trafficquality.remote.AndroidFeaturesHeaderProvider import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider.CustomHeadersPlugin import com.duckduckgo.di.scopes.AppScope @@ -26,6 +28,7 @@ import javax.inject.Inject class AndroidFeaturesHeaderPlugin @Inject constructor( private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, + private val androidFeaturesHeaderProvider: AndroidFeaturesHeaderProvider, ) : CustomHeadersPlugin { override fun getHeaders(url: String): Map { @@ -33,15 +36,14 @@ class AndroidFeaturesHeaderPlugin @Inject constructor( androidBrowserConfigFeature.featuresRequestHeader().isEnabled() && duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url) ) { - return mapOf( - X_DUCKDUCKGO_ANDROID_HEADER to TEST_VALUE, - ) + androidFeaturesHeaderProvider.provide()?.let { headerValue -> + return mapOf(X_DUCKDUCKGO_ANDROID_HEADER to headerValue) + } } 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/trafficquality/QualityAppVersionProvider.kt b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/QualityAppVersionProvider.kt new file mode 100644 index 000000000000..f77772f83197 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/QualityAppVersionProvider.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.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +interface QualityAppVersionProvider { + fun provide(): String +} + +@ContributesBinding(AppScope::class) +class RealQualityAppVersionProvider @Inject constructor( + private val appBuildConfig: AppBuildConfig, +) : QualityAppVersionProvider { + override fun provide(): String { + val appBuildDateMillis = appBuildConfig.buildDateTimeMillis + + if (appBuildDateMillis == 0L) { + return APP_VERSION_QUALITY_DEFAULT_VALUE + } + + val appBuildDate = LocalDateTime.ofEpochSecond(appBuildDateMillis / 1000, 0, ZoneOffset.UTC) + val now = LocalDateTime.now(ZoneOffset.UTC) + val daysSinceBuild = ChronoUnit.DAYS.between(appBuildDate, now) + + if (daysSinceBuild < DAYS_AFTER_APP_BUILD_WITH_DEFAULT_VALUE) { + return APP_VERSION_QUALITY_DEFAULT_VALUE + } + + if (daysSinceBuild > DAYS_FOR_APP_VERSION_LOGGING) { + return APP_VERSION_QUALITY_DEFAULT_VALUE + } + + return appBuildConfig.versionName + } + + companion object { + const val APP_VERSION_QUALITY_DEFAULT_VALUE = "other_versions" + const val DAYS_AFTER_APP_BUILD_WITH_DEFAULT_VALUE = 6 + const val DAYS_FOR_APP_VERSION_LOGGING = DAYS_AFTER_APP_BUILD_WITH_DEFAULT_VALUE + 10 + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/trafficquality/remote/AndroidFeaturesHeaderProvider.kt b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/remote/AndroidFeaturesHeaderProvider.kt new file mode 100644 index 000000000000..e9a6e4fec526 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/trafficquality/remote/AndroidFeaturesHeaderProvider.kt @@ -0,0 +1,127 @@ +/* + * 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.remote + +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autoconsent.api.Autoconsent +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.ContributesBinding +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import kotlinx.coroutines.runBlocking + +interface AndroidFeaturesHeaderProvider { + fun provide(): String? +} + +@ContributesBinding(AppScope::class) +class RealAndroidFeaturesHeaderProvider @Inject constructor( + private val appBuildConfig: AppBuildConfig, + private val featuresRequestHeaderStore: FeaturesRequestHeaderStore, + private val autoconsent: Autoconsent, + private val gpc: Gpc, + private val appTrackingProtection: AppTrackingProtection, + private val networkProtectionState: NetworkProtectionState, +) : AndroidFeaturesHeaderProvider { + + override fun provide(): String? { + val config = featuresRequestHeaderStore.getConfig() + val versionConfig = config.find { it.appVersion == appBuildConfig.versionCode } + return if (versionConfig != null && shouldLogValue(versionConfig)) { + logFeature(versionConfig) + } else { + null + } + } + + private fun shouldLogValue(versionConfig: TrafficQualityAppVersion): Boolean { + val appBuildDateMillis = appBuildConfig.buildDateTimeMillis + if (appBuildDateMillis == 0L) { + return false + } + + val appBuildDate = LocalDateTime.ofEpochSecond(appBuildDateMillis / 1000, 0, ZoneOffset.UTC) + val now = LocalDateTime.now(ZoneOffset.UTC) + + val daysSinceBuild = ChronoUnit.DAYS.between(appBuildDate, now) + val daysUntilLoggingStarts = versionConfig.daysUntilLoggingStarts + val daysForAppVersionLogging = versionConfig.daysUntilLoggingStarts + versionConfig.daysLogging + + return daysSinceBuild in daysUntilLoggingStarts..daysForAppVersionLogging + } + + private fun logFeature(versionConfig: TrafficQualityAppVersion): String? { + val listOfFeatures = mutableListOf() + if (versionConfig.featuresLogged.cpm) { + listOfFeatures.add(CPM_HEADER) + } + if (versionConfig.featuresLogged.gpc) { + listOfFeatures.add(GPC_HEADER) + } + + if (versionConfig.featuresLogged.appTP) { + listOfFeatures.add(APP_TP_HEADER) + } + + if (versionConfig.featuresLogged.netP) { + listOfFeatures.add(NET_P_HEADER) + } + + return if (listOfFeatures.isEmpty()) { + null + } else { + val randomIndex = (0.. +} + +data class TrafficQualitySettingsJson( + val versions: List, +) + +data class TrafficQualityAppVersion( + val appVersion: Int, + val daysUntilLoggingStarts: Int, + val daysLogging: Int, + val featuresLogged: TrafficQualityAppVersionFeatures, +) + +data class TrafficQualityAppVersionFeatures( + val gpc: Boolean, + val cpm: Boolean, + val appTP: Boolean, + val netP: Boolean, +) + +@ContributesBinding(AppScope::class) +class FeaturesRequestHeaderSettingStore @Inject constructor( + private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, + private val moshi: Moshi, +) : FeaturesRequestHeaderStore { + + private val jsonAdapter: JsonAdapter by lazy { + moshi.adapter(TrafficQualitySettingsJson::class.java) + } + + override fun getConfig(): List { + val config = androidBrowserConfigFeature.featuresRequestHeader().getSettings()?.let { + runCatching { + val configJson = jsonAdapter.fromJson(it) + configJson?.versions + }.getOrDefault(emptyList()) + } ?: emptyList() + return config + } +} diff --git a/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt b/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt index 82cbcdc114fc..9c95226d5bb5 100644 --- a/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt +++ b/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt @@ -58,9 +58,13 @@ class RealAppBuildConfig @Inject constructor( override val isPerformanceTest: Boolean = BuildConfig.IS_PERFORMANCE_TEST override val isDefaultVariantForced: Boolean = BuildConfig.FORCE_DEFAULT_VARIANT + override val deviceLocale: Locale get() = Locale.getDefault() override val variantName: String? get() = variantManager.get().getVariantKey() + + override val buildDateTimeMillis: Long + get() = BuildConfig.BUILD_DATE_MILLIS } diff --git a/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt b/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt index 44d1908ca35f..9025ce66e540 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt @@ -35,6 +35,7 @@ enum class CtaId { DAX_END, DAX_FIRE_BUTTON_PULSE, DEVICE_SHIELD_CTA, + BROKEN_SITE_PROMPT, UNKNOWN, } diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt index c4e3c235e605..b4594f430a94 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt @@ -38,6 +38,8 @@ import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.install.daysInstalled import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.pixels.AppPixelName.SITE_NOT_WORKING_SHOWN +import com.duckduckgo.app.pixels.AppPixelName.SITE_NOT_WORKING_WEBSITE_BROKEN import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelValues.DAX_FIRE_DIALOG_CTA @@ -968,10 +970,12 @@ sealed class DaxBubbleCta( data class DaxPrivacyProCta( override val onboardingStore: OnboardingStore, override val appInstallStore: AppInstallStore, + val titleRes: Int, + val descriptionRes: Int, ) : DaxBubbleCta( ctaId = CtaId.DAX_INTRO_PRIVACY_PRO, - title = R.string.onboardingPrivacyProDaxDialogTitle, - description = R.string.onboardingPrivacyProDaxDialogDescription, + title = titleRes, + description = descriptionRes, placeholder = com.duckduckgo.mobile.android.R.drawable.ic_privacy_pro_128, primaryCta = R.string.onboardingPrivacyProDaxDialogOkButton, secondaryCta = R.string.onboardingPrivacyProDaxDialogCancelButton, @@ -1030,6 +1034,26 @@ sealed class DaxBubbleCta( appInstallStore = appInstallStore, ) + data class DaxExperimentPrivacyProCta( + override val onboardingStore: OnboardingStore, + override val appInstallStore: AppInstallStore, + val titleRes: Int, + val descriptionRes: Int, + ) : DaxBubbleCta( + ctaId = CtaId.DAX_INTRO_PRIVACY_PRO, + title = titleRes, + description = descriptionRes, + placeholder = com.duckduckgo.mobile.android.R.drawable.ic_privacy_pro_128, + primaryCta = R.string.onboardingPrivacyProDaxDialogOkButton, + secondaryCta = R.string.onboardingPrivacyProDaxDialogCancelButton, + shownPixel = AppPixelName.ONBOARDING_DAX_CTA_SHOWN, + okPixel = AppPixelName.ONBOARDING_DAX_CTA_OK_BUTTON, + cancelPixel = AppPixelName.ONBOARDING_DAX_CTA_CANCEL_BUTTON, + ctaPixelParam = Pixel.PixelValues.DAX_PRIVACY_PRO, + onboardingStore = onboardingStore, + appInstallStore = appInstallStore, + ) + data class DaxDialogIntroOption( val optionText: String, @DrawableRes val iconRes: Int, @@ -1095,6 +1119,38 @@ sealed class HomePanelCta( ) } +class BrokenSitePromptDialogCta() : Cta { + + override val ctaId: CtaId = CtaId.BROKEN_SITE_PROMPT + override val shownPixel: Pixel.PixelName = SITE_NOT_WORKING_SHOWN + override val okPixel: Pixel.PixelName = SITE_NOT_WORKING_WEBSITE_BROKEN + override val cancelPixel: Pixel.PixelName? = null + + override fun pixelCancelParameters(): Map = mapOf() + + override fun pixelOkParameters(): Map = mapOf() + + override fun pixelShownParameters(): Map = mapOf() + + fun hideOnboardingCta(binding: FragmentBrowserTabBinding) { + val view = binding.includeBrokenSitePromptDialog.root + view.gone() + } + + fun showBrokenSitePromptCta( + binding: FragmentBrowserTabBinding, + onReportBrokenSiteClicked: () -> Unit, + onDismissCtaClicked: () -> Unit, + onCtaShown: () -> Unit, + ) { + val daxDialog = binding.includeBrokenSitePromptDialog + daxDialog.root.show() + binding.includeBrokenSitePromptDialog.reportButton.setOnClickListener { onReportBrokenSiteClicked.invoke() } + binding.includeBrokenSitePromptDialog.dismissButton.setOnClickListener { onDismissCtaClicked.invoke() } + onCtaShown() + } +} + fun DaxCta.addCtaToHistory(newCta: String): String { val param = onboardingStore.onboardingDialogJourney?.split("-").orEmpty().toMutableList() val daysInstalled = minOf(appInstallStore.daysInstalled().toInt(), MAX_DAYS_ALLOWED) 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 36febc9b8554..429c4902c5c2 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 @@ -20,6 +20,7 @@ import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import androidx.core.net.toUri import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.app.browser.R import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.model.DismissedCta @@ -29,9 +30,18 @@ import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.global.model.orderedTrackerBlockedEntities -import com.duckduckgo.app.onboarding.store.* +import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.OnboardingStore +import com.duckduckgo.app.onboarding.store.UserStageStore +import com.duckduckgo.app.onboarding.store.daxOnboardingActive import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingFeatureToggles +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingFeatureToggles.Cohorts +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingPixelsPlugin import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.HighlightsOnboardingExperimentManager +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.testPrivacyProOnboardingPrimaryButtonMetricPixel +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.testPrivacyProOnboardingSecondaryButtonMetricPixel +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.testPrivacyProOnboardingShownMetricPixel +import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.settings.db.SettingsDataStore @@ -39,6 +49,8 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.widget.ui.WidgetCapabilities +import com.duckduckgo.brokensite.api.BrokenSitePrompt +import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckplayer.api.DuckPlayer @@ -50,7 +62,14 @@ import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext import timber.log.Timber @@ -71,6 +90,9 @@ class CtaViewModel @Inject constructor( private val subscriptions: Subscriptions, private val duckPlayer: DuckPlayer, private val highlightsOnboardingExperimentManager: HighlightsOnboardingExperimentManager, + private val brokenSitePrompt: BrokenSitePrompt, + private val extendedOnboardingPixelsPlugin: ExtendedOnboardingPixelsPlugin, + private val userBrowserProperties: UserBrowserProperties, ) { @ExperimentalCoroutinesApi @VisibleForTesting @@ -87,8 +109,9 @@ class CtaViewModel @Inject constructor( } } - private val requiredDaxOnboardingCtas: Array by lazy { - if (extendedOnboardingFeatureToggles.privacyProCta().isEnabled()) { + private suspend fun requiredDaxOnboardingCtas(): Array { + val shouldShowPrivacyProCta = subscriptions.isEligible() && extendedOnboardingFeatureToggles.privacyProCta().isEnabled() + return if (shouldShowPrivacyProCta) { arrayOf( CtaId.DAX_INTRO, CtaId.DAX_DIALOG_SERP, @@ -115,7 +138,7 @@ class CtaViewModel @Inject constructor( } } - fun onCtaShown(cta: Cta) { + suspend fun onCtaShown(cta: Cta) { cta.shownPixel?.let { val canSendPixel = when (cta) { is DaxCta -> cta.canSendShownPixel() @@ -128,6 +151,30 @@ class CtaViewModel @Inject constructor( if (cta is OnboardingDaxDialogCta && cta.markAsReadOnShow) { dismissedCtaDao.insert(DismissedCta(cta.ctaId)) } + if (cta is BrokenSitePromptDialogCta) { + brokenSitePrompt.ctaShown() + } + withContext(dispatchers.io()) { + if (cta is DaxBubbleCta.DaxPrivacyProCta || cta is DaxBubbleCta.DaxExperimentPrivacyProCta) { + extendedOnboardingPixelsPlugin.testPrivacyProOnboardingShownMetricPixel()?.getPixelDefinitions()?.forEach { + pixel.fire(it.pixelName, it.params) + } + } + } + + // Temporary pixel + val isVisitSiteSuggestionsCta = + cta is DaxBubbleCta.DaxIntroVisitSiteOptionsCta || cta is DaxBubbleCta.DaxExperimentIntroVisitSiteOptionsCta || + cta is OnboardingDaxDialogCta.DaxSiteSuggestionsCta || cta is OnboardingDaxDialogCta.DaxExperimentSiteSuggestionsCta + if (isVisitSiteSuggestionsCta) { + if (userBrowserProperties.daysSinceInstalled() <= MIN_DAYS_TO_COUNT_ONBOARDING_CTA_SHOWN) { + val count = onboardingStore.visitSiteCtaDisplayCount ?: 0 + pixel.fire(AppPixelName.ONBOARDING_VISIT_SITE_CTA_SHOWN, mapOf("count" to count.toString())) + onboardingStore.visitSiteCtaDisplayCount = count + 1 + } else { + onboardingStore.clearVisitSiteCtaDisplayCount() + } + } } suspend fun registerDaxBubbleCtaDismissed(cta: Cta) { @@ -148,6 +195,10 @@ class CtaViewModel @Inject constructor( suspend fun onUserDismissedCta(cta: Cta) { withContext(dispatchers.io()) { + if (cta is BrokenSitePromptDialogCta) { + brokenSitePrompt.userDismissedPrompt() + } + cta.cancelPixel?.let { pixel.fire(it, cta.pixelCancelParameters()) } @@ -158,10 +209,30 @@ class CtaViewModel @Inject constructor( } } - fun onUserClickCtaOkButton(cta: Cta) { + suspend fun onUserClickCtaOkButton(cta: Cta) { cta.okPixel?.let { pixel.fire(it, cta.pixelOkParameters()) } + if (cta is BrokenSitePromptDialogCta) { + brokenSitePrompt.userAcceptedPrompt() + } + withContext(dispatchers.io()) { + if (cta is DaxBubbleCta.DaxPrivacyProCta || cta is DaxBubbleCta.DaxExperimentPrivacyProCta) { + extendedOnboardingPixelsPlugin.testPrivacyProOnboardingPrimaryButtonMetricPixel()?.getPixelDefinitions()?.forEach { + pixel.fire(it.pixelName, it.params) + } + } + } + } + + suspend fun onUserClickCtaSkipButton(cta: Cta) { + withContext(dispatchers.io()) { + if (cta is DaxBubbleCta.DaxPrivacyProCta || cta is DaxBubbleCta.DaxExperimentPrivacyProCta) { + extendedOnboardingPixelsPlugin.testPrivacyProOnboardingSecondaryButtonMetricPixel()?.getPixelDefinitions()?.forEach { + pixel.fire(it.pixelName, it.params) + } + } + } } suspend fun refreshCta( @@ -171,7 +242,7 @@ class CtaViewModel @Inject constructor( ): Cta? { return withContext(dispatcher) { if (isBrowserShowing) { - getDaxDialogCta(site) + getBrowserCta(site) } else { getHomeCta() } @@ -214,6 +285,7 @@ class CtaViewModel @Inject constructor( userStageStore.stageCompleted(AppStage.DAX_ONBOARDING) null } + canShowDaxIntroCta() && !extendedOnboardingFeatureToggles.noBrowserCtas().isEnabled() -> { if (highlightsOnboardingExperimentManager.isHighlightsEnabled()) { DaxBubbleCta.DaxExperimentIntroSearchOptionsCta(onboardingStore, appInstallStore) @@ -238,8 +310,33 @@ class CtaViewModel @Inject constructor( } } - canShowPrivacyProCta() && extendedOnboardingFeatureToggles.privacyProCta().isEnabled() -> { - DaxBubbleCta.DaxPrivacyProCta(onboardingStore, appInstallStore) + canShowPrivacyProCta() -> { + val titleRes: Int + val descriptionRes: Int + when { + extendedOnboardingFeatureToggles.testPrivacyProOnboardingCopyNov24().isEnabled(Cohorts.STEP) -> { + titleRes = R.string.onboardingPrivacyProStepDaxDialogTitle + descriptionRes = R.string.onboardingPrivacyProStepDaxDialogDescription + } + extendedOnboardingFeatureToggles.testPrivacyProOnboardingCopyNov24().isEnabled(Cohorts.PROTECTION) -> { + titleRes = R.string.onboardingPrivacyProProtectionDaxDialogTitle + descriptionRes = R.string.onboardingPrivacyProProtectionDaxDialogDescription + } + extendedOnboardingFeatureToggles.testPrivacyProOnboardingCopyNov24().isEnabled(Cohorts.DEAL) -> { + titleRes = R.string.onboardingPrivacyProDealDaxDialogTitle + descriptionRes = R.string.onboardingPrivacyProDealDaxDialogDescription + } + else -> { + titleRes = R.string.onboardingPrivacyProDaxDialogTitle + descriptionRes = R.string.onboardingPrivacyProDaxDialogDescription + } + } + + if (highlightsOnboardingExperimentManager.isHighlightsEnabled()) { + DaxBubbleCta.DaxExperimentPrivacyProCta(onboardingStore, appInstallStore, titleRes, descriptionRes) + } else { + DaxBubbleCta.DaxPrivacyProCta(onboardingStore, appInstallStore, titleRes, descriptionRes) + } } canShowWidgetCta() -> { @@ -270,7 +367,7 @@ class CtaViewModel @Inject constructor( !hideTips() && (daxDialogNetworkShown() || daxDialogOtherShown() || daxDialogSerpShown() || daxDialogTrackersFoundShown()) - private suspend fun canShowDaxDialogCta(): Boolean { + private suspend fun canShowOnboardingDaxDialogCta(): Boolean { return when { !daxOnboardingActive() || hideTips() -> false extendedOnboardingFeatureToggles.noBrowserCtas().isEnabled() -> { @@ -278,16 +375,18 @@ class CtaViewModel @Inject constructor( userStageStore.stageCompleted(AppStage.DAX_ONBOARDING) false } + else -> true } } private suspend fun canShowPrivacyProCta(): Boolean { - return daxOnboardingActive() && !hideTips() && !daxDialogPrivacyProShown() + return daxOnboardingActive() && !hideTips() && !daxDialogPrivacyProShown() && + subscriptions.isEligible() && extendedOnboardingFeatureToggles.privacyProCta().isEnabled() } @WorkerThread - private suspend fun getDaxDialogCta(site: Site?): Cta? { + private suspend fun getBrowserCta(site: Site?): Cta? { val nonNullSite = site ?: return null val host = nonNullSite.domain @@ -300,7 +399,13 @@ class CtaViewModel @Inject constructor( return null } - if (!canShowDaxDialogCta()) return null + if (!canShowOnboardingDaxDialogCta()) { + return if (brokenSitePrompt.shouldShowBrokenSitePrompt(nonNullSite.url)) { + BrokenSitePromptDialogCta() + } else { + null + } + } // Trackers blocked if (!daxDialogTrackersFoundShown() && !isSerpUrl(it.url) && it.orderedTrackerBlockedEntities().isNotEmpty()) { @@ -426,11 +531,7 @@ class CtaViewModel @Inject constructor( !daxOnboardingActive() || pulseFireButtonShown() || daxDialogFireEducationShown() || hideTips() private suspend fun allOnboardingCtasShown(): Boolean { - return withContext(dispatchers.io()) { - requiredDaxOnboardingCtas.all { - dismissedCtaDao.exists(it) - } - } + return requiredDaxOnboardingCtas().all { dismissedCtaDao.exists(it) } } private fun forceStopFireButtonPulseAnimationFlow() = tabRepository.flowTabs.distinctUntilChanged() @@ -475,7 +576,19 @@ class CtaViewModel @Inject constructor( fun isSuggestedSiteOption(query: String): Boolean = onboardingStore.getSitesOptions().map { it.link }.contains(query) + fun getCohortOrigin(): String { + val cohort = extendedOnboardingFeatureToggles.testPrivacyProOnboardingCopyNov24().getCohort() + return when (cohort?.name) { + Cohorts.STEP.cohortName -> "_${Cohorts.STEP.cohortName}" + Cohorts.PROTECTION.cohortName -> "_${Cohorts.PROTECTION.cohortName}" + Cohorts.DEAL.cohortName -> "_${Cohorts.DEAL.cohortName}" + Cohorts.CONTROL.cohortName -> "_${Cohorts.CONTROL.cohortName}" + else -> "" + } + } + companion object { private const val MAX_TABS_OPEN_FIRE_EDUCATION = 2 + private const val MIN_DAYS_TO_COUNT_ONBOARDING_CTA_SHOWN = 3 } } diff --git a/app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModel.kt b/app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModel.kt index 25e09639906c..59d7ed73e452 100644 --- a/app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModel.kt @@ -21,6 +21,7 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.global.intentText import com.duckduckgo.autofill.api.emailprotection.EmailProtectionLinkVerifier import com.duckduckgo.common.utils.DispatcherProvider @@ -37,6 +38,7 @@ class IntentDispatcherViewModel @Inject constructor( private val customTabDetector: CustomTabDetector, private val dispatcherProvider: DispatcherProvider, private val emailProtectionLinkVerifier: EmailProtectionLinkVerifier, + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState()) @@ -56,7 +58,8 @@ class IntentDispatcherViewModel @Inject constructor( val intentText = intent?.intentText val toolbarColor = intent?.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, defaultColor) ?: defaultColor val isEmailProtectionLink = emailProtectionLinkVerifier.shouldDelegateToInContextView(intentText, true) - val customTabRequested = hasSession && !isEmailProtectionLink + val isDuckDuckGoUrl = intentText?.let { duckDuckGoUrlDetector.isDuckDuckGoUrl(it) } ?: false + val customTabRequested = hasSession && !isEmailProtectionLink && !isDuckDuckGoUrl Timber.d("Intent $intent received. Has extra session=$hasSession. Intent text=$intentText. Toolbar color=$toolbarColor") 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 3e7a786074fc..99dfabb924b7 100644 --- a/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/GeneralSettingsViewModel.kt @@ -154,7 +154,7 @@ class GeneralSettingsViewModel @Inject constructor( private fun observeShowOnAppLaunchOption() { showOnAppLaunchOptionDataStore.optionFlow .onEach { showOnAppLaunchOption -> - _viewState.update { it!!.copy(showOnAppLaunchSelectedOption = showOnAppLaunchOption) } + _viewState.value?.let { state -> _viewState.update { state.copy(showOnAppLaunchSelectedOption = showOnAppLaunchOption) } } }.launchIn(viewModelScope) } 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 132305e45741..f01221520ebd 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 @@ -19,6 +19,8 @@ package com.duckduckgo.app.global.api import com.duckduckgo.app.browser.WebViewPixelName import com.duckduckgo.app.browser.httperrors.HttpErrorPixelName import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.pixels.AppPixelName.SITE_NOT_WORKING_SHOWN +import com.duckduckgo.app.pixels.AppPixelName.SITE_NOT_WORKING_WEBSITE_BROKEN import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.utils.AppUrl import com.duckduckgo.common.utils.plugins.PluginPoint @@ -87,9 +89,11 @@ 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(), + SITE_NOT_WORKING_SHOWN.pixelName to PixelParameter.removeAtb(), + SITE_NOT_WORKING_WEBSITE_BROKEN.pixelName to PixelParameter.removeAtb(), + AppPixelName.APP_VERSION_AT_SEARCH_TIME.pixelName to PixelParameter.removeAll(), ) } } diff --git a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt index 99985184defe..d9fad2a5e178 100644 --- a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt +++ b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt @@ -56,6 +56,7 @@ import com.duckduckgo.app.statistics.store.PendingPixelDao import com.duckduckgo.app.survey.db.SurveyDao import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.tabs.db.TabsDao +import com.duckduckgo.app.tabs.model.LocalDateTimeTypeConverter import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabSelectionEntity import com.duckduckgo.app.trackerdetection.db.* @@ -72,7 +73,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao @Database( exportSchema = true, - version = 55, + version = 56, entities = [ TdsTracker::class, TdsEntity::class, @@ -122,6 +123,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao LocationPermissionTypeConverter::class, QueryParamsTypeConverter::class, EntityTypeConverter::class, + LocalDateTimeTypeConverter::class, ) abstract class AppDatabase : RoomDatabase() { @@ -674,6 +676,12 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa } } + private val MIGRATION_55_TO_56: Migration = object : Migration(55, 56) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `tabs` ADD COLUMN `lastAccessTime` TEXT") + } + } + /** * WARNING ⚠️ * This needs to happen because Room doesn't support UNIQUE (...) ON CONFLICT REPLACE when creating the bookmarks table. @@ -754,6 +762,7 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa MIGRATION_52_TO_53, MIGRATION_53_TO_54, MIGRATION_54_TO_55, + MIGRATION_55_TO_56, ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt index 0da349b25523..3efc9324ff34 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt @@ -20,7 +20,9 @@ import com.duckduckgo.app.cta.ui.DaxBubbleCta.DaxDialogIntroOption interface OnboardingStore { var onboardingDialogJourney: String? + var visitSiteCtaDisplayCount: Int fun getSearchOptions(): List fun getSitesOptions(): List fun getExperimentSearchOptions(): List + fun clearVisitSiteCtaDisplayCount() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt index d18fcdadbb65..55bd021a824c 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt @@ -25,7 +25,9 @@ import com.duckduckgo.mobile.android.R.drawable import java.util.Locale import javax.inject.Inject -class OnboardingStoreImpl @Inject constructor(private val context: Context) : OnboardingStore { +class OnboardingStoreImpl @Inject constructor( + private val context: Context, +) : OnboardingStore { private val preferences: SharedPreferences by lazy { context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE) } @@ -33,6 +35,10 @@ class OnboardingStoreImpl @Inject constructor(private val context: Context) : On get() = preferences.getString(ONBOARDING_JOURNEY, null) set(dialogJourney) = preferences.edit { putString(ONBOARDING_JOURNEY, dialogJourney) } + override var visitSiteCtaDisplayCount: Int + get() = preferences.getInt(VISIT_SITE_CTA_DISPLAY_COUNT, 0) + set(count) = preferences.edit { putInt(VISIT_SITE_CTA_DISPLAY_COUNT, count) } + override fun getSearchOptions(): List { val country = Locale.getDefault().country val language = Locale.getDefault().language @@ -205,8 +211,13 @@ class OnboardingStoreImpl @Inject constructor(private val context: Context) : On ) } + override fun clearVisitSiteCtaDisplayCount() { + preferences.edit { remove(VISIT_SITE_CTA_DISPLAY_COUNT) } + } + companion object { const val FILENAME = "com.duckduckgo.app.onboarding.settings" const val ONBOARDING_JOURNEY = "onboardingJourney" + const val VISIT_SITE_CTA_DISPLAY_COUNT = "visitSiteCtaDisplayCount" } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/extendedonboarding/ExtendedOnboardingFeatureToggles.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/extendedonboarding/ExtendedOnboardingFeatureToggles.kt index f52ad60f546b..787a6741486c 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/extendedonboarding/ExtendedOnboardingFeatureToggles.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/extendedonboarding/ExtendedOnboardingFeatureToggles.kt @@ -18,8 +18,14 @@ package com.duckduckgo.app.onboarding.ui.page.extendedonboarding import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.ConversionWindow +import com.duckduckgo.feature.toggles.api.MetricsPixel +import com.duckduckgo.feature.toggles.api.MetricsPixelPlugin import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.Experiment +import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject @ContributesRemoteFeature( scope = AppScope::class, @@ -31,14 +37,61 @@ interface ExtendedOnboardingFeatureToggles { fun self(): Toggle @Toggle.DefaultValue(false) - @Experiment fun noBrowserCtas(): Toggle @Toggle.DefaultValue(false) - @Experiment fun privacyProCta(): Toggle @Toggle.DefaultValue(false) @Experiment fun highlights(): Toggle + + @Toggle.DefaultValue(false) + fun testPrivacyProOnboardingCopyNov24(): Toggle + + enum class Cohorts(override val cohortName: String) : CohortName { + CONTROL("control"), + PROTECTION("protection"), + DEAL("deal"), + STEP("step"), + } +} + +internal suspend fun ExtendedOnboardingPixelsPlugin.testPrivacyProOnboardingShownMetricPixel(): MetricsPixel? { + return this.getMetrics().firstOrNull { it.metric == "dialogShown" } +} + +internal suspend fun ExtendedOnboardingPixelsPlugin.testPrivacyProOnboardingPrimaryButtonMetricPixel(): MetricsPixel? { + return this.getMetrics().firstOrNull { it.metric == "primaryButtonSelected" } +} + +internal suspend fun ExtendedOnboardingPixelsPlugin.testPrivacyProOnboardingSecondaryButtonMetricPixel(): MetricsPixel? { + return this.getMetrics().firstOrNull { it.metric == "secondaryButtonSelected" } +} + +@ContributesMultibinding(AppScope::class) +class ExtendedOnboardingPixelsPlugin @Inject constructor(private val toggle: ExtendedOnboardingFeatureToggles) : MetricsPixelPlugin { + + override suspend fun getMetrics(): List { + return listOf( + MetricsPixel( + metric = "dialogShown", + value = "1", + toggle = toggle.testPrivacyProOnboardingCopyNov24(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 1)), + ), + MetricsPixel( + metric = "primaryButtonSelected", + value = "1", + toggle = toggle.testPrivacyProOnboardingCopyNov24(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 1)), + ), + MetricsPixel( + metric = "secondaryButtonSelected", + value = "1", + toggle = toggle.testPrivacyProOnboardingCopyNov24(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 1)), + ), + ) + } } 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 f334fde9ef21..180dacc82e60 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -49,6 +49,7 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { ONBOARDING_DAX_CTA_OK_BUTTON("m_odc_ok"), ONBOARDING_DAX_CTA_CANCEL_BUTTON("m_onboarding_dax_cta_cancel"), ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE("m_onboarding_skip_major_network_unique"), + ONBOARDING_VISIT_SITE_CTA_SHOWN("onboarding_visit_site_cta_shown"), BROWSER_MENU_ALLOWLIST_ADD("mb_wla"), BROWSER_MENU_ALLOWLIST_REMOVE("mb_wlr"), @@ -363,11 +364,14 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { REFRESH_ACTION_DAILY_PIXEL("m_refresh_action_daily"), RELOAD_TWICE_WITHIN_12_SECONDS("m_reload_twice_within_12_seconds"), - RELOAD_THREE_TIMES_WITHIN_20_SECONDS("m_reload_three_times_within_20_seconds"), + RELOAD_THREE_TIMES_WITHIN_20_SECONDS("m_reload-three-times-within-20-seconds"), + + SITE_NOT_WORKING_WEBSITE_BROKEN("m_site-not-working_website-is-broken"), + SITE_NOT_WORKING_SHOWN("m_site-not-working_shown"), URI_LOADED("m_uri_loaded"), ERROR_PAGE_SHOWN("m_errorpageshown"), - FEATURES_ENABLED_AT_SEARCH_TIME("features"), + APP_VERSION_AT_SEARCH_TIME("app_version_at_search_time"), } diff --git a/app/src/main/java/com/duckduckgo/app/pixels/campaign/CampaignPixelParamsAdditionInterceptor.kt b/app/src/main/java/com/duckduckgo/app/pixels/campaign/CampaignPixelParamsAdditionInterceptor.kt index 299a681ef9ec..0d2aa12ce72b 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/campaign/CampaignPixelParamsAdditionInterceptor.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/campaign/CampaignPixelParamsAdditionInterceptor.kt @@ -50,6 +50,10 @@ class CampaignPixelParamsAdditionInterceptor @Inject constructor( val queryParams = queryParamsString.toParamsMap() if (plugin.isEligible(queryParams)) { runBlocking { + /** + * The additional parameters being collected only apply to a single promotion about a DuckDuckGo product. + * The parameters are temporary, collected in aggregate, and anonymous. + */ additionalPixelParamsGenerator.generateAdditionalParams().forEach { (key, value) -> url.addQueryParameter(key, value) } diff --git a/app/src/main/java/com/duckduckgo/app/settings/LegacySettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/LegacySettingsActivity.kt new file mode 100644 index 000000000000..c154f1d673eb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/settings/LegacySettingsActivity.kt @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2017 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.settings + +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.about.AboutScreenNoParams +import com.duckduckgo.app.accessibility.AccessibilityScreens +import com.duckduckgo.app.appearance.AppearanceScreen +import com.duckduckgo.app.browser.BrowserActivity +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.databinding.ActivitySettingsBinding +import com.duckduckgo.app.email.ui.EmailProtectionUnsupportedScreenNoParams +import com.duckduckgo.app.firebutton.FireButtonScreenNoParams +import com.duckduckgo.app.generalsettings.GeneralSettingsScreenNoParams +import com.duckduckgo.app.global.view.launchDefaultAppActivity +import com.duckduckgo.app.permissions.PermissionsScreenNoParams +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.pixels.AppPixelName.PRIVACY_PRO_IS_ENABLED_AND_ELIGIBLE +import com.duckduckgo.app.privatesearch.PrivateSearchScreenNoParams +import com.duckduckgo.app.settings.LegacySettingsViewModel.Command +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.webtrackingprotection.WebTrackingProtectionScreenNoParams +import com.duckduckgo.app.widget.AddWidgetLauncher +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autoconsent.impl.ui.AutoconsentSettingsActivity +import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreen +import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.listitem.CheckListItem +import com.duckduckgo.common.ui.view.listitem.TwoLineListItem +import com.duckduckgo.common.ui.view.show +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.internal.features.api.InternalFeaturePlugin +import com.duckduckgo.macos.api.MacOsScreenWithEmptyParams +import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerActivityWithEmptyParams +import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin +import com.duckduckgo.settings.api.NewSettingsFeature +import com.duckduckgo.settings.api.ProSettingsPlugin +import com.duckduckgo.sync.api.SyncActivityWithEmptyParams +import com.duckduckgo.windows.api.ui.WindowsScreenWithEmptyParams +import javax.inject.Inject +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber + +@InjectWith(ActivityScope::class) +class LegacySettingsActivity : DuckDuckGoActivity() { + + private val viewModel: LegacySettingsViewModel by bindViewModel() + private val binding: ActivitySettingsBinding by viewBinding() + + @Inject + lateinit var pixel: Pixel + + @Inject + lateinit var internalFeaturePlugins: PluginPoint + + @Inject + lateinit var addWidgetLauncher: AddWidgetLauncher + + @Inject + lateinit var appBuildConfig: AppBuildConfig + + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + @Inject + lateinit var _proSettingsPlugin: PluginPoint + private val proSettingsPlugin by lazy { + _proSettingsPlugin.getPlugins() + } + + @Inject + lateinit var _duckPlayerSettingsPlugin: PluginPoint + private val duckPlayerSettingsPlugin by lazy { + _duckPlayerSettingsPlugin.getPlugins() + } + + @Inject + lateinit var newSettingsFeature: NewSettingsFeature + + private val viewsPrivacy + get() = binding.includeSettings.contentSettingsPrivacy + + private val viewsSettings + get() = binding.includeSettings.contentSettingsSettings + + private val viewsMore + get() = binding.includeSettings.contentSettingsMore + + private val viewsInternal + get() = binding.includeSettings.contentSettingsInternal + + private val viewsPro + get() = binding.includeSettings.settingsSectionPro + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setupToolbar(binding.includeToolbar.toolbar) + + configureUiEventHandlers() + configureInternalFeatures() + configureSettings() + lifecycle.addObserver(viewModel) + observeViewModel() + + intent?.getStringExtra(BrowserActivity.LAUNCH_FROM_NOTIFICATION_PIXEL_NAME)?.let { + viewModel.onLaunchedFromNotification(it) + } + } + + private fun configureUiEventHandlers() { + with(viewsPrivacy) { + setAsDefaultBrowserSetting.setClickListener { viewModel.onDefaultBrowserSettingClicked() } + privateSearchSetting.setClickListener { viewModel.onPrivateSearchSettingClicked() } + webTrackingProtectionSetting.setClickListener { viewModel.onWebTrackingProtectionSettingClicked() } + cookiePopupProtectionSetting.setClickListener { viewModel.onCookiePopupProtectionSettingClicked() } + emailSetting.setClickListener { viewModel.onEmailProtectionSettingClicked() } + vpnSetting.setClickListener { viewModel.onAppTPSettingClicked() } + } + + with(viewsSettings) { + homeScreenWidgetSetting.setClickListener { viewModel.userRequestedToAddHomeScreenWidget() } + autofillLoginsSetting.setClickListener { viewModel.onAutofillSettingsClick() } + syncSetting.setClickListener { viewModel.onSyncSettingClicked() } + fireButtonSetting.setClickListener { viewModel.onFireButtonSettingClicked() } + permissionsSetting.setClickListener { viewModel.onPermissionsSettingClicked() } + appearanceSetting.setClickListener { viewModel.onAppearanceSettingClicked() } + accessibilitySetting.setClickListener { viewModel.onAccessibilitySettingClicked() } + aboutSetting.setClickListener { viewModel.onAboutSettingClicked() } + generalSetting.setClickListener { viewModel.onGeneralSettingClicked() } + } + + with(viewsMore) { + macOsSetting.setClickListener { viewModel.onMacOsSettingClicked() } + windowsSetting.setClickListener { viewModel.windowsSettingClicked() } + } + } + + private fun configureSettings() { + if (proSettingsPlugin.isEmpty()) { + viewsPro.gone() + } else { + proSettingsPlugin.forEach { plugin -> + viewsPro.addView(plugin.getView(this)) + } + } + + if (duckPlayerSettingsPlugin.isEmpty()) { + viewsSettings.settingsSectionDuckPlayer.gone() + } else { + duckPlayerSettingsPlugin.forEach { plugin -> + viewsSettings.settingsSectionDuckPlayer.addView(plugin.getView(this)) + } + } + } + + private fun configureInternalFeatures() { + viewsInternal.settingsSectionInternal.visibility = if (internalFeaturePlugins.getPlugins().isEmpty()) View.GONE else View.VISIBLE + internalFeaturePlugins.getPlugins().forEach { feature -> + Timber.v("Adding internal feature ${feature.internalFeatureTitle()}") + val view = TwoLineListItem(this).apply { + setPrimaryText(feature.internalFeatureTitle()) + setSecondaryText(feature.internalFeatureSubtitle()) + } + viewsInternal.settingsInternalFeaturesContainer.addView(view) + view.setClickListener { feature.onInternalFeatureClicked(this) } + } + } + + private fun observeViewModel() { + viewModel.viewState() + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .distinctUntilChanged() + .onEach { viewState -> + viewState.let { + updateDefaultBrowserViewVisibility(it) + updateDeviceShieldSettings( + it.appTrackingProtectionEnabled, + it.appTrackingProtectionOnboardingShown, + ) + updateEmailSubtitle(it.emailAddress) + updateAutofill(it.showAutofill) + updateSyncSetting(visible = it.showSyncSetting) + updateAutoconsent(it.isAutoconsentEnabled) + updatePrivacyPro(it.isPrivacyProEnabled) + updateDuckPlayer(it.isDuckPlayerEnabled) + } + }.launchIn(lifecycleScope) + + viewModel.commands() + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun updatePrivacyPro(isPrivacyProEnabled: Boolean) { + if (isPrivacyProEnabled) { + pixel.fire(PRIVACY_PRO_IS_ENABLED_AND_ELIGIBLE, type = Daily()) + viewsPro.show() + } else { + viewsPro.gone() + } + } + + private fun updateDuckPlayer(isDuckPlayerEnabled: Boolean) { + if (isDuckPlayerEnabled) { + viewsSettings.settingsSectionDuckPlayer.show() + } else { + viewsSettings.settingsSectionDuckPlayer.gone() + } + } + + private fun updateAutofill(autofillEnabled: Boolean) = with(viewsSettings.autofillLoginsSetting) { + visibility = if (autofillEnabled) { + View.VISIBLE + } else { + View.GONE + } + } + + private fun updateEmailSubtitle(emailAddress: String?) { + if (emailAddress.isNullOrEmpty()) { + viewsPrivacy.emailSetting.setSecondaryText(getString(R.string.settingsEmailProtectionSubtitle)) + viewsPrivacy.emailSetting.setItemStatus(CheckListItem.CheckItemStatus.DISABLED) + } else { + viewsPrivacy.emailSetting.setSecondaryText(emailAddress) + viewsPrivacy.emailSetting.setItemStatus(CheckListItem.CheckItemStatus.ENABLED) + } + } + + private fun updateSyncSetting(visible: Boolean) { + with(viewsSettings.syncSetting) { + isVisible = visible + } + } + + private fun updateAutoconsent(enabled: Boolean) { + if (enabled) { + viewsPrivacy.cookiePopupProtectionSetting.setSecondaryText(getString(R.string.cookiePopupProtectionEnabled)) + viewsPrivacy.cookiePopupProtectionSetting.setItemStatus(CheckListItem.CheckItemStatus.ENABLED) + } else { + viewsPrivacy.cookiePopupProtectionSetting.setSecondaryText(getString(R.string.cookiePopupProtectionDescription)) + viewsPrivacy.cookiePopupProtectionSetting.setItemStatus(CheckListItem.CheckItemStatus.DISABLED) + } + } + + private fun processCommand(it: Command?) { + when (it) { + is Command.LaunchDefaultBrowser -> launchDefaultAppScreen() + is Command.LaunchAutofillSettings -> launchAutofillSettings() + is Command.LaunchAccessibilitySettings -> launchAccessibilitySettings() + is Command.LaunchAppTPTrackersScreen -> launchAppTPTrackersScreen() + is Command.LaunchAppTPOnboarding -> launchAppTPOnboardingScreen() + is Command.LaunchEmailProtection -> launchEmailProtectionScreen(it.url) + is Command.LaunchEmailProtectionNotSupported -> launchEmailProtectionNotSupported() + is Command.LaunchAddHomeScreenWidget -> launchAddHomeScreenWidget() + is Command.LaunchMacOs -> launchMacOsScreen() + is Command.LaunchWindows -> launchWindowsScreen() + is Command.LaunchSyncSettings -> launchSyncSettings() + is Command.LaunchPrivateSearchWebPage -> launchPrivateSearchScreen() + is Command.LaunchWebTrackingProtectionScreen -> launchWebTrackingProtectionScreen() + is Command.LaunchCookiePopupProtectionScreen -> launchCookiePopupProtectionScreen() + is Command.LaunchFireButtonScreen -> launchFireButtonScreen() + is Command.LaunchPermissionsScreen -> launchPermissionsScreen() + is Command.LaunchAppearanceScreen -> launchAppearanceScreen() + is Command.LaunchAboutScreen -> launchAboutScreen() + is Command.LaunchGeneralSettingsScreen -> launchGeneralSettingsScreen() + null -> TODO() + } + } + + private fun updateDefaultBrowserViewVisibility(it: LegacySettingsViewModel.ViewState) { + with(viewsPrivacy.setAsDefaultBrowserSetting) { + visibility = if (it.showDefaultBrowserSetting) { + if (it.isAppDefaultBrowser) { + setItemStatus(CheckListItem.CheckItemStatus.ENABLED) + setSecondaryText(getString(R.string.settingsDefaultBrowserSetDescription)) + } else { + setItemStatus(CheckListItem.CheckItemStatus.DISABLED) + setSecondaryText(getString(R.string.settingsDefaultBrowserNotSetDescription)) + } + View.VISIBLE + } else { + View.GONE + } + } + } + + private fun updateDeviceShieldSettings( + appTPEnabled: Boolean, + appTrackingProtectionOnboardingShown: Boolean, + ) { + with(viewsPrivacy) { + if (appTPEnabled) { + vpnSetting.setSecondaryText(getString(R.string.atp_SettingsDeviceShieldEnabled)) + vpnSetting.setItemStatus(CheckListItem.CheckItemStatus.ENABLED) + } else { + if (appTrackingProtectionOnboardingShown) { + vpnSetting.setSecondaryText(getString(R.string.atp_SettingsDeviceShieldDisabled)) + vpnSetting.setItemStatus(CheckListItem.CheckItemStatus.WARNING) + } else { + vpnSetting.setSecondaryText(getString(R.string.atp_SettingsDeviceShieldNeverEnabled)) + vpnSetting.setItemStatus(CheckListItem.CheckItemStatus.DISABLED) + } + } + } + } + + private fun launchDefaultAppScreen() { + launchDefaultAppActivity() + } + + private fun launchAutofillSettings() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AutofillSettingsScreen(source = AutofillSettingsLaunchSource.SettingsActivity), options) + } + + private fun launchAccessibilitySettings() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AccessibilityScreens.Default, options) + } + + private fun launchEmailProtectionScreen(url: String) { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + startActivity(BrowserActivity.intent(this, url, interstitialScreen = true), options) + this.finish() + } + + private fun launchEmailProtectionNotSupported() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, EmailProtectionUnsupportedScreenNoParams, options) + } + + private fun launchMacOsScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, MacOsScreenWithEmptyParams, options) + } + + private fun launchWindowsScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, WindowsScreenWithEmptyParams, options) + } + + private fun launchSyncSettings() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, SyncActivityWithEmptyParams, options) + } + + private fun launchAppTPTrackersScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AppTrackerActivityWithEmptyParams, options) + } + + private fun launchAppTPOnboardingScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AppTrackerOnboardingActivityWithEmptyParamsParams, options) + } + + private fun launchAddHomeScreenWidget() { + pixel.fire(AppPixelName.SETTINGS_ADD_HOME_SCREEN_WIDGET_CLICKED) + addWidgetLauncher.launchAddWidget(this) + } + + private fun launchPrivateSearchScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, PrivateSearchScreenNoParams, options) + } + + private fun launchWebTrackingProtectionScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, WebTrackingProtectionScreenNoParams, options) + } + + private fun launchCookiePopupProtectionScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + startActivity(AutoconsentSettingsActivity.intent(this), options) + } + + private fun launchFireButtonScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, FireButtonScreenNoParams, options) + } + + private fun launchPermissionsScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, PermissionsScreenNoParams, options) + } + + private fun launchAppearanceScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AppearanceScreen.Default, options) + } + + private fun launchAboutScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AboutScreenNoParams, options) + } + + private fun launchGeneralSettingsScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, GeneralSettingsScreenNoParams, options) + } + + companion object { + const val LAUNCH_FROM_NOTIFICATION_PIXEL_NAME = "LAUNCH_FROM_NOTIFICATION_PIXEL_NAME" + + fun intent(context: Context): Intent { + return Intent(context, LegacySettingsActivity::class.java) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/LegacySettingsViewModel.kt similarity index 98% rename from app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt rename to app/src/main/java/com/duckduckgo/app/settings/LegacySettingsViewModel.kt index 22abfba72720..18a6184f2814 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/LegacySettingsViewModel.kt @@ -51,7 +51,7 @@ import kotlinx.coroutines.launch @SuppressLint("NoLifecycleObserver") @ContributesViewModel(ActivityScope::class) -class SettingsViewModel @Inject constructor( +class LegacySettingsViewModel @Inject constructor( private val defaultWebBrowserCapability: DefaultBrowserDetector, private val appTrackingProtection: AppTrackingProtection, private val pixel: Pixel, @@ -222,7 +222,7 @@ class SettingsViewModel @Inject constructor( } else { Command.LaunchEmailProtectionNotSupported } - this@SettingsViewModel.command.send(command) + this@LegacySettingsViewModel.command.send(command) } pixel.fire(SETTINGS_EMAIL_PROTECTION_PRESSED) } diff --git a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt new file mode 100644 index 000000000000..5afdc8bc9a70 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt @@ -0,0 +1,403 @@ +/* + * 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.settings + +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.about.AboutScreenNoParams +import com.duckduckgo.app.accessibility.AccessibilityScreens +import com.duckduckgo.app.appearance.AppearanceScreen +import com.duckduckgo.app.browser.BrowserActivity +import com.duckduckgo.app.browser.databinding.ActivitySettingsNewBinding +import com.duckduckgo.app.email.ui.EmailProtectionUnsupportedScreenNoParams +import com.duckduckgo.app.firebutton.FireButtonScreenNoParams +import com.duckduckgo.app.generalsettings.GeneralSettingsScreenNoParams +import com.duckduckgo.app.global.view.launchDefaultAppActivity +import com.duckduckgo.app.permissions.PermissionsScreenNoParams +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.pixels.AppPixelName.PRIVACY_PRO_IS_ENABLED_AND_ELIGIBLE +import com.duckduckgo.app.privatesearch.PrivateSearchScreenNoParams +import com.duckduckgo.app.settings.NewSettingsViewModel.Command +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.webtrackingprotection.WebTrackingProtectionScreenNoParams +import com.duckduckgo.app.widget.AddWidgetLauncher +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autoconsent.impl.ui.AutoconsentSettingsActivity +import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreen +import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.listitem.TwoLineListItem +import com.duckduckgo.common.ui.view.show +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.internal.features.api.InternalFeaturePlugin +import com.duckduckgo.macos.api.MacOsScreenWithEmptyParams +import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerActivityWithEmptyParams +import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin +import com.duckduckgo.settings.api.ProSettingsPlugin +import com.duckduckgo.sync.api.SyncActivityWithEmptyParams +import com.duckduckgo.windows.api.ui.WindowsScreenWithEmptyParams +import javax.inject.Inject +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber + +@InjectWith(ActivityScope::class) +class NewSettingsActivity : DuckDuckGoActivity() { + + private val viewModel: NewSettingsViewModel by bindViewModel() + private val binding: ActivitySettingsNewBinding by viewBinding() + + @Inject + lateinit var pixel: Pixel + + @Inject + lateinit var internalFeaturePlugins: PluginPoint + + @Inject + lateinit var addWidgetLauncher: AddWidgetLauncher + + @Inject + lateinit var appBuildConfig: AppBuildConfig + + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + @Inject + lateinit var _proSettingsPlugin: PluginPoint + private val proSettingsPlugin by lazy { + _proSettingsPlugin.getPlugins() + } + + @Inject + lateinit var _duckPlayerSettingsPlugin: PluginPoint + private val duckPlayerSettingsPlugin by lazy { + _duckPlayerSettingsPlugin.getPlugins() + } + + private val viewsPrivacy + get() = binding.includeSettings.contentSettingsPrivacy + + private val viewsSettings + get() = binding.includeSettings.contentSettingsSettings + + private val viewsMore + get() = binding.includeSettings.contentSettingsMore + + private val viewsInternal + get() = binding.includeSettings.contentSettingsInternal + + private val viewsPro + get() = binding.includeSettings.settingsSectionPro + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setupToolbar(binding.includeToolbar.toolbar) + + configureUiEventHandlers() + configureInternalFeatures() + configureSettings() + lifecycle.addObserver(viewModel) + observeViewModel() + + intent?.getStringExtra(BrowserActivity.LAUNCH_FROM_NOTIFICATION_PIXEL_NAME)?.let { + viewModel.onLaunchedFromNotification(it) + } + } + + private fun configureUiEventHandlers() { + with(viewsPrivacy) { + setAsDefaultBrowserSetting.setClickListener { viewModel.onDefaultBrowserSettingClicked() } + privateSearchSetting.setClickListener { viewModel.onPrivateSearchSettingClicked() } + webTrackingProtectionSetting.setClickListener { viewModel.onWebTrackingProtectionSettingClicked() } + cookiePopupProtectionSetting.setClickListener { viewModel.onCookiePopupProtectionSettingClicked() } + emailSetting.setClickListener { viewModel.onEmailProtectionSettingClicked() } + vpnSetting.setClickListener { viewModel.onAppTPSettingClicked() } + } + + with(viewsSettings) { + homeScreenWidgetSetting.setClickListener { viewModel.userRequestedToAddHomeScreenWidget() } + autofillLoginsSetting.setClickListener { viewModel.onAutofillSettingsClick() } + syncSetting.setClickListener { viewModel.onSyncSettingClicked() } + fireButtonSetting.setClickListener { viewModel.onFireButtonSettingClicked() } + permissionsSetting.setClickListener { viewModel.onPermissionsSettingClicked() } + appearanceSetting.setClickListener { viewModel.onAppearanceSettingClicked() } + accessibilitySetting.setClickListener { viewModel.onAccessibilitySettingClicked() } + aboutSetting.setClickListener { viewModel.onAboutSettingClicked() } + generalSetting.setClickListener { viewModel.onGeneralSettingClicked() } + } + + with(viewsMore) { + macOsSetting.setClickListener { viewModel.onMacOsSettingClicked() } + windowsSetting.setClickListener { viewModel.windowsSettingClicked() } + } + } + + private fun configureSettings() { + if (proSettingsPlugin.isEmpty()) { + viewsPro.gone() + } else { + proSettingsPlugin.forEach { plugin -> + viewsPro.addView(plugin.getView(this)) + } + } + + if (duckPlayerSettingsPlugin.isEmpty()) { + viewsSettings.settingsSectionDuckPlayer.gone() + } else { + duckPlayerSettingsPlugin.forEach { plugin -> + viewsSettings.settingsSectionDuckPlayer.addView(plugin.getView(this)) + } + } + } + + private fun configureInternalFeatures() { + viewsInternal.settingsSectionInternal.visibility = if (internalFeaturePlugins.getPlugins().isEmpty()) View.GONE else View.VISIBLE + internalFeaturePlugins.getPlugins().forEach { feature -> + Timber.v("Adding internal feature ${feature.internalFeatureTitle()}") + val view = TwoLineListItem(this).apply { + setPrimaryText(feature.internalFeatureTitle()) + setSecondaryText(feature.internalFeatureSubtitle()) + } + viewsInternal.settingsInternalFeaturesContainer.addView(view) + view.setClickListener { feature.onInternalFeatureClicked(this) } + } + } + + private fun observeViewModel() { + viewModel.viewState() + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .distinctUntilChanged() + .onEach { viewState -> + viewState.let { + updateDefaultBrowserViewVisibility(it) + updateDeviceShieldSettings( + it.appTrackingProtectionEnabled, + ) + updateEmailSubtitle(it.emailAddress) + updateAutofill(it.showAutofill) + updateSyncSetting(visible = it.showSyncSetting) + updateAutoconsent(it.isAutoconsentEnabled) + updatePrivacyPro(it.isPrivacyProEnabled) + updateDuckPlayer(it.isDuckPlayerEnabled) + } + }.launchIn(lifecycleScope) + + viewModel.commands() + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun updatePrivacyPro(isPrivacyProEnabled: Boolean) { + if (isPrivacyProEnabled) { + pixel.fire(PRIVACY_PRO_IS_ENABLED_AND_ELIGIBLE, type = Daily()) + viewsPro.show() + } else { + viewsPro.gone() + } + } + + private fun updateDuckPlayer(isDuckPlayerEnabled: Boolean) { + if (isDuckPlayerEnabled) { + viewsSettings.settingsSectionDuckPlayer.show() + } else { + viewsSettings.settingsSectionDuckPlayer.gone() + } + } + + private fun updateAutofill(autofillEnabled: Boolean) = with(viewsSettings.autofillLoginsSetting) { + visibility = if (autofillEnabled) { + View.VISIBLE + } else { + View.GONE + } + } + + private fun updateEmailSubtitle(emailAddress: String?) { + viewsPrivacy.emailSetting.setStatus(isOn = !emailAddress.isNullOrEmpty()) + } + + private fun updateSyncSetting(visible: Boolean) { + with(viewsSettings.syncSetting) { + isVisible = visible + } + } + + private fun updateAutoconsent(enabled: Boolean) { + viewsPrivacy.cookiePopupProtectionSetting.setStatus(isOn = enabled) + } + + private fun processCommand(it: Command?) { + when (it) { + is Command.LaunchDefaultBrowser -> launchDefaultAppScreen() + is Command.LaunchAutofillSettings -> launchAutofillSettings() + is Command.LaunchAccessibilitySettings -> launchAccessibilitySettings() + is Command.LaunchAppTPTrackersScreen -> launchAppTPTrackersScreen() + is Command.LaunchAppTPOnboarding -> launchAppTPOnboardingScreen() + is Command.LaunchEmailProtection -> launchEmailProtectionScreen(it.url) + is Command.LaunchEmailProtectionNotSupported -> launchEmailProtectionNotSupported() + is Command.LaunchAddHomeScreenWidget -> launchAddHomeScreenWidget() + is Command.LaunchMacOs -> launchMacOsScreen() + is Command.LaunchWindows -> launchWindowsScreen() + is Command.LaunchSyncSettings -> launchSyncSettings() + is Command.LaunchPrivateSearchWebPage -> launchPrivateSearchScreen() + is Command.LaunchWebTrackingProtectionScreen -> launchWebTrackingProtectionScreen() + is Command.LaunchCookiePopupProtectionScreen -> launchCookiePopupProtectionScreen() + is Command.LaunchFireButtonScreen -> launchFireButtonScreen() + is Command.LaunchPermissionsScreen -> launchPermissionsScreen() + is Command.LaunchAppearanceScreen -> launchAppearanceScreen() + is Command.LaunchAboutScreen -> launchAboutScreen() + is Command.LaunchGeneralSettingsScreen -> launchGeneralSettingsScreen() + null -> TODO() + } + } + + private fun updateDefaultBrowserViewVisibility(it: NewSettingsViewModel.ViewState) { + with(viewsPrivacy.setAsDefaultBrowserSetting) { + visibility = if (it.showDefaultBrowserSetting) { + setStatus(isOn = it.isAppDefaultBrowser) + View.VISIBLE + } else { + View.GONE + } + } + } + + private fun updateDeviceShieldSettings(appTPEnabled: Boolean) { + viewsPrivacy.vpnSetting.setStatus(isOn = appTPEnabled) + } + + private fun launchDefaultAppScreen() { + launchDefaultAppActivity() + } + + private fun launchAutofillSettings() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AutofillSettingsScreen(source = AutofillSettingsLaunchSource.SettingsActivity), options) + } + + private fun launchAccessibilitySettings() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AccessibilityScreens.Default, options) + } + + private fun launchEmailProtectionScreen(url: String) { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + startActivity(BrowserActivity.intent(this, url, interstitialScreen = true), options) + this.finish() + } + + private fun launchEmailProtectionNotSupported() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, EmailProtectionUnsupportedScreenNoParams, options) + } + + private fun launchMacOsScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, MacOsScreenWithEmptyParams, options) + } + + private fun launchWindowsScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, WindowsScreenWithEmptyParams, options) + } + + private fun launchSyncSettings() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, SyncActivityWithEmptyParams, options) + } + + private fun launchAppTPTrackersScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AppTrackerActivityWithEmptyParams, options) + } + + private fun launchAppTPOnboardingScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AppTrackerOnboardingActivityWithEmptyParamsParams, options) + } + + private fun launchAddHomeScreenWidget() { + pixel.fire(AppPixelName.SETTINGS_ADD_HOME_SCREEN_WIDGET_CLICKED) + addWidgetLauncher.launchAddWidget(this) + } + + private fun launchPrivateSearchScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, PrivateSearchScreenNoParams, options) + } + + private fun launchWebTrackingProtectionScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, WebTrackingProtectionScreenNoParams, options) + } + + private fun launchCookiePopupProtectionScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + startActivity(AutoconsentSettingsActivity.intent(this), options) + } + + private fun launchFireButtonScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, FireButtonScreenNoParams, options) + } + + private fun launchPermissionsScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, PermissionsScreenNoParams, options) + } + + private fun launchAppearanceScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AppearanceScreen.Default, options) + } + + private fun launchAboutScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, AboutScreenNoParams, options) + } + + private fun launchGeneralSettingsScreen() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, GeneralSettingsScreenNoParams, options) + } + + companion object { + const val LAUNCH_FROM_NOTIFICATION_PIXEL_NAME = "LAUNCH_FROM_NOTIFICATION_PIXEL_NAME" + + fun intent(context: Context): Intent { + return Intent(context, NewSettingsActivity::class.java) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt new file mode 100644 index 000000000000..14d316c51289 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt @@ -0,0 +1,315 @@ +/* + * 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.settings + +import android.annotation.SuppressLint +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_ABOUT_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_ACCESSIBILITY_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_APPEARANCE_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_APPTP_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_COOKIE_POPUP_PROTECTION_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_DEFAULT_BROWSER_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_EMAIL_PROTECTION_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_FIRE_BUTTON_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_GENERAL_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_MAC_APP_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_OPENED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_PERMISSIONS_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_PRIVATE_SEARCH_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_SYNC_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_WEB_TRACKING_PROTECTION_PRESSED +import com.duckduckgo.app.pixels.AppPixelName.SETTINGS_WINDOWS_APP_PRESSED +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAboutScreen +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAccessibilitySettings +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAddHomeScreenWidget +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAppTPOnboarding +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAppTPTrackersScreen +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAppearanceScreen +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAutofillSettings +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchCookiePopupProtectionScreen +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchDefaultBrowser +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchEmailProtection +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchEmailProtectionNotSupported +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchFireButtonScreen +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchGeneralSettingsScreen +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchMacOs +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchPermissionsScreen +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchPrivateSearchWebPage +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchSyncSettings +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchWebTrackingProtectionScreen +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchWindows +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autoconsent.api.Autoconsent +import com.duckduckgo.autofill.api.AutofillCapabilityChecker +import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.common.utils.ConflatedJob +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED_WIH_HELP_LINK +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED +import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection +import com.duckduckgo.subscriptions.api.Subscriptions +import com.duckduckgo.sync.api.DeviceSyncState +import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +@SuppressLint("NoLifecycleObserver") +@ContributesViewModel(ActivityScope::class) +class NewSettingsViewModel @Inject constructor( + private val defaultWebBrowserCapability: DefaultBrowserDetector, + private val appTrackingProtection: AppTrackingProtection, + private val pixel: Pixel, + private val emailManager: EmailManager, + private val autofillCapabilityChecker: AutofillCapabilityChecker, + private val deviceSyncState: DeviceSyncState, + private val dispatcherProvider: DispatcherProvider, + private val autoconsent: Autoconsent, + private val subscriptions: Subscriptions, + private val duckPlayer: DuckPlayer, +) : ViewModel(), DefaultLifecycleObserver { + + data class ViewState( + val showDefaultBrowserSetting: Boolean = false, + val isAppDefaultBrowser: Boolean = false, + val appTrackingProtectionEnabled: Boolean = false, + val emailAddress: String? = null, + val showAutofill: Boolean = false, + val showSyncSetting: Boolean = false, + val isAutoconsentEnabled: Boolean = false, + val isPrivacyProEnabled: Boolean = false, + val isDuckPlayerEnabled: Boolean = false, + ) + + sealed class Command { + data object LaunchDefaultBrowser : Command() + data class LaunchEmailProtection(val url: String) : Command() + data object LaunchEmailProtectionNotSupported : Command() + data object LaunchAutofillSettings : Command() + data object LaunchAccessibilitySettings : Command() + data object LaunchAddHomeScreenWidget : Command() + data object LaunchAppTPTrackersScreen : Command() + data object LaunchAppTPOnboarding : Command() + data object LaunchMacOs : Command() + data object LaunchWindows : Command() + data object LaunchSyncSettings : Command() + data object LaunchPrivateSearchWebPage : Command() + data object LaunchWebTrackingProtectionScreen : Command() + data object LaunchCookiePopupProtectionScreen : Command() + data object LaunchFireButtonScreen : Command() + data object LaunchPermissionsScreen : Command() + data object LaunchAppearanceScreen : Command() + data object LaunchAboutScreen : Command() + data object LaunchGeneralSettingsScreen : Command() + } + + private val viewState = MutableStateFlow(ViewState()) + + private val command = Channel(1, BufferOverflow.DROP_OLDEST) + private val appTPPollJob = ConflatedJob() + + init { + pixel.fire(SETTINGS_OPENED) + } + + override fun onStart(owner: LifecycleOwner) { + super.onStart(owner) + start() + startPollingAppTPState() + } + + override fun onStop(owner: LifecycleOwner) { + super.onStop(owner) + appTPPollJob.cancel() + } + + @VisibleForTesting + internal fun start() { + val defaultBrowserAlready = defaultWebBrowserCapability.isDefaultBrowser() + + viewModelScope.launch { + viewState.emit( + currentViewState().copy( + isAppDefaultBrowser = defaultBrowserAlready, + showDefaultBrowserSetting = defaultWebBrowserCapability.deviceSupportsDefaultBrowserConfiguration(), + appTrackingProtectionEnabled = appTrackingProtection.isRunning(), + emailAddress = emailManager.getEmailAddress(), + showAutofill = autofillCapabilityChecker.canAccessCredentialManagementScreen(), + showSyncSetting = deviceSyncState.isFeatureEnabled(), + isAutoconsentEnabled = autoconsent.isSettingEnabled(), + isPrivacyProEnabled = subscriptions.isEligible(), + isDuckPlayerEnabled = duckPlayer.getDuckPlayerState().let { it == ENABLED || it == DISABLED_WIH_HELP_LINK }, + ), + ) + } + } + + // FIXME + // We need to fix this. This logic as inside the start method but it messes with the unit tests + // because when doing runningBlockingTest {} there is no delay and the tests crashes because this + // becomes a while(true) without any delay + private fun startPollingAppTPState() { + appTPPollJob += viewModelScope.launch(dispatcherProvider.io()) { + while (isActive) { + val isDeviceShieldEnabled = appTrackingProtection.isRunning() + val currentState = currentViewState() + viewState.value = currentState.copy( + appTrackingProtectionEnabled = isDeviceShieldEnabled, + isPrivacyProEnabled = subscriptions.isEligible(), + ) + delay(1_000) + } + } + } + + fun viewState(): StateFlow { + return viewState + } + + fun commands(): Flow { + return command.receiveAsFlow() + } + + fun userRequestedToAddHomeScreenWidget() { + viewModelScope.launch { command.send(LaunchAddHomeScreenWidget) } + } + + fun onDefaultBrowserSettingClicked() { + val defaultBrowserSelected = defaultWebBrowserCapability.isDefaultBrowser() + viewModelScope.launch { + viewState.emit(currentViewState().copy(isAppDefaultBrowser = defaultBrowserSelected)) + command.send(LaunchDefaultBrowser) + } + pixel.fire(SETTINGS_DEFAULT_BROWSER_PRESSED) + } + + fun onPrivateSearchSettingClicked() { + viewModelScope.launch { command.send(LaunchPrivateSearchWebPage) } + pixel.fire(SETTINGS_PRIVATE_SEARCH_PRESSED) + } + + fun onWebTrackingProtectionSettingClicked() { + viewModelScope.launch { command.send(LaunchWebTrackingProtectionScreen) } + pixel.fire(SETTINGS_WEB_TRACKING_PROTECTION_PRESSED) + } + + fun onCookiePopupProtectionSettingClicked() { + viewModelScope.launch { command.send(LaunchCookiePopupProtectionScreen) } + pixel.fire(SETTINGS_COOKIE_POPUP_PROTECTION_PRESSED) + } + + fun onAutofillSettingsClick() { + viewModelScope.launch { command.send(LaunchAutofillSettings) } + } + + fun onAccessibilitySettingClicked() { + viewModelScope.launch { command.send(LaunchAccessibilitySettings) } + pixel.fire(SETTINGS_ACCESSIBILITY_PRESSED) + } + + fun onAboutSettingClicked() { + viewModelScope.launch { command.send(LaunchAboutScreen) } + pixel.fire(SETTINGS_ABOUT_PRESSED) + } + + fun onGeneralSettingClicked() { + viewModelScope.launch { command.send(LaunchGeneralSettingsScreen) } + pixel.fire(SETTINGS_GENERAL_PRESSED) + } + + fun onEmailProtectionSettingClicked() { + viewModelScope.launch { + val command = if (emailManager.isEmailFeatureSupported()) { + LaunchEmailProtection(EMAIL_PROTECTION_URL) + } else { + LaunchEmailProtectionNotSupported + } + this@NewSettingsViewModel.command.send(command) + } + pixel.fire(SETTINGS_EMAIL_PROTECTION_PRESSED) + } + + fun onMacOsSettingClicked() { + viewModelScope.launch { command.send(LaunchMacOs) } + pixel.fire(SETTINGS_MAC_APP_PRESSED) + } + + fun windowsSettingClicked() { + viewModelScope.launch { + command.send(LaunchWindows) + } + pixel.fire(SETTINGS_WINDOWS_APP_PRESSED) + } + + fun onAppTPSettingClicked() { + viewModelScope.launch { + if (appTrackingProtection.isOnboarded()) { + command.send(LaunchAppTPTrackersScreen) + } else { + command.send(LaunchAppTPOnboarding) + } + pixel.fire(SETTINGS_APPTP_PRESSED) + } + } + + private fun currentViewState(): ViewState { + return viewState.value + } + + fun onSyncSettingClicked() { + viewModelScope.launch { command.send(LaunchSyncSettings) } + pixel.fire(SETTINGS_SYNC_PRESSED) + } + + fun onFireButtonSettingClicked() { + viewModelScope.launch { command.send(LaunchFireButtonScreen) } + pixel.fire(SETTINGS_FIRE_BUTTON_PRESSED) + } + + fun onPermissionsSettingClicked() { + viewModelScope.launch { command.send(LaunchPermissionsScreen) } + pixel.fire(SETTINGS_PERMISSIONS_PRESSED) + } + + fun onAppearanceSettingClicked() { + viewModelScope.launch { command.send(LaunchAppearanceScreen) } + pixel.fire(SETTINGS_APPEARANCE_PRESSED) + } + + fun onLaunchedFromNotification(pixelName: String) { + pixel.fire(pixelName) + } + + companion object { + const val EMAIL_PROTECTION_URL = "https://duckduckgo.com/email" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index 14e9a1f4f661..2414b4520263 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 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. @@ -16,421 +16,33 @@ package com.duckduckgo.app.settings -import android.app.ActivityOptions import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.View -import androidx.core.view.isVisible -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.about.AboutScreenNoParams -import com.duckduckgo.app.accessibility.AccessibilityScreens -import com.duckduckgo.app.appearance.AppearanceScreen -import com.duckduckgo.app.browser.BrowserActivity -import com.duckduckgo.app.browser.R -import com.duckduckgo.app.browser.databinding.ActivitySettingsBinding -import com.duckduckgo.app.email.ui.EmailProtectionUnsupportedScreenNoParams -import com.duckduckgo.app.firebutton.FireButtonScreenNoParams -import com.duckduckgo.app.generalsettings.GeneralSettingsScreenNoParams -import com.duckduckgo.app.global.view.launchDefaultAppActivity -import com.duckduckgo.app.permissions.PermissionsScreenNoParams -import com.duckduckgo.app.pixels.AppPixelName -import com.duckduckgo.app.pixels.AppPixelName.PRIVACY_PRO_IS_ENABLED_AND_ELIGIBLE -import com.duckduckgo.app.privatesearch.PrivateSearchScreenNoParams -import com.duckduckgo.app.settings.SettingsViewModel.Command -import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily -import com.duckduckgo.app.webtrackingprotection.WebTrackingProtectionScreenNoParams -import com.duckduckgo.app.widget.AddWidgetLauncher -import com.duckduckgo.appbuildconfig.api.AppBuildConfig -import com.duckduckgo.autoconsent.impl.ui.AutoconsentSettingsActivity -import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreen -import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource import com.duckduckgo.browser.api.ui.BrowserScreens.SettingsScreenNoParams import com.duckduckgo.common.ui.DuckDuckGoActivity -import com.duckduckgo.common.ui.view.gone -import com.duckduckgo.common.ui.view.listitem.CheckListItem -import com.duckduckgo.common.ui.view.listitem.TwoLineListItem -import com.duckduckgo.common.ui.view.show -import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.internal.features.api.InternalFeaturePlugin -import com.duckduckgo.macos.api.MacOsScreenWithEmptyParams -import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerActivityWithEmptyParams -import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams -import com.duckduckgo.navigation.api.GlobalActivityStarter -import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin -import com.duckduckgo.settings.api.ProSettingsPlugin -import com.duckduckgo.sync.api.SyncActivityWithEmptyParams -import com.duckduckgo.windows.api.ui.WindowsScreenWithEmptyParams +import com.duckduckgo.settings.api.NewSettingsFeature import javax.inject.Inject -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import timber.log.Timber @InjectWith(ActivityScope::class) @ContributeToActivityStarter(SettingsScreenNoParams::class, screenName = "settings") class SettingsActivity : DuckDuckGoActivity() { - private val viewModel: SettingsViewModel by bindViewModel() - private val binding: ActivitySettingsBinding by viewBinding() - - @Inject - lateinit var pixel: Pixel - - @Inject - lateinit var internalFeaturePlugins: PluginPoint - - @Inject - lateinit var addWidgetLauncher: AddWidgetLauncher - - @Inject - lateinit var appBuildConfig: AppBuildConfig - - @Inject - lateinit var globalActivityStarter: GlobalActivityStarter - - @Inject - lateinit var _proSettingsPlugin: PluginPoint - private val proSettingsPlugin by lazy { - _proSettingsPlugin.getPlugins() - } - @Inject - lateinit var _duckPlayerSettingsPlugin: PluginPoint - private val duckPlayerSettingsPlugin by lazy { - _duckPlayerSettingsPlugin.getPlugins() - } - - private val viewsPrivacy - get() = binding.includeSettings.contentSettingsPrivacy - - private val viewsSettings - get() = binding.includeSettings.contentSettingsSettings - - private val viewsMore - get() = binding.includeSettings.contentSettingsMore - - private val viewsInternal - get() = binding.includeSettings.contentSettingsInternal - - private val viewsPro - get() = binding.includeSettings.settingsSectionPro + lateinit var newSettingsFeature: NewSettingsFeature override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(binding.root) - setupToolbar(binding.includeToolbar.toolbar) - - configureUiEventHandlers() - configureInternalFeatures() - configureSettings() - lifecycle.addObserver(viewModel) - observeViewModel() - - intent?.getStringExtra(BrowserActivity.LAUNCH_FROM_NOTIFICATION_PIXEL_NAME)?.let { - viewModel.onLaunchedFromNotification(it) - } - } - - private fun configureUiEventHandlers() { - with(viewsPrivacy) { - setAsDefaultBrowserSetting.setClickListener { viewModel.onDefaultBrowserSettingClicked() } - privateSearchSetting.setClickListener { viewModel.onPrivateSearchSettingClicked() } - webTrackingProtectionSetting.setClickListener { viewModel.onWebTrackingProtectionSettingClicked() } - cookiePopupProtectionSetting.setClickListener { viewModel.onCookiePopupProtectionSettingClicked() } - emailSetting.setClickListener { viewModel.onEmailProtectionSettingClicked() } - vpnSetting.setClickListener { viewModel.onAppTPSettingClicked() } - } - - with(viewsSettings) { - homeScreenWidgetSetting.setClickListener { viewModel.userRequestedToAddHomeScreenWidget() } - autofillLoginsSetting.setClickListener { viewModel.onAutofillSettingsClick() } - syncSetting.setClickListener { viewModel.onSyncSettingClicked() } - fireButtonSetting.setClickListener { viewModel.onFireButtonSettingClicked() } - permissionsSetting.setClickListener { viewModel.onPermissionsSettingClicked() } - appearanceSetting.setClickListener { viewModel.onAppearanceSettingClicked() } - accessibilitySetting.setClickListener { viewModel.onAccessibilitySettingClicked() } - aboutSetting.setClickListener { viewModel.onAboutSettingClicked() } - generalSetting.setClickListener { viewModel.onGeneralSettingClicked() } - } - - with(viewsMore) { - macOsSetting.setClickListener { viewModel.onMacOsSettingClicked() } - windowsSetting.setClickListener { viewModel.windowsSettingClicked() } - } - } - - private fun configureSettings() { - if (proSettingsPlugin.isEmpty()) { - viewsPro.gone() - } else { - proSettingsPlugin.forEach { plugin -> - viewsPro.addView(plugin.getView(this)) - } - } - - if (duckPlayerSettingsPlugin.isEmpty()) { - viewsSettings.settingsSectionDuckPlayer.gone() - } else { - duckPlayerSettingsPlugin.forEach { plugin -> - viewsSettings.settingsSectionDuckPlayer.addView(plugin.getView(this)) - } - } - } - - private fun configureInternalFeatures() { - viewsInternal.settingsSectionInternal.visibility = if (internalFeaturePlugins.getPlugins().isEmpty()) View.GONE else View.VISIBLE - internalFeaturePlugins.getPlugins().forEach { feature -> - Timber.v("Adding internal feature ${feature.internalFeatureTitle()}") - val view = TwoLineListItem(this).apply { - setPrimaryText(feature.internalFeatureTitle()) - setSecondaryText(feature.internalFeatureSubtitle()) - } - viewsInternal.settingsInternalFeaturesContainer.addView(view) - view.setClickListener { feature.onInternalFeatureClicked(this) } - } - } - - private fun observeViewModel() { - viewModel.viewState() - .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) - .distinctUntilChanged() - .onEach { viewState -> - viewState.let { - updateDefaultBrowserViewVisibility(it) - updateDeviceShieldSettings( - it.appTrackingProtectionEnabled, - it.appTrackingProtectionOnboardingShown, - ) - updateEmailSubtitle(it.emailAddress) - updateAutofill(it.showAutofill) - updateSyncSetting(visible = it.showSyncSetting) - updateAutoconsent(it.isAutoconsentEnabled) - updatePrivacyPro(it.isPrivacyProEnabled) - updateDuckPlayer(it.isDuckPlayerEnabled) - } - }.launchIn(lifecycleScope) - - viewModel.commands() - .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) - .onEach { processCommand(it) } - .launchIn(lifecycleScope) - } - - private fun updatePrivacyPro(isPrivacyProEnabled: Boolean) { - if (isPrivacyProEnabled) { - pixel.fire(PRIVACY_PRO_IS_ENABLED_AND_ELIGIBLE, type = Daily()) - viewsPro.show() - } else { - viewsPro.gone() - } - } - - private fun updateDuckPlayer(isDuckPlayerEnabled: Boolean) { - if (isDuckPlayerEnabled) { - viewsSettings.settingsSectionDuckPlayer.show() + if (newSettingsFeature.self().isEnabled()) { + startActivity(NewSettingsActivity.intent(this)) } else { - viewsSettings.settingsSectionDuckPlayer.gone() + startActivity(LegacySettingsActivity.intent(this)) } - } - - private fun updateAutofill(autofillEnabled: Boolean) = with(viewsSettings.autofillLoginsSetting) { - visibility = if (autofillEnabled) { - View.VISIBLE - } else { - View.GONE - } - } - - private fun updateEmailSubtitle(emailAddress: String?) { - if (emailAddress.isNullOrEmpty()) { - viewsPrivacy.emailSetting.setSecondaryText(getString(R.string.settingsEmailProtectionSubtitle)) - viewsPrivacy.emailSetting.setItemStatus(CheckListItem.CheckItemStatus.DISABLED) - } else { - viewsPrivacy.emailSetting.setSecondaryText(emailAddress) - viewsPrivacy.emailSetting.setItemStatus(CheckListItem.CheckItemStatus.ENABLED) - } - } - - private fun updateSyncSetting(visible: Boolean) { - with(viewsSettings.syncSetting) { - isVisible = visible - } - } - - private fun updateAutoconsent(enabled: Boolean) { - if (enabled) { - viewsPrivacy.cookiePopupProtectionSetting.setSecondaryText(getString(R.string.cookiePopupProtectionEnabled)) - viewsPrivacy.cookiePopupProtectionSetting.setItemStatus(CheckListItem.CheckItemStatus.ENABLED) - } else { - viewsPrivacy.cookiePopupProtectionSetting.setSecondaryText(getString(R.string.cookiePopupProtectionDescription)) - viewsPrivacy.cookiePopupProtectionSetting.setItemStatus(CheckListItem.CheckItemStatus.DISABLED) - } - } - - private fun processCommand(it: Command?) { - when (it) { - is Command.LaunchDefaultBrowser -> launchDefaultAppScreen() - is Command.LaunchAutofillSettings -> launchAutofillSettings() - is Command.LaunchAccessibilitySettings -> launchAccessibilitySettings() - is Command.LaunchAppTPTrackersScreen -> launchAppTPTrackersScreen() - is Command.LaunchAppTPOnboarding -> launchAppTPOnboardingScreen() - is Command.LaunchEmailProtection -> launchEmailProtectionScreen(it.url) - is Command.LaunchEmailProtectionNotSupported -> launchEmailProtectionNotSupported() - is Command.LaunchAddHomeScreenWidget -> launchAddHomeScreenWidget() - is Command.LaunchMacOs -> launchMacOsScreen() - is Command.LaunchWindows -> launchWindowsScreen() - is Command.LaunchSyncSettings -> launchSyncSettings() - is Command.LaunchPrivateSearchWebPage -> launchPrivateSearchScreen() - is Command.LaunchWebTrackingProtectionScreen -> launchWebTrackingProtectionScreen() - is Command.LaunchCookiePopupProtectionScreen -> launchCookiePopupProtectionScreen() - is Command.LaunchFireButtonScreen -> launchFireButtonScreen() - is Command.LaunchPermissionsScreen -> launchPermissionsScreen() - is Command.LaunchAppearanceScreen -> launchAppearanceScreen() - is Command.LaunchAboutScreen -> launchAboutScreen() - is Command.LaunchGeneralSettingsScreen -> launchGeneralSettingsScreen() - null -> TODO() - } - } - - private fun updateDefaultBrowserViewVisibility(it: SettingsViewModel.ViewState) { - with(viewsPrivacy.setAsDefaultBrowserSetting) { - visibility = if (it.showDefaultBrowserSetting) { - if (it.isAppDefaultBrowser) { - setItemStatus(CheckListItem.CheckItemStatus.ENABLED) - setSecondaryText(getString(R.string.settingsDefaultBrowserSetDescription)) - } else { - setItemStatus(CheckListItem.CheckItemStatus.DISABLED) - setSecondaryText(getString(R.string.settingsDefaultBrowserNotSetDescription)) - } - View.VISIBLE - } else { - View.GONE - } - } - } - - private fun updateDeviceShieldSettings( - appTPEnabled: Boolean, - appTrackingProtectionOnboardingShown: Boolean, - ) { - with(viewsPrivacy) { - if (appTPEnabled) { - vpnSetting.setSecondaryText(getString(R.string.atp_SettingsDeviceShieldEnabled)) - vpnSetting.setItemStatus(CheckListItem.CheckItemStatus.ENABLED) - } else { - if (appTrackingProtectionOnboardingShown) { - vpnSetting.setSecondaryText(getString(R.string.atp_SettingsDeviceShieldDisabled)) - vpnSetting.setItemStatus(CheckListItem.CheckItemStatus.WARNING) - } else { - vpnSetting.setSecondaryText(getString(R.string.atp_SettingsDeviceShieldNeverEnabled)) - vpnSetting.setItemStatus(CheckListItem.CheckItemStatus.DISABLED) - } - } - } - } - - private fun launchDefaultAppScreen() { - launchDefaultAppActivity() - } - - private fun launchAutofillSettings() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, AutofillSettingsScreen(source = AutofillSettingsLaunchSource.SettingsActivity), options) - } - - private fun launchAccessibilitySettings() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, AccessibilityScreens.Default, options) - } - - private fun launchEmailProtectionScreen(url: String) { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - startActivity(BrowserActivity.intent(this, url, interstitialScreen = true), options) - this.finish() - } - - private fun launchEmailProtectionNotSupported() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, EmailProtectionUnsupportedScreenNoParams, options) - } - - private fun launchMacOsScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, MacOsScreenWithEmptyParams, options) - } - - private fun launchWindowsScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, WindowsScreenWithEmptyParams, options) - } - - private fun launchSyncSettings() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, SyncActivityWithEmptyParams, options) - } - - private fun launchAppTPTrackersScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, AppTrackerActivityWithEmptyParams, options) - } - - private fun launchAppTPOnboardingScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, AppTrackerOnboardingActivityWithEmptyParamsParams, options) - } - - private fun launchAddHomeScreenWidget() { - pixel.fire(AppPixelName.SETTINGS_ADD_HOME_SCREEN_WIDGET_CLICKED) - addWidgetLauncher.launchAddWidget(this) - } - - private fun launchPrivateSearchScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, PrivateSearchScreenNoParams, options) - } - - private fun launchWebTrackingProtectionScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, WebTrackingProtectionScreenNoParams, options) - } - - private fun launchCookiePopupProtectionScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - startActivity(AutoconsentSettingsActivity.intent(this), options) - } - - private fun launchFireButtonScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, FireButtonScreenNoParams, options) - } - - private fun launchPermissionsScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, PermissionsScreenNoParams, options) - } - - private fun launchAppearanceScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, AppearanceScreen.Default, options) - } - - private fun launchAboutScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, AboutScreenNoParams, options) - } - - private fun launchGeneralSettingsScreen() { - val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - globalActivityStarter.start(this, GeneralSettingsScreenNoParams, options) + finish() } companion object { diff --git a/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt index caa0cb4caa9f..09cb34c742eb 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.tabs.model.TabSelectionEntity import com.duckduckgo.common.utils.swap import com.duckduckgo.di.scopes.AppScope import dagger.SingleInstanceIn +import java.time.LocalDateTime import kotlinx.coroutines.flow.Flow @Dao @@ -165,6 +166,12 @@ abstract class TabsDao { return tabs().lastOrNull() } + @Query("update tabs set lastAccessTime=:lastAccessTime where tabId=:tabId") + abstract fun updateTabLastAccess( + tabId: String, + lastAccessTime: LocalDateTime, + ) + @Query("update tabs set url=:url, title=:title, viewed=:viewed where tabId=:tabId") abstract fun updateUrlAndTitle( tabId: String, 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 0338a282c400..06e8cce5c1ad 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 @@ -30,6 +30,7 @@ import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType import com.duckduckgo.app.tabs.model.TabSwitcherData.UserState import com.duckduckgo.app.tabs.store.TabSwitcherDataStore import com.duckduckgo.common.utils.ConflatedJob +import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import dagger.SingleInstanceIn @@ -55,6 +56,7 @@ class TabDataRepository @Inject constructor( private val webViewPreviewPersister: WebViewPreviewPersister, private val faviconManager: FaviconManager, private val tabSwitcherDataStore: TabSwitcherDataStore, + private val timeProvider: CurrentTimeProvider, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatchers: DispatcherProvider, ) : TabRepository { @@ -192,6 +194,19 @@ class TabDataRepository @Inject constructor( tabSwitcherDataStore.setTabLayoutType(layoutType) } + override fun getOpenTabCount(): Int { + return tabsDao.tabs().size + } + + override fun countTabsAccessedWithinRange(accessOlderThan: Long, accessNotMoreThan: Long?): Int { + val now = timeProvider.localDateTimeNow() + val start = now.minusDays(accessOlderThan) + val end = accessNotMoreThan?.let { now.minusDays(it).minusSeconds(1) } // subtracted a second to make the end limit inclusive + return tabsDao.tabs().filter { + it.lastAccessTime?.isBefore(start) == true && (end == null || it.lastAccessTime?.isAfter(end) == true) + }.size + } + override suspend fun addNewTabAfterExistingTab( url: String?, tabId: String, @@ -228,6 +243,12 @@ class TabDataRepository @Inject constructor( } } + override suspend fun updateTabLastAccess(tabId: String) { + databaseExecutor().scheduleDirect { + tabsDao.updateTabLastAccess(tabId, timeProvider.localDateTimeNow()) + } + } + override fun retrieveSiteData(tabId: String): MutableLiveData { val storedData = siteData[tabId] if (storedData != null) { @@ -335,8 +356,7 @@ class TabDataRepository @Inject constructor( Timber.w("Cannot find tab for tab ID") return@scheduleDirect } - tab.tabPreviewFile = fileName - tabsDao.updateTab(tab) + tabsDao.updateTab(tab.copy(tabPreviewFile = fileName)) Timber.i("Updated tab preview image. $tabId now uses $fileName") deleteOldPreviewImages(tabId, fileName) diff --git a/app/src/main/java/com/duckduckgo/app/tabs/store/TabStatsBucketing.kt b/app/src/main/java/com/duckduckgo/app/tabs/store/TabStatsBucketing.kt new file mode 100644 index 000000000000..d31686965504 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/tabs/store/TabStatsBucketing.kt @@ -0,0 +1,112 @@ +/* + * 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.tabs.store + +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.ACTIVITY_BUCKETS +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.ONE_WEEK_IN_DAYS +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.TAB_COUNT_BUCKETS +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.THREE_WEEKS_IN_DAYS +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.TWO_WEEKS_IN_DAYS +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface TabStatsBucketing { + suspend fun getNumberOfOpenTabs(): String + suspend fun getTabsActiveLastWeek(): String + suspend fun getTabsActiveOneWeekAgo(): String + suspend fun getTabsActiveTwoWeeksAgo(): String + suspend fun getTabsActiveMoreThanThreeWeeksAgo(): String + + companion object { + const val ONE_WEEK_IN_DAYS = 7L + const val TWO_WEEKS_IN_DAYS = 14L + const val THREE_WEEKS_IN_DAYS = 21L + + val TAB_COUNT_BUCKETS = listOf( + 0..1, + 2..5, + 6..10, + 11..20, + 21..40, + 41..60, + 61..80, + 81..100, + 101..125, + 126..150, + 151..250, + 251..500, + 501..Int.MAX_VALUE, + ) + + val ACTIVITY_BUCKETS = listOf( + 0..0, + 1..5, + 6..10, + 11..20, + 21..Int.MAX_VALUE, + ) + } +} + +@ContributesBinding(AppScope::class) +class DefaultTabStatsBucketing @Inject constructor( + private val tabRepository: TabRepository, +) : TabStatsBucketing { + override suspend fun getNumberOfOpenTabs(): String { + val count = tabRepository.getOpenTabCount() + return getBucketLabel(count, TAB_COUNT_BUCKETS) + } + + override suspend fun getTabsActiveLastWeek(): String { + val count = tabRepository.countTabsAccessedWithinRange(accessOlderThan = 0, accessNotMoreThan = ONE_WEEK_IN_DAYS) + return getBucketLabel(count, ACTIVITY_BUCKETS) + } + + override suspend fun getTabsActiveOneWeekAgo(): String { + val count = tabRepository.countTabsAccessedWithinRange(accessOlderThan = ONE_WEEK_IN_DAYS, accessNotMoreThan = TWO_WEEKS_IN_DAYS) + return getBucketLabel(count, ACTIVITY_BUCKETS) + } + + override suspend fun getTabsActiveTwoWeeksAgo(): String { + val count = tabRepository.countTabsAccessedWithinRange(accessOlderThan = TWO_WEEKS_IN_DAYS, accessNotMoreThan = THREE_WEEKS_IN_DAYS) + return getBucketLabel(count, ACTIVITY_BUCKETS) + } + + override suspend fun getTabsActiveMoreThanThreeWeeksAgo(): String { + val count = tabRepository.countTabsAccessedWithinRange(accessOlderThan = THREE_WEEKS_IN_DAYS) + return getBucketLabel(count, ACTIVITY_BUCKETS) + } + + private fun getBucketLabel(count: Int, buckets: List): String { + val bucket = buckets.first { bucket -> + count in bucket + } + return when (bucket) { + buckets.first() -> { + bucket.last.toString() + } + buckets.last() -> { + "${bucket.first}+" + } + else -> { + "${bucket.first}-${bucket.last}" + } + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt b/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt index 9065dc1de6b8..e5548f49d77d 100644 --- a/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt +++ b/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt @@ -20,8 +20,8 @@ import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope import com.duckduckgo.app.browser.R -import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -29,25 +29,27 @@ import com.duckduckgo.savedsites.api.SavedSitesRepository import com.duckduckgo.widget.SearchAndFavoritesWidget import dagger.SingleInstanceIn import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @SingleInstanceIn(AppScope::class) class FavoritesObserver @Inject constructor( context: Context, private val savedSitesRepository: SavedSitesRepository, - @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, ) : MainProcessLifecycleObserver { - private val instance = AppWidgetManager.getInstance(context) + private val appWidgetManager: AppWidgetManager? by lazy { + AppWidgetManager.getInstance(context) + } private val componentName = ComponentName(context, SearchAndFavoritesWidget::class.java) override fun onStart(owner: LifecycleOwner) { - appCoroutineScope.launch(dispatcherProvider.io()) { - savedSitesRepository.getFavorites().collect { - instance.notifyAppWidgetViewDataChanged(instance.getAppWidgetIds(componentName), R.id.favoritesGrid) - instance.notifyAppWidgetViewDataChanged(instance.getAppWidgetIds(componentName), R.id.emptyfavoritesGrid) + owner.lifecycle.coroutineScope.launch(dispatcherProvider.io()) { + appWidgetManager?.let { instance -> + savedSitesRepository.getFavorites().collect { + instance.notifyAppWidgetViewDataChanged(instance.getAppWidgetIds(componentName), R.id.favoritesGrid) + instance.notifyAppWidgetViewDataChanged(instance.getAppWidgetIds(componentName), R.id.emptyfavoritesGrid) + } } } } diff --git a/app/src/main/res/layout/activity_settings_new.xml b/app/src/main/res/layout/activity_settings_new.xml new file mode 100644 index 000000000000..b666d07abafd --- /dev/null +++ b/app/src/main/res/layout/activity_settings_new.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/content_settings_new.xml b/app/src/main/res/layout/content_settings_new.xml new file mode 100644 index 000000000000..b66805d5afaf --- /dev/null +++ b/app/src/main/res/layout/content_settings_new.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/content_settings_new_privacy.xml b/app/src/main/res/layout/content_settings_new_privacy.xml new file mode 100644 index 000000000000..f8a7b076896a --- /dev/null +++ b/app/src/main/res/layout/content_settings_new_privacy.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_browser_tab.xml b/app/src/main/res/layout/fragment_browser_tab.xml index 39e00656717a..6a1ec794923c 100644 --- a/app/src/main/res/layout/fragment_browser_tab.xml +++ b/app/src/main/res/layout/fragment_browser_tab.xml @@ -96,6 +96,11 @@ android:id="@+id/includeOnboardingDaxDialogExperiment" layout="@layout/include_onboarding_view_dax_dialog_experiment" android:visibility="gone" /> + + Може би по-късно Не питай отново за този сайт Давате ли разрешение на %1$s за достъп до местоположението? - Използваме анонимното Ви местоположение само за да Ви осигурим по-добри резултати близо до мястото, на което се намирате. Можете винаги да промените решението си по-късно. В Настройки можете да управлявате разрешенията за достъп до местоположението, които сте дали на отделни сайтове. + Използваме анонимното Ви местоположение само за да Ви осигурим по-добри резултати близо до мястото, на което се намирате. В Настройки можете да управлявате разрешенията за достъп до местоположението, които сте дали на отделни сайтове. Винаги Само за тази сесия Винаги да се отказва @@ -673,10 +673,10 @@ Все още няма сайтове Разрешенията са премахнати за всички сайтове Разрешения за \"%1$s\" - Питане + Питай всеки път Отказвам Разрешавам - Опцията „Питай“ е деактивирана за всички сайтове + Деактивирано за всички сайтове %1$s разрешение за %2$s diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 2881a8d89b96..5a5287a8b754 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -547,8 +547,8 @@ Možná později Neptat se znovu u těchto stránek Udělit %1$s povolení k přístupu k poloze? - Vaši anonymní polohu využíváme pouze k dosažení lepších výsledků, blíže k vám. Vždycky si to můžete později rozmyslet. Oprávnění k přístupu k poloze, která jste udělili jednotlivým webovým stránkám, můžete spravovat v Nastavení. + Tvou anonymní polohu používáme jen k tomu, abychom ti mohli poskytovat lepší výsledky v tvojí blízkosti. Oprávnění k přístupu k poloze udělené jednotlivým webovým stránkám můžeš spravovat v Nastaveních. Vždy Pouze pro tuto relaci Vždy odmítnout @@ -673,10 +673,10 @@ Zatím žádné weby Oprávnění odebrána všem webům Oprávnění „%1$s“ - Zeptat se + Vždycky se ptát Odmítnout Povolit - Možnost „Zeptat se“ je u všech webů vypnutá + Zakázáno pro všechny weby %1$s – povolení pro web %2$s diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4b691ab38862..cd035481133f 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -547,8 +547,8 @@ Måske senere Spørg ikke igen for dette websted Vil du give %1$s tilladelse til at få adgang til placering? - Vi bruger kun din anonyme placering til at levere bedre resultater tættere på dig. Du kan altid skifte mening senere. Du kan administrere de tilladelser til placeringsadgang, du har givet til individuelle websteder under Indstillinger. + Vi bruger kun din anonyme placering til at levere bedre resultater tættere på dig. Du kan administrere de tilladelser til placeringsadgang, du har givet til individuelle websteder under Indstillinger. Altid Kun for denne session Afvis altid @@ -673,10 +673,10 @@ Ingen websteder endnu Tilladelser fjernet for alle websteder Tilladelser til \"%1$s\" - Spørg + Spørg hver gang Afvis Tillad - \"Spørg\" er deaktiveret for alle websteder + Deaktiveret for alle websteder %1$s tilladelse til %2$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7df22371ecbc..8a50a12ea366 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -547,8 +547,8 @@ Vielleicht später Für diese Website nicht erneut fragen %1$s Zugriffsberechtigung auf den Gerätestandort erteilen? - Wir verwenden deinen anonymen Standort nur, um dir genauere, standortbasierte Ergebnisse zu liefern. Diese Einstellung kann jederzeit rückgängig gemacht werden. Du kannst die Berechtigungen in den Einstellungen verwalten. Dort siehst du, welchen Websites du Zugriffsberechtigungen auf den Gerätestandort gewährt hast. + Wir verwenden deinen anonymen Standort nur, um dir bessere Ergebnisse in deiner Nähe zu liefern. Du kannst die Berechtigungen in den Einstellungen verwalten. Dort siehst du, welchen Websites du Zugriffsberechtigungen auf den Gerätestandort gewährt hast. Immer Nur für diese Sitzung Immer ablehnen @@ -673,10 +673,10 @@ Noch keine Websites Berechtigungen für alle Websites entfernt Berechtigungen für „%1$s“ - Fragen + Jedes Mal fragen Ablehnen Zulassen - „Fragen“ für alle Seiten deaktiviert + Für alle Websites deaktiviert %1$s-Berechtigung für %2$s diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 5240e9eef78d..08b2a5d502f8 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -547,8 +547,8 @@ Ίσως αργότερα Να μην γίνει ξανά ερώτηση γι\' αυτόν τον ιστότοπο Εκχώρηση άδειας στην εφαρμογή %1$s για πρόσβαση στην τοποθεσία; - Χρησιμοποιούμε την ανώνυμη τοποθεσία σας αποκλειστικά και μόνο για να προσφέρουμε καλύτερα αποτελέσματα, πιο κοντά σε σας. Μπορείτε πάντα να αλλάξετε γνώμη αργότερα. Μπορείτε να διαχειριστείτε τα δικαιώματα πρόσβασης τοποθεσίας που έχετε εκχωρήσει σε μεμονωμένους ιστότοπους από τις Ρυθμίσεις. + Χρησιμοποιούμε την ανώνυμη τοποθεσία σας αποκλειστικά και μόνο για να προσφέρουμε καλύτερα αποτελέσματα, πιο κοντά σε εσάς. Μπορείτε να διαχειριστείτε τα δικαιώματα πρόσβασης τοποθεσίας που έχετε εκχωρήσει σε μεμονωμένους ιστότοπους από τις Ρυθμίσεις. Πάντα Μόνο γι\' αυτή τη σύνδεση Άρνηση για πάντα @@ -673,10 +673,10 @@ Δεν υπάρχουν ακόμη ιστότοποι Τα δικαιώματα καταργήθηκαν για όλους τους ιστότοπους Δικαιώματα για «%1$s» - Αιτηθείτε + Να γίνεται ερώτηση κάθε φορά Απόρριψη Αποδοχή - Η επιλογή «Να γίνεται ερώτηση» είναι απενεργοποιημένη για όλους τους ιστότοπους + Απενεργοποιήθηκε για όλους τους ιστότοπους %1$s άδεια για %2$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3306e8ef0199..708aad738017 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -547,8 +547,8 @@ Quizá más tarde No volver a preguntar para este sitio ¿Permitir a %1$s acceder a la ubicación? - Solo utilizamos tu ubicación anónima para ofrecer mejores resultados, más cerca de ti. Siempre puedes cambiar de opinión más tarde. Puedes gestionar los permisos de acceso a la ubicación que has concedido a sitios específicos en Ajustes. + Solo utilizamos tu ubicación anónima para ofrecer mejores resultados, más cerca de ti. Puedes gestionar los permisos de acceso a la ubicación que has concedido a sitios específicos en Ajustes. Siempre Solo para esta sesión Rechazar siempre @@ -673,10 +673,10 @@ Aún no hay sitios Permisos eliminados para todos los sitios Permisos para \"%1$s\" - Solicitar + Preguntar cada vez Rechazar Permitir - \"Preguntar\" desactivado para todos los sitios + Desactivado para todos los sitios %1$s permiso para %2$s diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9c46215d5951..0702ce97fae7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -547,8 +547,8 @@ Võib-olla hiljem Ära küsi enam selle saidi jaoks Anda %1$s luba asukohale juurdepääsuks? - Kasutame sinu anonüümset asukohta ainult sulle paremate tulemuste pakkumiseks. Hiljem saad alati oma meelt muuta. Saad hallata asukohale juurdepääsu luba, mille sa oled andnud üksikutele saitidele valikus Seaded. + Kasutame sinu anonüümset asukohta ainult selleks, et pakkuda paremaid tulemusi, mis on sulle lähemal. Saad hallata asukohale juurdepääsu luba, mille sa oled andnud üksikutele saitidele valikus Seaded. Alati Ainult selle seansi jaoks Keela alati @@ -673,10 +673,10 @@ Saite pole veel Kõikide saitide load on eemaldatud Saidi „%1$s“ load - Küsi + Küsi iga kord Keela Luba - „Küsi“ on kõigil saitidel keelatud + Kõigi saitide jaoks keelatud Luba %1$s saidile %2$s diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index a7fe8b6158ed..09f65885e8bb 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -547,8 +547,8 @@ Ehkä myöhemmin Älä kysy uudelleen tälle sivustolle Annetaanko palvelulle %1$s lupa nähdä sijaintisi? - Käytämme anonyymiä sijaintia pelkästään tarjotaksemme parempia tuloksia lähempää sijaintiasi. Voit aina muuttaa mieltäsi myöhemmin. Voit hallita yksittäisille sivustoille antamiasi sijaintilupia asetuksista. + Käytämme anonyymiä sijaintia pelkästään tarjotaksemme parempia tuloksia lähempää sijaintiasi. Voit hallita yksittäisille sivustoille antamiasi sijaintilupia asetuksista. Aina Vain tässä istunnossa Kiellä aina @@ -673,10 +673,10 @@ Ei vielä yhtään sivustoa Käyttöoikeudet poistettu kaikilta sivustoilta Oikeudet: %1$s - Kysy + Kysy joka kerta Estä Salli - Kysy-toiminto poistettu käytöstä kaikilta sivustoilta + Poistettu käytöstä kaikilla sivustoilla %1$s: käyttöoikeus – lupa %2$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a7785e38d138..b2971785ac5c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -547,8 +547,8 @@ Peut-être plus tard Ne plus demander pour ce site Autoriser %1$s à accéder à la localisation ? - Nous n\'utilisons votre localisation anonyme que pour vous fournir de meilleurs résultats, plus proches de vous. Vous pouvez modifier ce réglage plus tard. Vous pouvez gérer les autorisations d\'accès à la localisation accordées aux différents sites depuis les paramètres. + Nous n\'utilisons votre localisation anonyme que pour vous fournir de meilleurs résultats, plus proches de vous. Vous pouvez gérer les autorisations d\'accès à la localisation accordées aux différents sites depuis les paramètres. Toujours Seulement pour cette session Toujours refuser @@ -673,10 +673,10 @@ Pas encore de sites Autorisations supprimées pour tous les sites Autorisations pour « %1$s » - Demander + Toujours demander Refuser Autoriser - « Demander » désactivé pour tous les sites + Désactivé pour tous les sites Autorisation de %1$s pour %2$s diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index fd77cc12603d..195c427c8a9d 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -547,8 +547,8 @@ Možda kasnije Ne pitaj ponovno za ovo web-mjesto Dati %1$s dopuštenje za pristup lokaciji? - Vašu anonimnu lokaciju upotrebljavamo samo da bismo vam pružili bolje rezultate, bliže vašoj lokaciji. Uvijek se kasnije možete predomisliti. U postavkama možete upravljati dozvolama za pristup lokaciji koje ste dodijelili pojedinim web-mjestima. + Tvoju anonimnu lokaciju koristimo samo za postizanje boljih rezultata, bliže tebi. U postavkama možeš upravljati dozvolama za pristup lokaciji koje si dodijelio pojedinim web-mjestima. Uvijek Samo za ovu sesiju Uvijek odbij @@ -673,10 +673,10 @@ Još nema ni jedne web-lokacije Uklonjena su dopuštenja za sve web lokacije Dopuštenja za „%1$s“ - Zatraži + Pitaj svaki puta Odbij Dopusti - Opcija \"Pitaj\" onemogućena je za sve web lokacije + Onemogućeno za sva web-mjesta %1$s dopuštenje za %2$s diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a3b8d66c2ade..6c1882100e85 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -547,8 +547,8 @@ Talán később Ehhez a webhelyhez ne kérje újra Megadod %1$s számára a helyhozzáférési engedélyt? - Csak az anonim helyedet használjuk a jobb, hozzád közelebbi eredmények érdekében. Később bármikor meggondolhatod magad. Az egyes webhelyeknek megadott helyhozzáférési engedélyeket a Beállításokban kezelheted. + Az anonim tartózkodási helyedet csak arra használjuk, hogy jobb, hozzád közelebbi találatokat tudjunk adni. A Beállításokban kezelheted az egyes webhelyeknek megadott helyhozzáférési engedélyeket. Mindig Csak erre a munkamenetre Mindig megtagad @@ -673,10 +673,10 @@ Még nincsenek oldalak Minden webhely engedélye eltávolítva Engedélyek a következőhöz: „%1$s” - Rákérdezés + Kérdezzen rá minden alkalommal Megtagadás Engedélyezés - A „Rákérdezés” minden webhelyen letiltva + Letiltva minden webhely számára %1$s engedély a(z) %2$s számára diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c9bc92394bbc..9785eb2441ad 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -255,7 +255,7 @@ Conferma Salva - Cancella + Elimina Modifica Rimuovi Elimina tutto @@ -547,8 +547,8 @@ Forse più tardi Non chiedere più per questo sito Consenti a %1$s di accedere alla tua posizione? - Usiamo la tua posizione anonima solo per offrirti risultati migliori, più vicini a te. Puoi sempre cambiare idea in seguito. Puoi gestire le autorizzazioni di accesso alla posizione concesse ai singoli siti in Impostazioni. + Usiamo la tua posizione anonima solo per offrirti risultati migliori, più vicini a te. Puoi gestire le autorizzazioni di accesso alla posizione concesse ai singoli siti in Impostazioni. Sempre Solo per questa sessione Nega sempre @@ -633,7 +633,7 @@ Impossibile aprire il file. Verifica la disponibilità di un\'app compatibile. Il file non esiste più Condividi - Cancella + Elimina Annulla Elimina tutto %1$s eliminato @@ -673,10 +673,10 @@ Non esistono ancora siti Autorizzazioni rimosse per tutti i siti Autorizzazioni per \"%1$s\" - Chiedi + Chiedi ogni volta Rifiuta Consenti - \"Chiedi\" disattivato per tutti i siti + Disattivato per tutti i siti Autorizzazione %1$s per %2$s diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index c486898d3899..dc073e712c2e 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -547,8 +547,8 @@ Galbūt vėliau Nebeprašykite šioje svetainėje Suteikti %1$s leidimą pasiekti vietą? - Jūsų anoniminę vietą naudojame tik norėdami pateikti geresnius rezultatus, esančius arčiau jūsų. Vėliau visada galite tai pakeisti. Galite tvarkyti vietos prieigos leidimus, kuriuos nustatymuose suteikėte pavienėms svetainėms. + Jūsų anoniminę buvimo vietą naudojame tik tam, kad galėtume pateikti geresnius rezultatus arčiau jūsų. Galite tvarkyti vietos prieigos leidimus, kuriuos nustatymuose suteikėte pavienėms svetainėms. Visada Tik šiam seansui Visada atsisakyti @@ -673,10 +673,10 @@ Svetainių dar nėra Pašalinti visų svetainių leidimai Leidimai „%1$s“ - Klausti + Klausti kiekvieną kartą Atsisakyti Leisti - „Klausti“ išjungta visose svetainėse + Išjungta visose svetainėse %1$s leidimas %2$s diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index c34ba319d874..635fa3889038 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -547,8 +547,8 @@ Varbūt vēlāk Vairs nejautāt par šo vietni Vai piešķirt %1$s atļauju piekļūt atrašanās vietai? - Mēs izmantojam tavu anonīmo atrašanās vietu tikai tāpēc, lai sniegtu labākus rezultātus tev tuvāk. Vēlāk vienmēr varēsi pārdomāt. Iestatījumu sadaļā vari pārvaldīt atrašanās vietas piekļuves atļaujas, ko piešķīri atsevišķām vietnēm. + Mēs izmantojam tavu anonīmo atrašanās vietu tikai tam, lai sniegtu labākus rezultātus tuvāk tev. Iestatījumu sadaļā vari pārvaldīt atrašanās vietas piekļuves atļaujas, kas piešķirtas atsevišķām vietnēm. Vienmēr Tikai šai sesijai Vienmēr aizliegt @@ -673,10 +673,10 @@ Vēl nav nevienas vietnes Atļaujas ir noņemtas visām vietnēm Atļaujas vietnei \"%1$s\" - Jautāt + Jautāt katru reizi Liegt Atļaut - “Jautāt” ir atspējots visām vietnēm + Atspējots visām vietnēm %1$s – atļauja vietnei %2$s diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index f2bf970c846a..74eb608c3882 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -547,8 +547,8 @@ Kanskje senere Ikke spør igjen for dette nettstedet Vil du gi %1$s tillatelse til å se posisjonen din? - Vi bruker den anonyme posisjonen kun til å levere bedre resultater, i nærheten av deg. Du kan ombestemme deg senere. Du kan administrere posisjonstillatelser du har gitt til enkeltsider under Innstillinger. + Vi bruker den anonyme posisjonen kun til å levere bedre resultater, i nærheten av deg. I innstillingene kan du administrere posisjonstillatelser du har gitt til enkeltsider. Alltid Kun for denne økten Aldri tillat @@ -673,10 +673,10 @@ Ingen nettsteder ennå Tillatelser fjernet for alle nettsteder Tillatelser for «%1$s» - Spør + Spør hver gang Avvis Tillat - «Spør» er deaktivert for alle nettsteder + Deaktivert for alle nettsteder %1$stillatelse for %2$s diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ad23f880340b..ab669b15fa24 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -547,8 +547,8 @@ Later misschien Niet opnieuw vragen voor deze site %1$s toestemming geven om je locatie te gebruiken? - We gebruik je anonieme locatie alleen om betere resultaten dichter bij je in de buurt te bieden. Je kunt dit later altijd weer uitschakelen. De locatietoegangsmachtigingen die je aan individuele sites hebt gegeven, kun je beheren in Instellingen. + We gebruiken je anonieme locatie alleen om betere resultaten bij je in de buurt aan te bieden. De locatietoegangsmachtigingen die je aan individuele sites hebt gegeven, kun je beheren in Instellingen. Altijd Alleen voor deze sessie Altijd weigeren @@ -673,10 +673,10 @@ Nog geen sites Toestemmingen verwijderd voor alle sites Toestemmingen voor \'%1$s\' - Vragen + Elke keer vragen Weigeren Toestaan - \"Vragen\" uitgeschakeld voor alle sites + Uitgeschakeld voor alle sites Toestemming voor %1$s voor %2$s diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9285f34da18a..b2e8e8a7ac6b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -547,8 +547,8 @@ Może później Nie pytaj ponownie dla tej witryny Przyznać %1$s uprawnienia dostępu do lokalizacji? - Używamy Twojej anonimowej lokalizacji tylko po to, aby zapewnić lepsze wyniki, bliżej Ciebie. Zawsze możesz później zmienić zdanie. Uprawnieniami dostępu do lokalizacji przyznanymi poszczególnym witrynom możesz zarządzać w Ustawieniach. + Używamy Twojej anonimowej lokalizacji tylko po to, aby zapewnić lepsze wyniki bliżej Ciebie. Uprawnieniami dostępu do lokalizacji przyznanymi poszczególnym witrynom możesz zarządzać w Ustawieniach. Zawsze Tylko dla tej sesji Zawsze odmawiaj @@ -673,10 +673,10 @@ Nie masz jeszcze witryn Usunięto uprawnienia wszystkich witryn Uprawnienia dla: „%1$s” - Poproś + Pytaj za każdym razem Odmów Zezwól - Wyłączono opcję „Pytaj” dla wszystkich witryn + Wyłączono dla wszystkich witryn Pozwolenie dla witryny %2$s na dostęp do urządzenia: %1$s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 715ae9406401..15da35819ef7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -547,8 +547,8 @@ Talvez mais tarde Não pedir novamente por este site Conceder %1$s permissão para aceder a localização? - Só utilizamos a tua localização anónima para apresentar resultados melhores mais perto de ti. Podes sempre mudar de ideias mais tarde. Pode gerir as permissões de acesso à localização concedidas a sites individuais nas Definições. + Só utilizamos a tua localização anónima para apresentar resultados melhores mais perto de ti. Podes gerir as permissões de acesso à localização concedidas a sites individuais nas Definições. Sempre Apenas para esta sessão Negar sempre @@ -673,10 +673,10 @@ Ainda não há sites As permissões foram removidas para todos os sites Permissões para \"%1$s\" - Perguntar + Pergunte todas as vezes Recusar Permitir - A funcionalidade \"Perguntar\" foi desativada para todos os sites + Desativado para todos os sites Permissão para %2$s utilizar %1$s diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 5dc535f896fd..16b385a0d50d 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -547,8 +547,8 @@ Poate mai târziu Nu întreba din nou pentru acest site Acorzi %1$s permisiunea de a accesa locația? - Folosim locația ta anonimă doar pentru a oferi rezultate mai bune, mai aproape de tine. Poți oricând să te răzgândești mai târziu. Poți gestiona permisiunile de acces la locație pe care le-ai acordat site-urilor individuale în Setări. + Utilizăm locația ta anonimă doar pentru a oferi rezultate mai bune, mai aproape de tine. Poți gestiona permisiunile de acces la locație pe care le-ai acordat site-urilor individuale în Setări. Întotdeauna Numai pentru această sesiune Respinge întotdeauna @@ -673,10 +673,10 @@ Niciun site încă Permisiuni eliminate pentru toate site-urile Permisiuni pentru „%1$s” - Cere + Întreabă de fiecare dată Refuză Permite - „Întreabă” este dezactivat pentru toate site-urile + Dezactivat pentru toate site-urile Permisiune %1$s pentru %2$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b30b9e5785a8..91ff09224387 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -547,8 +547,8 @@ Позже Больше не спрашивать для этого сайта Дать %1$s доступ к геопозиции? - Анонимная геопозиция позволяет подобрать более точные результаты поблизости. Вы всегда можете изменить эту настройку позже. Вы можете управлять разрешениями на доступ к геопозиции, которые вы предоставили отдельным сайтам, в настройках. + Анонимная геопозиция нужна только для поиска подходящих результатов поблизости. Доступ к геопозиции, предоставленный отдельным сайтам, можно проконтролировать в настройках. Всегда Только для этой сессии Всегда отказывать @@ -673,10 +673,10 @@ Пока нет сайтов Разрешения удалены для всех сайтов Разрешения для «%1$s» - Запрашивать + Спрашивать каждый раз Отказать Разрешить - «Запросить» отключено для всех сайтов + Отключено в отношении всех сайтов Разрешение на доступ %2$s к %1$s diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 662b70f26b20..e2f8c4a6ca29 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -547,8 +547,8 @@ Možno neskôr Pre túto stránku sa už nepýtajte Udeliť %1$s povolenie na prístup k polohe? - Anonymnú polohu používame iba na poskytovanie lepších výsledkov z vášho okolia. Toto nastavenie môžete kedykoľvek zmeniť. Povolenia na prístup k polohe, ktoré ste udelili jednotlivým webovým stránkam, môžete spravovať v Nastaveniach. + Používame vašu anonymnú polohu len na to, aby sme vám mohli ponúknuť lepšie výsledky, ktoré sú bližšie k vám. Povolenia na prístup k polohe, ktoré ste udelili jednotlivým webovým stránkam, môžete spravovať v Nastaveniach. Vždy Iba pre túto reláciu Vždy odmietnuť @@ -673,10 +673,10 @@ Zatiaľ žiadne lokality Povolenia boli odstránené pre všetky lokality Povolenia pre „%1$s” - Opýtať sa + Vždy sa opýtať Odmietnuť Povoliť - Možnosť „Spýtať sa“ je pre všetky lokality vypnutá + Zakázané pre všetky lokality %1$s povolenie pre %2$s diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index ce3f63ae6e86..b585ab8c20a4 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -547,8 +547,8 @@ Mogoče kasneje Ne vprašaj znova za to spletno mesto Dovoli %1$s dostop do lokacije? - Vašo anonimno lokacijo uporabljamo samo, da vam zagotovimo boljše rezultate v vaši bližini. Pozneje si lahko vedno premislite. Dovoljenja za dostop do lokacije, ki ste jih podelili posameznim spletnim mestom, lahko upravljate v nastavitvah. + Vašo anonimno lokacijo uporabljamo le za zagotavljanje boljših rezultatov, bližje vam. Dovoljenja za dostop do lokacije, ki ste jih podelili posameznim spletnim mestom, lahko upravljate v nastavitvah. Vedno Samo za to sejo Vedno zavrni @@ -673,10 +673,10 @@ Spletnih mest še ni Dovoljenja so odstranjena za vsa spletna mesta Dovoljenja za »%1$s« - Vprašaj + Vprašaj vsakič Zavrni Dovoli - Poziv »Vprašaj« je onemogočen za vsa spletna mesta + Onemogočeno za vsa spletna mesta %1$s dovoljenje za %2$s diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 14a8c18d6119..608fab3990ef 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -547,8 +547,8 @@ Kanske senare Fråga inte igen för denna webbplats Bevilja åtkomst till enhetens platsinf\bormation för %1$s? - Vi använder bara din anonyma plats för att ge dig bättre resultat, närmare dig. Du kan alltid ändra dig senare. Du kan gå till Inställningar för att hantera åtkomster till din enhets platsinf\bormation som du har beviljat för enskilda webbplatser. + Vi använder bara din anonyma plats för att ge dig bättre resultat, närmare dig. Du kan gå till Inställningar för att hantera åtkomster till din enhets platsinformation som du har beviljat för enskilda webbplatser. Alltid Endast för denna session Neka alltid @@ -673,10 +673,10 @@ Inga webbplatser ännu Behörigheter har tagits bort för alla webbplatser Behörigheter för %1$s - Fråga + Fråga varje gång Neka Tillåt - ”Fråga” inaktiverat för alla webbplatser + Inaktiverat för alla webbplatser %1$s-behörighet för %2$s diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 3cb4607d2996..33d81e641c0b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -547,8 +547,8 @@ Belki Sonra Bu Site için Bir Daha Sorma %1$s için konuma erişim izni verilsin mi? - Anonim konumunuzu sadece size daha yakın, daha alakalı sonuçlar sunmak için kullanırız. Bu tercihi daha sonra dilediğiniz zaman değiştirebilirsiniz. Sitelere verdiğiniz konuma erişim izinlerini Ayarlar bölümünden yönetebilirsiniz. + Anonim konumunuzu yalnızca size daha yakın, daha iyi sonuçlar sunmak için kullanıyoruz. Ayrı ayrı sitelere verdiğiniz konum erişimi izinlerini Ayarlar bölümünden yönetebilirsiniz. Her zaman Yalnızca Bu Oturum için Her Zaman Reddet @@ -673,10 +673,10 @@ Henüz site yok Tüm Siteler İçin İzinler Kaldırıldı \"%1$s\" için izinler - Sor + Her seferinde sor Reddet İzin ver - Tüm siteler için \"Sor\" devre dışı bırakıldı + Tüm siteler için devre dışı %2$s için %1$s izni diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 727181f5eb9f..c2fafd000233 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -63,4 +63,12 @@ The government may be blocking access to duckduckgo.com on this network provider, which could affect this app\'s functionality. Other providers may not be affected. Okay + + Oh, just one more step… + VPN!

Activate it with a paid Privacy Pro subscription.]]>
+ Get even more protection… + VPN!

Activate it with a paid Privacy Pro subscription.]]>
+ Ready for a deal…? + VPN.

Activate it with a paid Privacy Pro subscription.]]>
+ diff --git a/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt b/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt index 098a0b6bb611..bd3caa0b71a6 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt @@ -1,9 +1,11 @@ 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.browser.trafficquality.AndroidFeaturesHeaderPlugin +import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER +import com.duckduckgo.app.browser.trafficquality.remote.AndroidFeaturesHeaderProvider import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.feature.toggles.api.Toggle +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -21,22 +23,39 @@ class AndroidFeaturesHeaderPluginTest { 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 } + private val mockAndroidFeaturesHeaderProvider: AndroidFeaturesHeaderProvider = mock() + + private val SAMPLE_HEADER = "header" @Before fun setup() { - testee = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mockAndroidBrowserConfigFeature) + testee = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mockAndroidBrowserConfigFeature, mockAndroidFeaturesHeaderProvider) + } + + @Test + fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureEnabledAndHeaderProvidedThenReturnCorrectHeader() = runTest { + val url = "duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockEnabledToggle) + whenever(mockAndroidFeaturesHeaderProvider.provide()).thenReturn(SAMPLE_HEADER) + + val headers = testee.getHeaders(url) + + assertEquals(SAMPLE_HEADER, headers[X_DUCKDUCKGO_ANDROID_HEADER]) } @Test - fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureEnabledThenReturnCorrectHeader() { + fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureEnabledAndHeaderNotProvidedThenReturnEmptyMap() = runTest { val url = "duckduckgo_search_url" whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true) whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockEnabledToggle) + whenever(mockAndroidFeaturesHeaderProvider.provide()).thenReturn(null) val headers = testee.getHeaders(url) - assertEquals(TEST_VALUE, headers[X_DUCKDUCKGO_ANDROID_HEADER]) + assertTrue(headers.isEmpty()) } @Test diff --git a/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidAppVersionPixelSenderTest.kt b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidAppVersionPixelSenderTest.kt new file mode 100644 index 000000000000..82f2de0dd875 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidAppVersionPixelSenderTest.kt @@ -0,0 +1,49 @@ +package com.duckduckgo.app.browser.trafficquality + +import com.duckduckgo.app.browser.trafficquality.RealQualityAppVersionProvider.Companion.APP_VERSION_QUALITY_DEFAULT_VALUE +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.test.CoroutineTestRule +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 AndroidAppVersionPixelSenderTest { + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val mockAppVersionProvider = mock() + private val mockPixel = mock() + + private lateinit var pixelSender: AndroidAppVersionPixelSender + + @Before + fun setup() { + pixelSender = AndroidAppVersionPixelSender( + mockAppVersionProvider, + mockPixel, + coroutineRule.testScope, + coroutineRule.testDispatcherProvider, + ) + } + + @Test + fun reportFeaturesEnabledOrDisabledWhenEnabledOrDisabled() = runTest { + whenever(mockAppVersionProvider.provide()).thenReturn(APP_VERSION_QUALITY_DEFAULT_VALUE) + + pixelSender.onSearchRetentionAtbRefreshed("v123-1", "v123-2") + + verify(mockPixel).fire( + AppPixelName.APP_VERSION_AT_SEARCH_TIME, + mapOf( + AndroidAppVersionPixelSender.PARAM_APP_VERSION to APP_VERSION_QUALITY_DEFAULT_VALUE, + ), + ) + verifyNoMoreInteractions(mockPixel) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesHeaderProviderTest.kt b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesHeaderProviderTest.kt new file mode 100644 index 000000000000..3614c53f5c58 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesHeaderProviderTest.kt @@ -0,0 +1,223 @@ +/* + * 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.browser.trafficquality.remote.FeaturesRequestHeaderStore +import com.duckduckgo.app.browser.trafficquality.remote.RealAndroidFeaturesHeaderProvider +import com.duckduckgo.app.browser.trafficquality.remote.TrafficQualityAppVersion +import com.duckduckgo.app.browser.trafficquality.remote.TrafficQualityAppVersionFeatures +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autoconsent.api.Autoconsent +import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection +import com.duckduckgo.networkprotection.api.NetworkProtectionState +import com.duckduckgo.privacy.config.api.Gpc +import java.time.LocalDateTime +import java.time.ZoneId +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class AndroidFeaturesHeaderProviderTest { + + private val currentVersion = 5210000 + private val anotherVersion = 5220000 + + private val appBuildConfig: AppBuildConfig = mock() + private val featuresRequestHeaderStore: FeaturesRequestHeaderStore = mock() + private val mockAutoconsent: Autoconsent = mock() + private val mockGpc: Gpc = mock() + private val mockAppTrackingProtection: AppTrackingProtection = mock() + private val mockNetworkProtectionState: NetworkProtectionState = mock() + + private lateinit var testee: RealAndroidFeaturesHeaderProvider + + @Before + fun setup() { + testee = RealAndroidFeaturesHeaderProvider( + appBuildConfig, + featuresRequestHeaderStore, + mockAutoconsent, + mockGpc, + mockAppTrackingProtection, + mockNetworkProtectionState, + ) + + whenever(appBuildConfig.versionCode).thenReturn(currentVersion) + givenBuildDateDaysAgo(6) + } + + @Test + fun whenNoVersionsPresentThenNoValueProvided() { + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(emptyList()) + + val result = testee.provide() + assertNull(result) + } + + @Test + fun whenCurrentVersionNotPresentThenNoValueProvided() { + val noFeaturesEnabled = TrafficQualityAppVersion(anotherVersion, 5, 5, noFeaturesEnabled()) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(noFeaturesEnabled)) + + val result = testee.provide() + assertNull(result) + } + + @Test + fun whenCurrentVersionPresentAndNoFeaturesEnabledThenNoValueProvided() { + val noFeaturesEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, noFeaturesEnabled()) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(noFeaturesEnabled)) + + val result = testee.provide() + assertNull(result) + } + + @Test + fun whenCurrentVersionPresentAndGPCFeatureEnabledAndGPCDisabledThenValueProvided() { + whenever(mockGpc.isEnabled()).thenReturn(false) + val gpcEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(gpc = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(gpcEnabled)) + + val result = testee.provide() + assertTrue(result == "gpc_enabled=false") + } + + @Test + fun whenCurrentVersionPresentAndGPCFeatureEnabledAndGPCEnabledThenValueProvided() { + whenever(mockGpc.isEnabled()).thenReturn(true) + val gpcEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(gpc = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(gpcEnabled)) + + val result = testee.provide() + assertTrue(result == "gpc_enabled=true") + } + + @Test + fun whenCurrentVersionPresentAndCPMFeatureEnabledAndCPMDisabledThenValueProvided() { + whenever(mockAutoconsent.isAutoconsentEnabled()).thenReturn(false) + val gpcEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(cpm = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(gpcEnabled)) + + val result = testee.provide() + assertTrue(result == "cpm_enabled=false") + } + + @Test + fun whenCurrentVersionPresentAndCPMFeatureEnabledAndCPMEnabledThenValueProvided() { + whenever(mockAutoconsent.isAutoconsentEnabled()).thenReturn(true) + val gpcEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(cpm = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(gpcEnabled)) + + val result = testee.provide() + assertTrue(result == "cpm_enabled=true") + } + + @Test + fun whenCurrentVersionPresentAndAppTPFeatureEnabledAndAppTPDisabledThenValueProvided() = runTest { + whenever(mockAppTrackingProtection.isEnabled()).thenReturn(false) + val gpcEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(appTP = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(gpcEnabled)) + + val result = testee.provide() + assertTrue(result == "atp_enabled=false") + } + + @Test + fun whenCurrentVersionPresentAndAppTPFeatureEnabledAndAppTPEnabledThenValueProvided() = runTest { + whenever(mockAppTrackingProtection.isEnabled()).thenReturn(true) + val gpcEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(appTP = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(gpcEnabled)) + + val result = testee.provide() + assertTrue(result == "atp_enabled=true") + } + + @Test + fun whenCurrentVersionPresentAndVPNFeatureEnabledAndVPNDisabledThenValueProvided() = runTest { + whenever(mockNetworkProtectionState.isEnabled()).thenReturn(false) + val gpcEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(netP = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(gpcEnabled)) + + val result = testee.provide() + assertTrue(result == "vpn_enabled=false") + } + + @Test + fun whenCurrentVersionPresentAndVPNFeatureEnabledAndVPNEnabledThenValueProvided() = runTest { + whenever(mockNetworkProtectionState.isEnabled()).thenReturn(true) + val gpcEnabled = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(netP = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(gpcEnabled)) + + val result = testee.provide() + assertTrue(result == "vpn_enabled=true") + } + + @Test + fun whenCurrentVersionPresentAndSeveralFeaturesEnabledThenOnlyOneValueProvided() = runTest { + whenever(mockNetworkProtectionState.isEnabled()).thenReturn(true) + whenever(mockAppTrackingProtection.isEnabled()).thenReturn(true) + whenever(mockAutoconsent.isAutoconsentEnabled()).thenReturn(true) + whenever(mockGpc.isEnabled()).thenReturn(true) + val features = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(gpc = true, cpm = true, appTP = true, netP = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(features)) + + val result = testee.provide() + assertTrue(result == "vpn_enabled=true" || result == "cpm_enabled=true" || result == "gpc_enabled=true" || result == "atp_enabled=true") + } + + @Test + fun whenItsTooEarlyToLogThenNoValueProvided() = runTest { + givenBuildDateDaysAgo(1) + val features = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(gpc = true, cpm = true, appTP = true, netP = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(features)) + + val result = testee.provide() + assertNull(result) + } + + @Test + fun whenItsTooLateToLogThenNoValueProvided() = runTest { + givenBuildDateDaysAgo(20) + val features = TrafficQualityAppVersion(currentVersion, 5, 5, featuresEnabled(gpc = true, cpm = true, appTP = true, netP = true)) + whenever(featuresRequestHeaderStore.getConfig()).thenReturn(listOf(features)) + + val result = testee.provide() + assertNull(result) + } + + private fun noFeaturesEnabled(): TrafficQualityAppVersionFeatures { + return TrafficQualityAppVersionFeatures(gpc = false, cpm = false, appTP = false, netP = false) + } + + private fun featuresEnabled( + gpc: Boolean = false, + cpm: Boolean = false, + appTP: Boolean = false, + netP: Boolean = false, + ): TrafficQualityAppVersionFeatures { + return TrafficQualityAppVersionFeatures(gpc = gpc, cpm = cpm, appTP = appTP, netP = netP) + } + + private fun givenBuildDateDaysAgo(days: Long) { + val daysAgo = LocalDateTime.now().minusDays(days).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + whenever(appBuildConfig.buildDateTimeMillis).thenReturn(daysAgo) + } +} 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 deleted file mode 100644 index b959c98f3315..000000000000 --- a/app/src/test/java/com/duckduckgo/app/browser/trafficquality/AndroidFeaturesPixelSenderTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -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/trafficquality/FeaturesRequestHeaderSettingsStoreTest.kt b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/FeaturesRequestHeaderSettingsStoreTest.kt new file mode 100644 index 000000000000..e65eb73b3d5b --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/FeaturesRequestHeaderSettingsStoreTest.kt @@ -0,0 +1,59 @@ +/* + * 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.browser.trafficquality.remote.TrafficQualitySettingsJson +import com.squareup.moshi.Moshi +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import org.junit.Test + +class FeaturesRequestHeaderSettingsStoreTest { + + private val moshi = Moshi.Builder().build() + + @Test + fun jsonCanBeParsed() { + val jsonString = """ + { + "versions": [ + { + "appVersion": 52200000, + "daysUntilLoggingStarts": 5, + "daysLogging": 10, + "features": { + "gpc": true, + "cpm": false, + "appTp": true, + "netP": false + } + } + ] + } + """ + val jsonAdapter = moshi.adapter(TrafficQualitySettingsJson::class.java) + val rootObject = jsonAdapter.fromJson(jsonString) + assertNotNull(rootObject) + assertEquals(1, rootObject?.versions?.size) + + val version = rootObject?.versions?.first() + assertNotNull(version) + assertEquals(52200000, version?.appVersion) + assertEquals(5, version?.daysUntilLoggingStarts) + assertEquals(10, version?.daysLogging) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/trafficquality/RealQualityAppVersionProviderTest.kt b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/RealQualityAppVersionProviderTest.kt new file mode 100644 index 000000000000..2d79d6606f8e --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/trafficquality/RealQualityAppVersionProviderTest.kt @@ -0,0 +1,92 @@ +package com.duckduckgo.app.browser.trafficquality + +import com.duckduckgo.app.browser.trafficquality.RealQualityAppVersionProvider.Companion.APP_VERSION_QUALITY_DEFAULT_VALUE +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.appbuildconfig.api.BuildFlavor +import java.time.LocalDateTime +import java.time.ZoneId +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class RealQualityAppVersionProviderTest { + + private val appBuildConfig = mock().apply { + whenever(this.flavor).thenReturn(BuildFlavor.PLAY) + } + + private lateinit var testee: RealQualityAppVersionProvider + + @Before + fun setup() { + testee = RealQualityAppVersionProvider(appBuildConfig) + } + + @Test + fun whenBuildDateEmptyThenReturnDefault() { + whenever(appBuildConfig.buildDateTimeMillis).thenReturn(0L) + val appVersion = testee.provide() + assertTrue(appVersion == APP_VERSION_QUALITY_DEFAULT_VALUE) + } + + @Test + fun whenBuildDateTodayThenReturnDefault() { + givenBuildDateDaysAgo(0) + val appVersion = testee.provide() + assertTrue(appVersion == APP_VERSION_QUALITY_DEFAULT_VALUE) + } + + @Test + fun whenNotYetTimeToLogThenReturnDefault() { + givenBuildDateDaysAgo(2) + val appVersion = testee.provide() + assertTrue(appVersion == APP_VERSION_QUALITY_DEFAULT_VALUE) + } + + @Test + fun whenTimeToLogThenReturnAppVersion() { + givenBuildDateDaysAgo(6) + val versionName = "5.212.0" + givenVersionName(versionName) + val appVersion = testee.provide() + assertTrue(appVersion == versionName) + } + + @Test + fun whenTimeToLogAndNotOverLoggingPeriodThenReturnAppVersion() { + val versionName = "5.212.0" + givenVersionName(versionName) + givenBuildDateDaysAgo(8) + val appVersion = testee.provide() + assertTrue(appVersion == versionName) + } + + @Test + fun whenTimeToLogAndOverLoggingPeriodThenReturnDefault() { + val versionName = "5.212.0" + givenVersionName(versionName) + givenBuildDateDaysAgo(20) + val appVersion = testee.provide() + assertTrue(appVersion == APP_VERSION_QUALITY_DEFAULT_VALUE) + } + + @Test + fun whenTimeToLogAndJustOnLoggingPeriodThenReturnVersionName() { + val versionName = "5.212.0" + givenVersionName(versionName) + givenBuildDateDaysAgo(16) + val appVersion = testee.provide() + assertTrue(appVersion == versionName) + } + + private fun givenBuildDateDaysAgo(days: Long) { + val daysAgo = LocalDateTime.now().minusDays(days).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + whenever(appBuildConfig.buildDateTimeMillis).thenReturn(daysAgo) + } + + private fun givenVersionName(versionName: String) { + whenever(appBuildConfig.versionName).thenReturn(versionName) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/cta/ui/OnboardingDaxDialogTests.kt b/app/src/test/java/com/duckduckgo/app/cta/ui/OnboardingDaxDialogTests.kt index 9d40ba9a1d68..2bdf27b047be 100644 --- a/app/src/test/java/com/duckduckgo/app/cta/ui/OnboardingDaxDialogTests.kt +++ b/app/src/test/java/com/duckduckgo/app/cta/ui/OnboardingDaxDialogTests.kt @@ -28,12 +28,15 @@ import com.duckduckgo.app.onboarding.store.AppStage.DAX_ONBOARDING import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingFeatureToggles +import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingPixelsPlugin import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.HighlightsOnboardingExperimentManager import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.widget.ui.WidgetCapabilities +import com.duckduckgo.brokensite.api.BrokenSitePrompt +import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.duckplayer.api.DuckPlayer @@ -74,6 +77,9 @@ class OnboardingDaxDialogTests { private val extendedOnboardingFeatureToggles: ExtendedOnboardingFeatureToggles = mock() private val mockDuckPlayer: DuckPlayer = mock() private val mockHighlightsOnboardingExperimentManager: HighlightsOnboardingExperimentManager = mock() + private val mockBrokenSitePrompt: BrokenSitePrompt = mock() + private val mockExtendedOnboardingPixelsPlugin: ExtendedOnboardingPixelsPlugin = mock() + private val mockUserBrowserProperties: UserBrowserProperties = mock() val mockEnabledToggle: Toggle = org.mockito.kotlin.mock { on { it.isEnabled() } doReturn true } val mockDisabledToggle: Toggle = org.mockito.kotlin.mock { on { it.isEnabled() } doReturn false } @@ -93,6 +99,9 @@ class OnboardingDaxDialogTests { subscriptions = mock(), mockDuckPlayer, mockHighlightsOnboardingExperimentManager, + mockBrokenSitePrompt, + mockExtendedOnboardingPixelsPlugin, + mockUserBrowserProperties, ) } diff --git a/app/src/test/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModelTest.kt index d3abefe81add..9590ddf61682 100644 --- a/app/src/test/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModelTest.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.dispatchers import android.content.Intent import androidx.browser.customtabs.CustomTabsIntent import app.cash.turbine.test +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.global.intentText import com.duckduckgo.autofill.api.emailprotection.EmailProtectionLinkVerifier import com.duckduckgo.common.test.CoroutineTestRule @@ -42,6 +43,7 @@ class IntentDispatcherViewModelTest { private val mockCustomTabDetector: CustomTabDetector = mock() private val mockIntent: Intent = mock() private val emailProtectionLinkVerifier: EmailProtectionLinkVerifier = mock() + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private lateinit var testee: IntentDispatcherViewModel @@ -51,6 +53,7 @@ class IntentDispatcherViewModelTest { customTabDetector = mockCustomTabDetector, dispatcherProvider = coroutineTestRule.testDispatcherProvider, emailProtectionLinkVerifier = emailProtectionLinkVerifier, + duckDuckGoUrlDetector = duckDuckGoUrlDetector, ) } @@ -173,6 +176,23 @@ class IntentDispatcherViewModelTest { } } + @Test + fun `when Intent received with session and intent text is a DDG domain then custom tab is not requested`() = runTest { + val text = "some DDG url" + val toolbarColor = 100 + configureHasSession(true) + whenever(mockIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0)).thenReturn(toolbarColor) + whenever(mockIntent.intentText).thenReturn(text) + whenever(duckDuckGoUrlDetector.isDuckDuckGoUrl(text)).thenReturn(true) + + testee.onIntentReceived(mockIntent, DEFAULT_COLOR, isExternal = false) + + testee.viewState.test { + val state = awaitItem() + assertFalse(state.customTabRequested) + } + } + private fun configureHasSession(returnValue: Boolean) { whenever(mockIntent.hasExtra(CustomTabsIntent.EXTRA_SESSION)).thenReturn(returnValue) } 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 index d663c75048eb..0067de644847 100644 --- a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt @@ -790,6 +790,10 @@ class ShowOnAppLaunchOptionHandlerImplTest { TODO("Not yet implemented") } + override suspend fun updateTabLastAccess(tabId: String) { + TODO("Not yet implemented") + } + override fun retrieveSiteData(tabId: String): MutableLiveData { TODO("Not yet implemented") } @@ -851,5 +855,16 @@ class ShowOnAppLaunchOptionHandlerImplTest { override suspend fun setTabLayoutType(layoutType: LayoutType) { TODO("Not yet implemented") } + + override fun getOpenTabCount(): Int { + TODO("Not yet implemented") + } + + override fun countTabsAccessedWithinRange( + accessOlderThan: Long, + accessNotMoreThan: Long?, + ): Int { + TODO("Not yet implemented") + } } } diff --git a/app/src/test/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/settings/LegacySettingsViewModelTest.kt similarity index 98% rename from app/src/test/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt rename to app/src/test/java/com/duckduckgo/app/settings/LegacySettingsViewModelTest.kt index 4d76c398eaf8..48f7c9e6d36f 100644 --- a/app/src/test/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/settings/LegacySettingsViewModelTest.kt @@ -20,8 +20,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.pixels.AppPixelName -import com.duckduckgo.app.settings.SettingsViewModel.Command -import com.duckduckgo.app.settings.SettingsViewModel.Companion.EMAIL_PROTECTION_URL +import com.duckduckgo.app.settings.LegacySettingsViewModel.Command +import com.duckduckgo.app.settings.LegacySettingsViewModel.Companion.EMAIL_PROTECTION_URL import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autoconsent.api.Autoconsent import com.duckduckgo.autofill.api.AutofillCapabilityChecker @@ -52,7 +52,7 @@ class SettingsViewModelTest { @Suppress("unused") var instantTaskExecutorRule = InstantTaskExecutorRule() - private lateinit var testee: SettingsViewModel + private lateinit var testee: LegacySettingsViewModel @Mock private lateinit var mockDefaultBrowserDetector: DefaultBrowserDetector @@ -95,7 +95,7 @@ class SettingsViewModelTest { whenever(subscriptions.isEligible()).thenReturn(true) } - testee = SettingsViewModel( + testee = LegacySettingsViewModel( mockDefaultBrowserDetector, appTrackingProtection, mockPixel, diff --git a/app/src/test/java/com/duckduckgo/app/tabs/store/TabStatsBucketingTest.kt b/app/src/test/java/com/duckduckgo/app/tabs/store/TabStatsBucketingTest.kt new file mode 100644 index 000000000000..511ee02756d8 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/tabs/store/TabStatsBucketingTest.kt @@ -0,0 +1,313 @@ +package com.duckduckgo.app.tabs.store +/* + * 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.tabs.model.TabRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +class DefaultTabStatsBucketingTest { + + private val tabRepository = mock() + + private lateinit var defaultTabStatsBucketing: DefaultTabStatsBucketing + + @Before + fun setup() { + defaultTabStatsBucketing = DefaultTabStatsBucketing(tabRepository) + } + + @Test + fun testGetNumberOfOpenTabsExactly1() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(1) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("1", result) + } + + @Test + fun testGetNumberOfOpenTabsZero() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(0) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("1", result) + } + + @Test + fun testGetNumberOfOpenTabs() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(5) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("2-5", result) + } + + @Test + fun testGetNumberOfOpenTabs6To10() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(8) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("6-10", result) + } + + @Test + fun testGetNumberOfOpenTabs11To20() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(11) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("11-20", result) + } + + @Test + fun testGetNumberOfOpenTabsMoreThan20() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(40) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("21-40", result) + } + + @Test + fun testGetNumberOfOpenTabs41To60() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(50) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("41-60", result) + } + + @Test + fun testGetNumberOfOpenTabs61To80() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(70) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("61-80", result) + } + + @Test + fun testGetNumberOfOpenTabs81To100() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(90) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("81-100", result) + } + + @Test + fun testGetNumberOfOpenTabs101To125() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(110) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("101-125", result) + } + + @Test + fun testGetNumberOfOpenTabs126To150() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(130) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("126-150", result) + } + + @Test + fun testGetNumberOfOpenTabs151To250() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(200) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("151-250", result) + } + + @Test + fun testGetNumberOfOpenTabs251To500() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(300) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("251-500", result) + } + + @Test + fun testGetNumberOfOpenTabsMaxValue() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(600) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("501+", result) + } + + @Test + fun testGetTabsActiveLastWeekZero() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(0) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("0", result) + } + + @Test + fun testGetTabsActiveLastWeekExactly1() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(1) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveLastWeek1To5() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(2) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveLastWeek6To10() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(10) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("6-10", result) + } + + @Test + fun testGetTabsActiveLastWeek11To20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(15) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("11-20", result) + } + + @Test + fun testGetTabsActiveLastWeekMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(25) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("21+", result) + } + + @Test + fun testGetTabsActiveLastWeekALotMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(250) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("21+", result) + } + + @Test + fun testGetTabsActiveOneWeekAgoZero() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(0) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("0", result) + } + + @Test + fun testGet1WeeksInactiveTabBucketExactly1() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(1) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("1-5", result) + } + + @Test + fun testGet1WeeksInactiveTabBucket1To5() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(3) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveOneWeekAgo6To10() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(8) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("6-10", result) + } + + @Test + fun testGetTabsActiveOneWeekAgo11To20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(15) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("11-20", result) + } + + @Test + fun testGetTabsActiveOneWeekAgoMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(25) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("21+", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgoZero() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(0) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("0", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgoExactly1() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(1) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgo1To5() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(5) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgo6To10() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(6) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("6-10", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgo11To20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn( + 20, + ) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("11-20", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgoMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn( + 199, + ) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("21+", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgoZero() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(0) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("0", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgoExactly1() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(1) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgo1To5() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(5) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgo6To10() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(10) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("6-10", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgo11To20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(11) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("11-20", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgoMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(21) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("21+", result) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt b/app/src/test/java/com/duckduckgo/tabs/db/TabsDaoTest.kt similarity index 98% rename from app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt rename to app/src/test/java/com/duckduckgo/tabs/db/TabsDaoTest.kt index e9c6d1d1e72e..5aa1c47aa39a 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt +++ b/app/src/test/java/com/duckduckgo/tabs/db/TabsDaoTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 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,12 +14,14 @@ * limitations under the License. */ -package com.duckduckgo.app.tabs.db +package com.duckduckgo.tabs.db import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.tabs.db.TabsDao import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabSelectionEntity import kotlinx.coroutines.test.runTest @@ -28,7 +30,9 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class TabsDaoTest { @get:Rule diff --git a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt b/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt similarity index 69% rename from app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt rename to app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt index 12f0256e3f3d..53113b90452a 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt +++ b/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 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,15 +14,13 @@ * limitations under the License. */ -@file:Suppress("RemoveExplicitTypeArguments") - -package com.duckduckgo.app.tabs.model +package com.duckduckgo.tabs.model import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.blockingObserve import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.browser.certificates.BypassedSSLCertificatesRepository import com.duckduckgo.app.browser.favicon.FaviconManager @@ -31,22 +29,42 @@ import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.model.SiteFactoryImpl import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.tabs.db.TabsDao +import com.duckduckgo.app.tabs.model.TabDataRepository +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabSelectionEntity import com.duckduckgo.app.tabs.store.TabSwitcherDataStore import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule +import com.duckduckgo.common.test.blockingObserve +import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.privacy.config.api.ContentBlocking +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import org.mockito.kotlin.* - +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) class TabDataRepositoryTest { @get:Rule @@ -204,6 +222,7 @@ class TabDataRepositoryTest { val existingTabs = listOf(tab0) whenever(mockDao.tabs()).thenReturn(existingTabs) + whenever(mockDao.lastTab()).thenReturn(existingTabs.last()) testee.add("http://www.example.com") @@ -420,6 +439,146 @@ class TabDataRepositoryTest { job.cancel() } + @Test + fun getOpenTabCountReturnsCorrectCount() = runTest { + // Arrange: Add some tabs to the repository + whenever(mockDao.tabs()).thenReturn( + listOf( + TabEntity(tabId = "tab1"), + TabEntity(tabId = "tab2"), + TabEntity(tabId = "tab3"), + ), + ) + val testee = tabDataRepository() + + val openTabCount = testee.getOpenTabCount() + + // Assert: Verify the count is correct + assertEquals(3, openTabCount) + } + + @Test + fun getActiveTabCountReturnsZeroWhenNoTabs() = runTest { + // Arrange: No tabs in the repository + whenever(mockDao.tabs()).thenReturn(emptyList()) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(0, 7) + + // Assert: Verify the count is zero + assertEquals(0, inactiveTabCount) + } + + @Test + fun getActiveTabCountReturnsZeroWhenNullTabs() = runTest { + // Arrange: Only null tabs in the repository + val tab1 = TabEntity(tabId = "tab1") + whenever(mockDao.tabs()).thenReturn(listOf(tab1)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(0, 7) + + // Assert: Verify the count is zero + assertEquals(0, inactiveTabCount) + } + + @Test + fun getActiveTabCountReturnsCorrectCountWhenTabsYoungerThanSpecifiedDay() = runTest { + // Arrange: No tabs in the repository + val now = now() + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(6)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(8)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(10)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(0, 9) + + // Assert: Verify the count is 2 + assertEquals(2, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsZeroWhenNoTabs() = runTest { + // Arrange: No tabs in the repository + whenever(mockDao.tabs()).thenReturn(emptyList()) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(7, 12) + + // Assert: Verify the count is zero + assertEquals(0, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsCorrectCountWhenAllTabsOlderThanSpecifiedDay() = runTest { + // Arrange: Add some tabs with different last access times + val now = now() + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(8)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(10)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(9).minusSeconds(1)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(9) + + // Assert: Verify the count is correct + assertEquals(2, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsCorrectCountWhenAllTabsInactiveWithinRange() = runTest { + // Arrange: Add some tabs with different last access times + val now = now() + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(8)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(10)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(9)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(7, 12) + + // Assert: Verify the count is correct + assertEquals(3, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsZeroWhenNoTabsInactiveWithinRange() = runTest { + // Arrange: Add some tabs with different last access times + val now = now() + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(5)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(6)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(13)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(7, 12) + + // Assert: Verify the count is zero + assertEquals(0, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsCorrectCountWhenSomeTabsInactiveWithinRange() = runTest { + // Arrange: Add some tabs with different last access times + val now = now() + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(5)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(10)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(15)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(7, 12) + + // Assert: Verify the count is correct + assertEquals(1, inactiveTabCount) + } + private fun tabDataRepository( dao: TabsDao = mockDatabase(), entityLookup: EntityLookup = mock(), @@ -430,6 +589,7 @@ class TabDataRepositoryTest { faviconManager: FaviconManager = mock(), tabSwitcherDataStore: TabSwitcherDataStore = mock(), duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock(), + timeProvider: CurrentTimeProvider = FakeTimeProvider(), ): TabDataRepository { return TabDataRepository( dao, @@ -446,6 +606,7 @@ class TabDataRepositoryTest { webViewPreviewPersister, faviconManager, tabSwitcherDataStore, + timeProvider, coroutinesTestRule.testScope, coroutinesTestRule.testDispatcherProvider, ) @@ -466,7 +627,19 @@ class TabDataRepositoryTest { return mockDao } + private fun now(): LocalDateTime { + return FakeTimeProvider().localDateTimeNow() + } + companion object { const val TAB_ID = "abcd" } + + private class FakeTimeProvider : CurrentTimeProvider { + var currentTime: Instant = Instant.parse("2024-10-16T00:00:00.00Z") + + override fun currentTimeMillis(): Long = currentTime.toEpochMilli() + override fun elapsedRealtime(): Long = throw UnsupportedOperationException() + override fun localDateTimeNow(): LocalDateTime = currentTime.atZone(ZoneOffset.UTC).toLocalDateTime() + } } diff --git a/app/version/version.properties b/app/version/version.properties index ebd5dbf6904e..9c4bfe6edc0f 100644 --- a/app/version/version.properties +++ b/app/version/version.properties @@ -1 +1 @@ -VERSION=5.220.0 \ No newline at end of file +VERSION=5.221.0 \ No newline at end of file diff --git a/auth-jwt/readme.md b/auth-jwt/readme.md deleted file mode 100644 index e6fbcf22cd06..000000000000 --- a/auth-jwt/readme.md +++ /dev/null @@ -1,9 +0,0 @@ -# Feature Name - -This module provides API for parsing and validating JWT (JWS) tokens issued by Auth API v2. - -## Who can help you better understand this feature? -- Łukasz Macionczyk - -## More information -N/A \ No newline at end of file diff --git a/autoconsent/autoconsent-impl/libs/autoconsent-bundle.js b/autoconsent/autoconsent-impl/libs/autoconsent-bundle.js index 974a24dd2fd7..a82b1bbffd29 100644 --- a/autoconsent/autoconsent-impl/libs/autoconsent-bundle.js +++ b/autoconsent/autoconsent-impl/libs/autoconsent-bundle.js @@ -1 +1,445 @@ -!function(){"use strict";var e=class e{static setBase(t){e.base=t}static findElement(t,o=null,i=!1){let c=null;return c=null!=o?Array.from(o.querySelectorAll(t.selector)):null!=e.base?Array.from(e.base.querySelectorAll(t.selector)):Array.from(document.querySelectorAll(t.selector)),null!=t.textFilter&&(c=c.filter((e=>{const o=e.textContent.toLowerCase();if(Array.isArray(t.textFilter)){let e=!1;for(const i of t.textFilter)if(-1!==o.indexOf(i.toLowerCase())){e=!0;break}return e}if(null!=t.textFilter)return-1!==o.indexOf(t.textFilter.toLowerCase())}))),null!=t.styleFilters&&(c=c.filter((e=>{const o=window.getComputedStyle(e);let i=!0;for(const e of t.styleFilters){const t=o[e.option];i=e.negated?i&&t!==e.value:i&&t===e.value}return i}))),null!=t.displayFilter&&(c=c.filter((e=>t.displayFilter?0!==e.offsetHeight:0===e.offsetHeight))),null!=t.iframeFilter&&(c=c.filter((()=>t.iframeFilter?window.location!==window.parent.location:window.location===window.parent.location))),null!=t.childFilter&&(c=c.filter((o=>{const i=e.base;e.setBase(o);const c=e.find(t.childFilter);return e.setBase(i),null!=c.target}))),i?c:(c.length>1&&console.warn("Multiple possible targets: ",c,t,o),c[0])}static find(t,o=!1){const i=[];if(null!=t.parent){const c=e.findElement(t.parent,null,o);if(null!=c){if(c instanceof Array)return c.forEach((c=>{const n=e.findElement(t.target,c,o);n instanceof Array?n.forEach((e=>{i.push({parent:c,target:e})})):i.push({parent:c,target:n})})),i;{const n=e.findElement(t.target,c,o);n instanceof Array?n.forEach((e=>{i.push({parent:c,target:e})})):i.push({parent:c,target:n})}}}else{const c=e.findElement(t.target,null,o);c instanceof Array?c.forEach((e=>{i.push({parent:null,target:e})})):i.push({parent:null,target:c})}return 0===i.length&&i.push({parent:null,target:null}),o?i:(1!==i.length&&console.warn("Multiple results found, even though multiple false",i),i[0])}};e.base=null;var t=e;function o(e){const o=t.find(e);return"css"===e.type?!!o.target:"checkbox"===e.type?!!o.target&&o.target.checked:void 0}async function i(e,a){switch(e.type){case"click":return async function(e){const o=t.find(e);null!=o.target&&o.target.click();return n(c)}(e);case"list":return async function(e,t){for(const o of e.actions)await i(o,t)}(e,a);case"consent":return async function(e,t){for(const c of e.consents){const e=-1!==t.indexOf(c.type);if(c.matcher&&c.toggleAction){o(c.matcher)!==e&&await i(c.toggleAction)}else e?await i(c.trueAction):await i(c.falseAction)}}(e,a);case"ifcss":return async function(e,o){const c=t.find(e);c.target?e.falseAction&&await i(e.falseAction,o):e.trueAction&&await i(e.trueAction,o)}(e,a);case"waitcss":return async function(e){await new Promise((o=>{let i=e.retries||10;const c=e.waitTime||250,n=()=>{const a=t.find(e);(e.negated&&a.target||!e.negated&&!a.target)&&i>0?(i-=1,setTimeout(n,c)):o()};n()}))}(e);case"foreach":return async function(e,o){const c=t.find(e,!0),n=t.base;for(const n of c)n.target&&(t.setBase(n.target),await i(e.action,o));t.setBase(n)}(e,a);case"hide":return async function(e){const o=t.find(e);o.target&&o.target.classList.add("Autoconsent-Hidden")}(e);case"slide":return async function(e){const o=t.find(e),i=t.find(e.dragTarget);if(o.target){const e=o.target.getBoundingClientRect(),t=i.target.getBoundingClientRect();let c=t.top-e.top,n=t.left-e.left;"y"===this.config.axis.toLowerCase()&&(n=0),"x"===this.config.axis.toLowerCase()&&(c=0);const a=window.screenX+e.left+e.width/2,s=window.screenY+e.top+e.height/2,r=e.left+e.width/2,l=e.top+e.height/2,p=document.createEvent("MouseEvents");p.initMouseEvent("mousedown",!0,!0,window,0,a,s,r,l,!1,!1,!1,!1,0,o.target);const d=document.createEvent("MouseEvents");d.initMouseEvent("mousemove",!0,!0,window,0,a+n,s+c,r+n,l+c,!1,!1,!1,!1,0,o.target);const u=document.createEvent("MouseEvents");u.initMouseEvent("mouseup",!0,!0,window,0,a+n,s+c,r+n,l+c,!1,!1,!1,!1,0,o.target),o.target.dispatchEvent(p),await this.waitTimeout(10),o.target.dispatchEvent(d),await this.waitTimeout(10),o.target.dispatchEvent(u)}}(e);case"close":return async function(){window.close()}();case"wait":return async function(e){await n(e.waitTime)}(e);case"eval":return async function(e){return console.log("eval!",e.code),new Promise((t=>{try{e.async?(window.eval(e.code),setTimeout((()=>{t(window.eval("window.__consentCheckResult"))}),e.timeout||250)):t(window.eval(e.code))}catch(o){console.warn("eval error",o,e.code),t(!1)}}))}(e);default:throw"Unknown action type: "+e.type}}var c=0;function n(e){return new Promise((t=>{setTimeout((()=>{t()}),e)}))}function a(){return crypto&&void 0!==crypto.randomUUID?crypto.randomUUID():Math.random().toString()}var s=class{constructor(e,t=1e3){this.id=e,this.promise=new Promise(((e,t)=>{this.resolve=e,this.reject=t})),this.timer=window.setTimeout((()=>{this.reject(new Error("timeout"))}),t)}},r={pending:new Map,sendContentMessage:null};var l={EVAL_0:()=>console.log(1),EVAL_CONSENTMANAGER_1:()=>window.__cmp&&"object"==typeof __cmp("getCMPData"),EVAL_CONSENTMANAGER_2:()=>!__cmp("consentStatus").userChoiceExists,EVAL_CONSENTMANAGER_3:()=>__cmp("setConsent",0),EVAL_CONSENTMANAGER_4:()=>__cmp("setConsent",1),EVAL_CONSENTMANAGER_5:()=>__cmp("consentStatus").userChoiceExists,EVAL_COOKIEBOT_1:()=>!!window.Cookiebot,EVAL_COOKIEBOT_2:()=>!window.Cookiebot.hasResponse&&!0===window.Cookiebot.dialog?.visible,EVAL_COOKIEBOT_3:()=>window.Cookiebot.withdraw()||!0,EVAL_COOKIEBOT_4:()=>window.Cookiebot.hide()||!0,EVAL_COOKIEBOT_5:()=>!0===window.Cookiebot.declined,EVAL_KLARO_1:()=>{const e=globalThis.klaroConfig||globalThis.klaro?.getManager&&globalThis.klaro.getManager().config;if(!e)return!0;const t=(e.services||e.apps).filter((e=>!e.required)).map((e=>e.name));if(klaro&&klaro.getManager){const e=klaro.getManager();return t.every((t=>!e.consents[t]))}if(klaroConfig&&"cookie"===klaroConfig.storageMethod){const e=klaroConfig.cookieName||klaroConfig.storageName,o=JSON.parse(decodeURIComponent(document.cookie.split(";").find((t=>t.trim().startsWith(e))).split("=")[1]));return Object.keys(o).filter((e=>t.includes(e))).every((e=>!1===o[e]))}},EVAL_KLARO_OPEN_POPUP:()=>{klaro.show(void 0,!0)},EVAL_KLARO_TRY_API_OPT_OUT:()=>{if(window.klaro&&"function"==typeof klaro.show&&"function"==typeof klaro.getManager)try{return klaro.getManager().changeAll(!1),klaro.getManager().saveAndApplyConsents(),!0}catch(e){return console.warn(e),!1}return!1},EVAL_ONETRUST_1:()=>window.OnetrustActiveGroups.split(",").filter((e=>e.length>0)).length<=1,EVAL_TRUSTARC_TOP:()=>window&&window.truste&&"0"===window.truste.eu.bindMap.prefCookie,EVAL_TRUSTARC_FRAME_TEST:()=>window&&window.QueryString&&"0"===window.QueryString.preferences,EVAL_TRUSTARC_FRAME_GTM:()=>window&&window.QueryString&&"1"===window.QueryString.gtm,EVAL_ABC_TEST:()=>document.cookie.includes("trackingconsent"),EVAL_ADROLL_0:()=>!document.cookie.includes("__adroll_fpc"),EVAL_ALMACMP_0:()=>document.cookie.includes('"name":"Google","consent":false'),EVAL_AFFINITY_SERIF_COM_0:()=>document.cookie.includes("serif_manage_cookies_viewed")&&!document.cookie.includes("serif_allow_analytics"),EVAL_ARBEITSAGENTUR_TEST:()=>document.cookie.includes("cookie_consent=denied"),EVAL_AXEPTIO_0:()=>document.cookie.includes("axeptio_authorized_vendors=%2C%2C"),EVAL_BAHN_TEST:()=>1===utag.gdpr.getSelectedCategories().length,EVAL_BING_0:()=>document.cookie.includes("AL=0")&&document.cookie.includes("AD=0")&&document.cookie.includes("SM=0"),EVAL_BLOCKSY_0:()=>document.cookie.includes("blocksy_cookies_consent_accepted=no"),EVAL_BORLABS_0:()=>!JSON.parse(decodeURIComponent(document.cookie.split(";").find((e=>-1!==e.indexOf("borlabs-cookie"))).split("=",2)[1])).consents.statistics,EVAL_BUNDESREGIERUNG_DE_0:()=>document.cookie.match("cookie-allow-tracking=0"),EVAL_CANVA_0:()=>!document.cookie.includes("gtm_fpc_engagement_event"),EVAL_CC_BANNER2_0:()=>!!document.cookie.match(/sncc=[^;]+D%3Dtrue/),EVAL_CLICKIO_0:()=>document.cookie.includes("__lxG__consent__v2_daisybit="),EVAL_CLINCH_0:()=>document.cookie.includes("ctc_rejected=1"),EVAL_COOKIECONSENT2_TEST:()=>document.cookie.includes("cc_cookie="),EVAL_COOKIECONSENT3_TEST:()=>document.cookie.includes("cc_cookie="),EVAL_COINBASE_0:()=>JSON.parse(decodeURIComponent(document.cookie.match(/cm_(eu|default)_preferences=([0-9a-zA-Z\\{\\}\\[\\]%:]*);?/)[2])).consent.length<=1,EVAL_COMPLIANZ_BANNER_0:()=>document.cookie.includes("cmplz_banner-status=dismissed"),EVAL_COOKIE_LAW_INFO_0:()=>CLI.disableAllCookies()||CLI.reject_close()||!0,EVAL_COOKIE_LAW_INFO_1:()=>-1===document.cookie.indexOf("cookielawinfo-checkbox-non-necessary=yes"),EVAL_COOKIE_LAW_INFO_DETECT:()=>!!window.CLI,EVAL_COOKIE_MANAGER_POPUP_0:()=>!1===JSON.parse(document.cookie.split(";").find((e=>e.trim().startsWith("CookieLevel"))).split("=")[1]).social,EVAL_COOKIEALERT_0:()=>document.querySelector("body").removeAttribute("style")||!0,EVAL_COOKIEALERT_1:()=>document.querySelector("body").removeAttribute("style")||!0,EVAL_COOKIEALERT_2:()=>!0===window.CookieConsent.declined,EVAL_COOKIEFIRST_0:()=>{return!1===(e=JSON.parse(decodeURIComponent(document.cookie.split(";").find((e=>-1!==e.indexOf("cookiefirst"))).trim()).split("=")[1])).performance&&!1===e.functional&&!1===e.advertising;var e},EVAL_COOKIEFIRST_1:()=>document.querySelectorAll("button[data-cookiefirst-accent-color=true][role=checkbox]:not([disabled])").forEach((e=>"true"==e.getAttribute("aria-checked")&&e.click()))||!0,EVAL_COOKIEINFORMATION_0:()=>CookieInformation.declineAllCategories()||!0,EVAL_COOKIEINFORMATION_1:()=>CookieInformation.submitAllCategories()||!0,EVAL_COOKIEINFORMATION_2:()=>document.cookie.includes("CookieInformationConsent="),EVAL_COOKIEYES_0:()=>document.cookie.includes("advertisement:no"),EVAL_DAILYMOTION_0:()=>!!document.cookie.match("dm-euconsent-v2"),EVAL_DNDBEYOND_TEST:()=>document.cookie.includes("cookie-consent=denied"),EVAL_DSGVO_0:()=>!document.cookie.includes("sp_dsgvo_cookie_settings"),EVAL_DUNELM_0:()=>document.cookie.includes("cc_functional=0")&&document.cookie.includes("cc_targeting=0"),EVAL_ETSY_0:()=>document.querySelectorAll(".gdpr-overlay-body input").forEach((e=>{e.checked=!1}))||!0,EVAL_ETSY_1:()=>document.querySelector(".gdpr-overlay-view button[data-wt-overlay-close]").click()||!0,EVAL_EU_COOKIE_COMPLIANCE_0:()=>-1===document.cookie.indexOf("cookie-agreed=2"),EVAL_EU_COOKIE_LAW_0:()=>!document.cookie.includes("euCookie"),EVAL_EZOIC_0:()=>ezCMP.handleAcceptAllClick(),EVAL_EZOIC_1:()=>!!document.cookie.match(/ez-consent-tcf/),EVAL_FIDES_DETECT_POPUP:()=>window.Fides?.initialized,EVAL_GOOGLE_0:()=>!!document.cookie.match(/SOCS=CAE/),EVAL_HEMA_TEST_0:()=>document.cookie.includes("cookies_rejected=1"),EVAL_IUBENDA_0:()=>document.querySelectorAll(".purposes-item input[type=checkbox]:not([disabled])").forEach((e=>{e.checked&&e.click()}))||!0,EVAL_IUBENDA_1:()=>!!document.cookie.match(/_iub_cs-\d+=/),EVAL_IWINK_TEST:()=>document.cookie.includes("cookie_permission_granted=no"),EVAL_JQUERY_COOKIEBAR_0:()=>!document.cookie.includes("cookies-state=accepted"),EVAL_KETCH_TEST:()=>document.cookie.includes("_ketch_consent_v1_"),EVAL_MEDIAVINE_0:()=>document.querySelectorAll('[data-name="mediavine-gdpr-cmp"] input[type=checkbox]').forEach((e=>e.checked&&e.click()))||!0,EVAL_MICROSOFT_0:()=>Array.from(document.querySelectorAll("div > button")).filter((e=>e.innerText.match("Reject|Ablehnen")))[0].click()||!0,EVAL_MICROSOFT_1:()=>Array.from(document.querySelectorAll("div > button")).filter((e=>e.innerText.match("Accept|Annehmen")))[0].click()||!0,EVAL_MICROSOFT_2:()=>!!document.cookie.match("MSCC|GHCC"),EVAL_MOOVE_0:()=>document.querySelectorAll("#moove_gdpr_cookie_modal input").forEach((e=>{e.disabled||(e.checked="moove_gdpr_strict_cookies"===e.name||"moove_gdpr_strict_cookies"===e.id)}))||!0,EVAL_ONENINETWO_0:()=>document.cookie.includes("CC_ADVERTISING=NO")&&document.cookie.includes("CC_ANALYTICS=NO"),EVAL_OPENAI_TEST:()=>document.cookie.includes("oai-allow-ne=false"),EVAL_OPERA_0:()=>document.cookie.includes("cookie_consent_essential=true")&&!document.cookie.includes("cookie_consent_marketing=true"),EVAL_PAYPAL_0:()=>!0===document.cookie.includes("cookie_prefs"),EVAL_PRIMEBOX_0:()=>!document.cookie.includes("cb-enabled=accepted"),EVAL_PUBTECH_0:()=>document.cookie.includes("euconsent-v2")&&(document.cookie.match(/.YAAAAAAAAAAA/)||document.cookie.match(/.aAAAAAAAAAAA/)||document.cookie.match(/.YAAACFgAAAAA/)),EVAL_REDDIT_0:()=>document.cookie.includes("eu_cookie={%22opted%22:true%2C%22nonessential%22:false}"),EVAL_ROBLOX_TEST:()=>document.cookie.includes("RBXcb"),EVAL_SIRDATA_UNBLOCK_SCROLL:()=>(document.documentElement.classList.forEach((e=>{e.startsWith("sd-cmp-")&&document.documentElement.classList.remove(e)})),!0),EVAL_SNIGEL_0:()=>!!document.cookie.match("snconsent"),EVAL_STEAMPOWERED_0:()=>2===JSON.parse(decodeURIComponent(document.cookie.split(";").find((e=>e.trim().startsWith("cookieSettings"))).split("=")[1])).preference_state,EVAL_SVT_TEST:()=>document.cookie.includes('cookie-consent-1={"optedIn":true,"functionality":false,"statistics":false}'),EVAL_TAKEALOT_0:()=>document.body.classList.remove("freeze")||(document.body.style="")||!0,EVAL_TARTEAUCITRON_0:()=>tarteaucitron.userInterface.respondAll(!1)||!0,EVAL_TARTEAUCITRON_1:()=>tarteaucitron.userInterface.respondAll(!0)||!0,EVAL_TARTEAUCITRON_2:()=>document.cookie.match(/tarteaucitron=[^;]*/)?.[0].includes("false"),EVAL_TAUNTON_TEST:()=>document.cookie.includes("taunton_user_consent_submitted=true"),EVAL_TEALIUM_0:()=>void 0!==window.utag&&"object"==typeof utag.gdpr,EVAL_TEALIUM_1:()=>utag.gdpr.setConsentValue(!1)||!0,EVAL_TEALIUM_DONOTSELL:()=>utag.gdpr.dns?.setDnsState(!1)||!0,EVAL_TEALIUM_2:()=>utag.gdpr.setConsentValue(!0)||!0,EVAL_TEALIUM_3:()=>1!==utag.gdpr.getConsentState(),EVAL_TEALIUM_DONOTSELL_CHECK:()=>1!==utag.gdpr.dns?.getDnsState(),EVAL_TESLA_TEST:()=>document.cookie.includes("tsla-cookie-consent=rejected"),EVAL_TESTCMP_0:()=>"button_clicked"===window.results.results[0],EVAL_TESTCMP_COSMETIC_0:()=>"banner_hidden"===window.results.results[0],EVAL_THEFREEDICTIONARY_0:()=>cmpUi.showPurposes()||cmpUi.rejectAll()||!0,EVAL_THEFREEDICTIONARY_1:()=>cmpUi.allowAll()||!0,EVAL_THEVERGE_0:()=>document.cookie.includes("_duet_gdpr_acknowledged=1"),EVAL_TWCC_TEST:()=>document.cookie.includes("twCookieConsent="),EVAL_UBUNTU_COM_0:()=>document.cookie.includes("_cookies_accepted=essential"),EVAL_UK_COOKIE_CONSENT_0:()=>!document.cookie.includes("catAccCookies"),EVAL_USERCENTRICS_API_0:()=>"object"==typeof UC_UI,EVAL_USERCENTRICS_API_1:()=>!!UC_UI.closeCMP(),EVAL_USERCENTRICS_API_2:()=>!!UC_UI.denyAllConsents(),EVAL_USERCENTRICS_API_3:()=>!!UC_UI.acceptAllConsents(),EVAL_USERCENTRICS_API_4:()=>!!UC_UI.closeCMP(),EVAL_USERCENTRICS_API_5:()=>!0===UC_UI.areAllConsentsAccepted(),EVAL_USERCENTRICS_API_6:()=>!1===UC_UI.areAllConsentsAccepted(),EVAL_USERCENTRICS_BUTTON_0:()=>JSON.parse(localStorage.getItem("usercentrics")).consents.every((e=>e.isEssential||!e.consentStatus)),EVAL_WAITROSE_0:()=>Array.from(document.querySelectorAll("label[id$=cookies-deny-label]")).forEach((e=>e.click()))||!0,EVAL_WAITROSE_1:()=>document.cookie.includes("wtr_cookies_advertising=0")&&document.cookie.includes("wtr_cookies_analytics=0"),EVAL_WP_COOKIE_NOTICE_0:()=>document.cookie.includes("wpl_viewed_cookie=no"),EVAL_XE_TEST:()=>document.cookie.includes("xeConsentState={%22performance%22:false%2C%22marketing%22:false%2C%22compliance%22:false}"),EVAL_XING_0:()=>document.cookie.includes("userConsent=%7B%22marketing%22%3Afalse"),EVAL_YOUTUBE_DESKTOP_0:()=>!!document.cookie.match(/SOCS=CAE/),EVAL_YOUTUBE_MOBILE_0:()=>!!document.cookie.match(/SOCS=CAE/)};var p={main:!0,frame:!1,urlPattern:""},d=class{constructor(e){this.runContext=p,this.autoconsent=e}get hasSelfTest(){throw new Error("Not Implemented")}get isIntermediate(){throw new Error("Not Implemented")}get isCosmetic(){throw new Error("Not Implemented")}mainWorldEval(e){const t=l[e];if(!t)return console.warn("Snippet not found",e),Promise.resolve(!1);const o=this.autoconsent.config.logs;if(this.autoconsent.config.isMainWorld){o.evals&&console.log("inline eval:",e,t);let i=!1;try{i=!!t.call(globalThis)}catch(t){o.evals&&console.error("error evaluating rule",e,t)}return Promise.resolve(i)}const i=`(${t.toString()})()`;return o.evals&&console.log("async eval:",e,i),function(e,t){const o=a();r.sendContentMessage({type:"eval",id:o,code:e,snippetId:t});const i=new s(o);return r.pending.set(i.id,i),i.promise}(i,e).catch((t=>(o.evals&&console.error("error evaluating rule",e,t),!1)))}checkRunContext(){const e={...p,...this.runContext},t=window.top===window;return!(t&&!e.main)&&(!(!t&&!e.frame)&&!(e.urlPattern&&!window.location.href.match(e.urlPattern)))}detectCmp(){throw new Error("Not Implemented")}async detectPopup(){return!1}optOut(){throw new Error("Not Implemented")}optIn(){throw new Error("Not Implemented")}openCmp(){throw new Error("Not Implemented")}async test(){return Promise.resolve(!0)}click(e,t=!1){return this.autoconsent.domActions.click(e,t)}elementExists(e){return this.autoconsent.domActions.elementExists(e)}elementVisible(e,t){return this.autoconsent.domActions.elementVisible(e,t)}waitForElement(e,t){return this.autoconsent.domActions.waitForElement(e,t)}waitForVisible(e,t,o){return this.autoconsent.domActions.waitForVisible(e,t,o)}waitForThenClick(e,t,o){return this.autoconsent.domActions.waitForThenClick(e,t,o)}wait(e){return this.autoconsent.domActions.wait(e)}hide(e,t){return this.autoconsent.domActions.hide(e,t)}prehide(e){return this.autoconsent.domActions.prehide(e)}undoPrehide(){return this.autoconsent.domActions.undoPrehide()}querySingleReplySelector(e,t){return this.autoconsent.domActions.querySingleReplySelector(e,t)}querySelectorChain(e){return this.autoconsent.domActions.querySelectorChain(e)}elementSelector(e){return this.autoconsent.domActions.elementSelector(e)}},u=class extends d{constructor(e,t){super(t),this.rule=e,this.name=e.name,this.runContext=e.runContext||p}get hasSelfTest(){return!!this.rule.test}get isIntermediate(){return!!this.rule.intermediate}get isCosmetic(){return!!this.rule.cosmetic}get prehideSelectors(){return this.rule.prehideSelectors}async detectCmp(){return!!this.rule.detectCmp&&this._runRulesParallel(this.rule.detectCmp)}async detectPopup(){return!!this.rule.detectPopup&&this._runRulesSequentially(this.rule.detectPopup)}async optOut(){const e=this.autoconsent.config.logs;return!!this.rule.optOut&&(e.lifecycle&&console.log("Initiated optOut()",this.rule.optOut),this._runRulesSequentially(this.rule.optOut))}async optIn(){const e=this.autoconsent.config.logs;return!!this.rule.optIn&&(e.lifecycle&&console.log("Initiated optIn()",this.rule.optIn),this._runRulesSequentially(this.rule.optIn))}async openCmp(){return!!this.rule.openCmp&&this._runRulesSequentially(this.rule.openCmp)}async test(){return this.hasSelfTest?this._runRulesSequentially(this.rule.test):super.test()}async evaluateRuleStep(e){const t=[],o=this.autoconsent.config.logs;if(e.exists&&t.push(this.elementExists(e.exists)),e.visible&&t.push(this.elementVisible(e.visible,e.check)),e.eval){const o=this.mainWorldEval(e.eval);t.push(o)}if(e.waitFor&&t.push(this.waitForElement(e.waitFor,e.timeout)),e.waitForVisible&&t.push(this.waitForVisible(e.waitForVisible,e.timeout,e.check)),e.click&&t.push(this.click(e.click,e.all)),e.waitForThenClick&&t.push(this.waitForThenClick(e.waitForThenClick,e.timeout,e.all)),e.wait&&t.push(this.wait(e.wait)),e.hide&&t.push(this.hide(e.hide,e.method)),e.if){if(!e.if.exists&&!e.if.visible)return console.error("invalid conditional rule",e.if),!1;const i=await this.evaluateRuleStep(e.if);o.rulesteps&&console.log("Condition is",i),i?t.push(this._runRulesSequentially(e.then)):e.else?t.push(this._runRulesSequentially(e.else)):t.push(!0)}if(e.any){for(const t of e.any)if(await this.evaluateRuleStep(t))return!0;return!1}if(0===t.length)return o.errors&&console.warn("Unrecognized rule",e),!1;return(await Promise.all(t)).reduce(((e,t)=>e&&t),!0)}async _runRulesParallel(e){const t=e.map((e=>this.evaluateRuleStep(e)));return(await Promise.all(t)).every((e=>!!e))}async _runRulesSequentially(e){const t=this.autoconsent.config.logs;for(const o of e){t.rulesteps&&console.log("Running rule...",o);const e=await this.evaluateRuleStep(o);if(t.rulesteps&&console.log("...rule result",e),!e&&!o.optional)return!1}return!0}},m=class{constructor(e,t){this.name=e,this.config=t,this.methods=new Map,this.runContext=p,this.isCosmetic=!1,t.methods.forEach((e=>{e.action&&this.methods.set(e.name,e.action)})),this.hasSelfTest=!1}get isIntermediate(){return!1}checkRunContext(){return!0}async detectCmp(){return this.config.detectors.map((e=>o(e.presentMatcher))).some((e=>!!e))}async detectPopup(){return this.config.detectors.map((e=>o(e.showingMatcher))).some((e=>!!e))}async executeAction(e,t){return!this.methods.has(e)||i(this.methods.get(e),t)}async optOut(){return await this.executeAction("HIDE_CMP"),await this.executeAction("OPEN_OPTIONS"),await this.executeAction("HIDE_CMP"),await this.executeAction("DO_CONSENT",[]),await this.executeAction("SAVE_CONSENT"),!0}async optIn(){return await this.executeAction("HIDE_CMP"),await this.executeAction("OPEN_OPTIONS"),await this.executeAction("HIDE_CMP"),await this.executeAction("DO_CONSENT",["D","A","B","E","F","X"]),await this.executeAction("SAVE_CONSENT"),!0}async openCmp(){return await this.executeAction("HIDE_CMP"),await this.executeAction("OPEN_OPTIONS"),!0}async test(){return!0}};function h(e="autoconsent-css-rules"){const t=`style#${e}`,o=document.querySelector(t);if(o&&o instanceof HTMLStyleElement)return o;{const t=document.head||document.getElementsByTagName("head")[0]||document.documentElement,o=document.createElement("style");return o.id=e,t.appendChild(o),o}}function k(e,t,o="display"){const i=`${t} { ${"opacity"===o?"opacity: 0":"display: none"} !important; z-index: -1 !important; pointer-events: none !important; } `;return e instanceof HTMLStyleElement&&(e.innerText+=i,t.length>0)}async function b(e,t,o){const i=await e();return!i&&t>0?new Promise((i=>{setTimeout((async()=>{i(b(e,t-1,o))}),o)})):Promise.resolve(i)}function _(e){if(!e)return!1;if(null!==e.offsetParent)return!0;{const t=window.getComputedStyle(e);if("fixed"===t.position&&"none"!==t.display)return!0}return!1}function g(e){const t={enabled:!0,autoAction:"optOut",disabledCmps:[],enablePrehide:!0,enableCosmeticRules:!0,detectRetries:20,isMainWorld:!1,prehideTimeout:2e3,logs:{lifecycle:!1,rulesteps:!1,evals:!1,errors:!0,messages:!1}},o=(i=t,globalThis.structuredClone?structuredClone(i):JSON.parse(JSON.stringify(i)));var i;for(const i of Object.keys(t))void 0!==e[i]&&(o[i]=e[i]);return o}var y="#truste-show-consent",w="#truste-consent-track",C=[class extends d{constructor(e){super(e),this.name="TrustArc-top",this.prehideSelectors=[".trustarc-banner-container",`.truste_popframe,.truste_overlay,.truste_box_overlay,${w}`],this.runContext={main:!0,frame:!1},this._shortcutButton=null,this._optInDone=!1}get hasSelfTest(){return!0}get isIntermediate(){return!this._optInDone&&!this._shortcutButton}get isCosmetic(){return!1}async detectCmp(){const e=this.elementExists(`${y},${w}`);return e&&(this._shortcutButton=document.querySelector("#truste-consent-required")),e}async detectPopup(){return this.elementVisible(`#truste-consent-content,#trustarc-banner-overlay,${w}`,"all")}openFrame(){this.click(y)}async optOut(){return this._shortcutButton?(this._shortcutButton.click(),!0):(k(h(),`.truste_popframe, .truste_overlay, .truste_box_overlay, ${w}`),this.click(y),setTimeout((()=>{h().remove()}),1e4),!0)}async optIn(){return this._optInDone=!0,this.click("#truste-consent-button")}async openCmp(){return!0}async test(){return await this.wait(500),await this.mainWorldEval("EVAL_TRUSTARC_TOP")}},class extends d{constructor(){super(...arguments),this.name="TrustArc-frame",this.runContext={main:!1,frame:!0,urlPattern:"^https://consent-pref\\.trustarc\\.com/\\?"}}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return!0}async detectPopup(){return this.elementVisible("#defaultpreferencemanager","any")&&this.elementVisible(".mainContent","any")}async navigateToSettings(){return await b((async()=>this.elementExists(".shp")||this.elementVisible(".advance","any")||this.elementExists(".switch span:first-child")),10,500),this.elementExists(".shp")&&this.click(".shp"),await this.waitForElement(".prefPanel",5e3),this.elementVisible(".advance","any")&&this.click(".advance"),await b((()=>this.elementVisible(".switch span:first-child","any")),5,1e3)}async optOut(){if(await this.mainWorldEval("EVAL_TRUSTARC_FRAME_TEST"))return!0;let e=3e3;return await this.mainWorldEval("EVAL_TRUSTARC_FRAME_GTM")&&(e=1500),await b((()=>"complete"===document.readyState),20,100),await this.waitForElement(".mainContent[aria-hidden=false]",e),!!this.click(".rejectAll")||(this.elementExists(".prefPanel")&&await this.waitForElement('.prefPanel[style="visibility: visible;"]',e),this.click("#catDetails0")?(this.click(".submit"),this.waitForThenClick("#gwt-debug-close_id",e),!0):this.click(".required")?(this.waitForThenClick("#gwt-debug-close_id",e),!0):(await this.navigateToSettings(),this.click(".switch span:nth-child(1):not(.active)",!0),this.click(".submit"),this.waitForThenClick("#gwt-debug-close_id",10*e),!0))}async optIn(){return this.click(".call")||(await this.navigateToSettings(),this.click(".switch span:nth-child(2)",!0),this.click(".submit"),this.waitForElement("#gwt-debug-close_id",3e5).then((()=>{this.click("#gwt-debug-close_id")}))),!0}async test(){return await this.wait(500),await this.mainWorldEval("EVAL_TRUSTARC_FRAME_TEST")}},class extends d{constructor(){super(...arguments),this.name="Cybotcookiebot",this.prehideSelectors=["#CybotCookiebotDialog,#CybotCookiebotDialogBodyUnderlay,#dtcookie-container,#cookiebanner,#cb-cookieoverlay,.modal--cookie-banner,#cookiebanner_outer,#CookieBanner"]}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return await this.mainWorldEval("EVAL_COOKIEBOT_1")}async detectPopup(){return this.mainWorldEval("EVAL_COOKIEBOT_2")}async optOut(){await this.wait(500);let e=await this.mainWorldEval("EVAL_COOKIEBOT_3");return await this.wait(500),e=e&&await this.mainWorldEval("EVAL_COOKIEBOT_4"),e}async optIn(){return this.elementExists("#dtcookie-container")?this.click(".h-dtcookie-accept"):(this.click(".CybotCookiebotDialogBodyLevelButton:not(:checked):enabled",!0),this.click("#CybotCookiebotDialogBodyLevelButtonAccept"),this.click("#CybotCookiebotDialogBodyButtonAccept"),!0)}async test(){return await this.wait(500),await this.mainWorldEval("EVAL_COOKIEBOT_5")}},class extends d{constructor(){super(...arguments),this.name="Sourcepoint-frame",this.prehideSelectors=["div[id^='sp_message_container_'],.message-overlay","#sp_privacy_manager_container"],this.ccpaNotice=!1,this.ccpaPopup=!1,this.runContext={main:!0,frame:!0}}get hasSelfTest(){return!1}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){const e=new URL(location.href);return e.searchParams.has("message_id")&&"ccpa-notice.sp-prod.net"===e.hostname?(this.ccpaNotice=!0,!0):"ccpa-pm.sp-prod.net"===e.hostname?(this.ccpaPopup=!0,!0):("/index.html"===e.pathname||"/privacy-manager/index.html"===e.pathname||"/ccpa_pm/index.html"===e.pathname)&&(e.searchParams.has("message_id")||e.searchParams.has("requestUUID")||e.searchParams.has("consentUUID"))}async detectPopup(){return!!this.ccpaNotice||(this.ccpaPopup?await this.waitForElement(".priv-save-btn",2e3):(await this.waitForElement(".sp_choice_type_11,.sp_choice_type_12,.sp_choice_type_13,.sp_choice_type_ACCEPT_ALL,.sp_choice_type_SAVE_AND_EXIT",2e3),!this.elementExists(".sp_choice_type_9")))}async optIn(){return await this.waitForElement(".sp_choice_type_11,.sp_choice_type_ACCEPT_ALL",2e3),!!this.click(".sp_choice_type_11")||!!this.click(".sp_choice_type_ACCEPT_ALL")}isManagerOpen(){return"/privacy-manager/index.html"===location.pathname||"/ccpa_pm/index.html"===location.pathname}async optOut(){const e=this.autoconsent.config.logs;if(this.ccpaPopup){const e=document.querySelectorAll(".priv-purpose-container .sp-switch-arrow-block a.neutral.on .right");for(const t of e)t.click();const t=document.querySelectorAll(".priv-purpose-container .sp-switch-arrow-block a.switch-bg.on");for(const e of t)e.click();return this.click(".priv-save-btn")}if(!this.isManagerOpen()){if(!await this.waitForElement(".sp_choice_type_12,.sp_choice_type_13"))return!1;if(!this.elementExists(".sp_choice_type_12"))return this.click(".sp_choice_type_13");this.click(".sp_choice_type_12"),await b((()=>this.isManagerOpen()),200,100)}await this.waitForElement(".type-modal",2e4),this.waitForThenClick(".ccpa-stack .pm-switch[aria-checked=true] .slider",500,!0);try{const e=".sp_choice_type_REJECT_ALL",t=".reject-toggle",o=await Promise.race([this.waitForElement(e,2e3).then((e=>e?0:-1)),this.waitForElement(t,2e3).then((e=>e?1:-1)),this.waitForElement(".pm-features",2e3).then((e=>e?2:-1))]);if(0===o)return await this.waitForVisible(e),this.click(e);1===o?this.click(t):2===o&&(await this.waitForElement(".pm-features",1e4),this.click(".checked > span",!0),this.click(".chevron"))}catch(t){e.errors&&console.warn(t)}return this.click(".sp_choice_type_SAVE_AND_EXIT")}},class extends d{constructor(){super(...arguments),this.name="consentmanager.net",this.prehideSelectors=["#cmpbox,#cmpbox2"],this.apiAvailable=!1}get hasSelfTest(){return this.apiAvailable}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.apiAvailable=await this.mainWorldEval("EVAL_CONSENTMANAGER_1"),!!this.apiAvailable||this.elementExists("#cmpbox")}async detectPopup(){return this.apiAvailable?(await this.wait(500),await this.mainWorldEval("EVAL_CONSENTMANAGER_2")):this.elementVisible("#cmpbox .cmpmore","any")}async optOut(){return await this.wait(500),this.apiAvailable?await this.mainWorldEval("EVAL_CONSENTMANAGER_3"):!!this.click(".cmpboxbtnno")||(this.elementExists(".cmpwelcomeprpsbtn")?(this.click(".cmpwelcomeprpsbtn > a[aria-checked=true]",!0),this.click(".cmpboxbtnsave"),!0):(this.click(".cmpboxbtncustom"),await this.waitForElement(".cmptblbox",2e3),this.click(".cmptdchoice > a[aria-checked=true]",!0),this.click(".cmpboxbtnyescustomchoices"),this.hide("#cmpwrapper,#cmpbox","display"),!0))}async optIn(){return this.apiAvailable?await this.mainWorldEval("EVAL_CONSENTMANAGER_4"):this.click(".cmpboxbtnyes")}async test(){if(this.apiAvailable)return await this.mainWorldEval("EVAL_CONSENTMANAGER_5")}},class extends d{constructor(){super(...arguments),this.name="Evidon"}get hasSelfTest(){return!1}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists("#_evidon_banner")}async detectPopup(){return this.elementVisible("#_evidon_banner","any")}async optOut(){return this.click("#_evidon-decline-button")||(k(h(),"#evidon-prefdiag-overlay,#evidon-prefdiag-background,#_evidon-background"),await this.waitForThenClick("#_evidon-option-button"),await this.waitForElement("#evidon-prefdiag-overlay",5e3),await this.wait(500),await this.waitForThenClick("#evidon-prefdiag-decline")),!0}async optIn(){return this.click("#_evidon-accept-button")}},class extends d{constructor(){super(...arguments),this.name="Onetrust",this.prehideSelectors=["#onetrust-banner-sdk,#onetrust-consent-sdk,.onetrust-pc-dark-filter,.js-consent-banner"],this.runContext={urlPattern:"^(?!.*https://www\\.nba\\.com/)"}}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists("#onetrust-banner-sdk,#onetrust-pc-sdk")}async detectPopup(){return this.elementVisible("#onetrust-banner-sdk,#onetrust-pc-sdk","any")}async optOut(){return this.elementVisible("#onetrust-reject-all-handler,.ot-pc-refuse-all-handler,.js-reject-cookies","any")?this.click("#onetrust-reject-all-handler,.ot-pc-refuse-all-handler,.js-reject-cookies"):(this.elementExists("#onetrust-pc-btn-handler")?this.click("#onetrust-pc-btn-handler"):this.click(".ot-sdk-show-settings,button.js-cookie-settings"),await this.waitForElement("#onetrust-consent-sdk",2e3),await this.wait(1e3),this.click("#onetrust-consent-sdk input.category-switch-handler:checked,.js-editor-toggle-state:checked",!0),await this.wait(1e3),await this.waitForElement(".save-preference-btn-handler,.js-consent-save",2e3),this.click(".save-preference-btn-handler,.js-consent-save"),await this.waitForVisible("#onetrust-banner-sdk",5e3,"none"),!0)}async optIn(){return this.click("#onetrust-accept-btn-handler,#accept-recommended-btn-handler,.js-accept-cookies")}async test(){return await b((()=>this.mainWorldEval("EVAL_ONETRUST_1")),10,500)}},class extends d{constructor(){super(...arguments),this.name="Klaro",this.prehideSelectors=[".klaro"],this.settingsOpen=!1}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists(".klaro > .cookie-modal")?(this.settingsOpen=!0,!0):this.elementExists(".klaro > .cookie-notice")}async detectPopup(){return this.elementVisible(".klaro > .cookie-notice,.klaro > .cookie-modal","any")}async optOut(){return!!await this.mainWorldEval("EVAL_KLARO_TRY_API_OPT_OUT")||(!!this.click(".klaro .cn-decline")||(await this.mainWorldEval("EVAL_KLARO_OPEN_POPUP"),!!this.click(".klaro .cn-decline")||(this.click(".cm-purpose:not(.cm-toggle-all) > input:not(.half-checked,.required,.only-required),.cm-purpose:not(.cm-toggle-all) > div > input:not(.half-checked,.required,.only-required)",!0),this.click(".cm-btn-accept,.cm-button"))))}async optIn(){return!!this.click(".klaro .cm-btn-accept-all")||(this.settingsOpen?(this.click(".cm-purpose:not(.cm-toggle-all) > input.half-checked",!0),this.click(".cm-btn-accept")):this.click(".klaro .cookie-notice .cm-btn-success"))}async test(){return await this.mainWorldEval("EVAL_KLARO_1")}},class extends d{constructor(){super(...arguments),this.name="Uniconsent"}get prehideSelectors(){return[".unic",".modal:has(.unic)"]}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists(".unic .unic-box,.unic .unic-bar,.unic .unic-modal")}async detectPopup(){return this.elementVisible(".unic .unic-box,.unic .unic-bar,.unic .unic-modal","any")}async optOut(){if(await this.waitForElement(".unic button",1e3),document.querySelectorAll(".unic button").forEach((e=>{const t=e.textContent;(t.includes("Manage Options")||t.includes("Optionen verwalten"))&&e.click()})),await this.waitForElement(".unic input[type=checkbox]",1e3)){await this.waitForElement(".unic button",1e3),document.querySelectorAll(".unic input[type=checkbox]").forEach((e=>{e.checked&&e.click()}));for(const e of document.querySelectorAll(".unic button")){const t=e.textContent;for(const o of["Confirm Choices","Save Choices","Auswahl speichern"])if(t.includes(o))return e.click(),await this.wait(500),!0}}return!1}async optIn(){return this.waitForThenClick(".unic #unic-agree")}async test(){await this.wait(1e3);return!this.elementExists(".unic .unic-box,.unic .unic-bar")}},class extends d{constructor(){super(...arguments),this.prehideSelectors=[".cmp-root"],this.name="Conversant"}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists(".cmp-root .cmp-receptacle")}async detectPopup(){return this.elementVisible(".cmp-root .cmp-receptacle","any")}async optOut(){if(!await this.waitForThenClick(".cmp-main-button:not(.cmp-main-button--primary)"))return!1;if(!await this.waitForElement(".cmp-view-tab-tabs"))return!1;await this.waitForThenClick(".cmp-view-tab-tabs > :first-child"),await this.waitForThenClick(".cmp-view-tab-tabs > .cmp-view-tab--active:first-child");for(const e of Array.from(document.querySelectorAll(".cmp-accordion-item"))){e.querySelector(".cmp-accordion-item-title").click(),await b((()=>!!e.querySelector(".cmp-accordion-item-content.cmp-active")),10,50);const t=e.querySelector(".cmp-accordion-item-content.cmp-active");t.querySelectorAll(".cmp-toggle-actions .cmp-toggle-deny:not(.cmp-toggle-deny--active)").forEach((e=>e.click())),t.querySelectorAll(".cmp-toggle-actions .cmp-toggle-checkbox:not(.cmp-toggle-checkbox--active)").forEach((e=>e.click()))}return await this.click(".cmp-main-button:not(.cmp-main-button--primary)"),!0}async optIn(){return this.waitForThenClick(".cmp-main-button.cmp-main-button--primary")}async test(){return document.cookie.includes("cmp-data=0")}},class extends d{constructor(){super(...arguments),this.name="tiktok.com",this.runContext={urlPattern:"tiktok"}}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}getShadowRoot(){const e=document.querySelector("tiktok-cookie-banner");return e?e.shadowRoot:null}async detectCmp(){return this.elementExists("tiktok-cookie-banner")}async detectPopup(){return _(this.getShadowRoot().querySelector(".tiktok-cookie-banner"))}async optOut(){const e=this.autoconsent.config.logs,t=this.getShadowRoot().querySelector(".button-wrapper button:first-child");return t?(e.rulesteps&&console.log("[clicking]",t),t.click(),!0):(e.errors&&console.log("no decline button found"),!1)}async optIn(){const e=this.autoconsent.config.logs,t=this.getShadowRoot().querySelector(".button-wrapper button:last-child");return t?(e.rulesteps&&console.log("[clicking]",t),t.click(),!0):(e.errors&&console.log("no accept button found"),!1)}async test(){const e=document.cookie.match(/cookie-consent=([^;]+)/);if(!e)return!1;const t=JSON.parse(decodeURIComponent(e[1]));return Object.values(t).every((e=>"boolean"!=typeof e||!1===e))}},class extends d{constructor(){super(...arguments),this.runContext={urlPattern:"^https://(www\\.)?airbnb\\.[^/]+/"},this.prehideSelectors=["div[data-testid=main-cookies-banner-container]",'div:has(> div:first-child):has(> div:last-child):has(> section [data-testid="strictly-necessary-cookies"])']}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists("div[data-testid=main-cookies-banner-container]")}async detectPopup(){return this.elementVisible("div[data-testid=main-cookies-banner-container","any")}async optOut(){let e;for(await this.waitForThenClick("div[data-testid=main-cookies-banner-container] button._snbhip0");e=document.querySelector("[data-testid=modal-container] button[aria-checked=true]:not([disabled])");)e.click();return this.waitForThenClick("button[data-testid=save-btn]")}async optIn(){return this.waitForThenClick("div[data-testid=main-cookies-banner-container] button._148dgdpk")}async test(){return await b((()=>!!document.cookie.match("OptanonAlertBoxClosed")),20,200)}},class extends d{constructor(){super(...arguments),this.name="tumblr-com",this.runContext={urlPattern:"^https://(www\\.)?tumblr\\.com/"}}get hasSelfTest(){return!1}get isIntermediate(){return!1}get isCosmetic(){return!1}get prehideSelectors(){return["#cmp-app-container"]}async detectCmp(){return this.elementExists("#cmp-app-container")}async detectPopup(){return this.elementVisible("#cmp-app-container","any")}async optOut(){let e=document.querySelector("#cmp-app-container iframe"),t=e.contentDocument?.querySelector(".cmp-components-button.is-secondary");return!!t&&(t.click(),await b((()=>{const e=document.querySelector("#cmp-app-container iframe");return!!e.contentDocument?.querySelector(".cmp__dialog input")}),5,500),e=document.querySelector("#cmp-app-container iframe"),t=e.contentDocument?.querySelector(".cmp-components-button.is-secondary"),!!t&&(t.click(),!0))}async optIn(){const e=document.querySelector("#cmp-app-container iframe").contentDocument.querySelector(".cmp-components-button.is-primary");return!!e&&(e.click(),!0)}},class extends d{constructor(){super(...arguments),this.name="Admiral"}get hasSelfTest(){return!1}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists("div > div[class*=Card] > div[class*=Frame] > div[class*=Pills] > button[class*=Pills__StyledPill]")}async detectPopup(){return this.elementVisible("div > div[class*=Card] > div[class*=Frame] > div[class*=Pills] > button[class*=Pills__StyledPill]","any")}async optOut(){const e="xpath///button[contains(., 'Afvis alle') or contains(., 'Reject all') or contains(., 'Odbaci sve') or contains(., 'Rechazar todo') or contains(., 'Atmesti visus') or contains(., 'Odmítnout vše') or contains(., 'Απόρριψη όλων') or contains(., 'Rejeitar tudo') or contains(., 'Tümünü reddet') or contains(., 'Отклонить все') or contains(., 'Noraidīt visu') or contains(., 'Avvisa alla') or contains(., 'Odrzuć wszystkie') or contains(., 'Alles afwijzen') or contains(., 'Отхвърляне на всички') or contains(., 'Rifiuta tutto') or contains(., 'Zavrni vse') or contains(., 'Az összes elutasítása') or contains(., 'Respingeți tot') or contains(., 'Alles ablehnen') or contains(., 'Tout rejeter') or contains(., 'Odmietnuť všetko') or contains(., 'Lükka kõik tagasi') or contains(., 'Hylkää kaikki')]";if(await this.waitForElement(e,500))return this.click(e);const t="xpath///button[contains(., 'Spara & avsluta') or contains(., 'Save & exit') or contains(., 'Uložit a ukončit') or contains(., 'Enregistrer et quitter') or contains(., 'Speichern & Verlassen') or contains(., 'Tallenna ja poistu') or contains(., 'Išsaugoti ir išeiti') or contains(., 'Opslaan & afsluiten') or contains(., 'Guardar y salir') or contains(., 'Shrani in zapri') or contains(., 'Uložiť a ukončiť') or contains(., 'Kaydet ve çıkış yap') or contains(., 'Сохранить и выйти') or contains(., 'Salvesta ja välju') or contains(., 'Salva ed esci') or contains(., 'Gem & afslut') or contains(., 'Αποθήκευση και έξοδος') or contains(., 'Saglabāt un iziet') or contains(., 'Mentés és kilépés') or contains(., 'Guardar e sair') or contains(., 'Zapisz & zakończ') or contains(., 'Salvare și ieșire') or contains(., 'Spremi i izađi') or contains(., 'Запазване и изход')]";if(await this.waitForThenClick("xpath///button[contains(., 'Zwecke') or contains(., 'Σκοποί') or contains(., 'Purposes') or contains(., 'Цели') or contains(., 'Eesmärgid') or contains(., 'Tikslai') or contains(., 'Svrhe') or contains(., 'Cele') or contains(., 'Účely') or contains(., 'Finalidades') or contains(., 'Mērķi') or contains(., 'Scopuri') or contains(., 'Fines') or contains(., 'Ändamål') or contains(., 'Finalités') or contains(., 'Doeleinden') or contains(., 'Tarkoitukset') or contains(., 'Scopi') or contains(., 'Amaçlar') or contains(., 'Nameni') or contains(., 'Célok') or contains(., 'Formål')]")&&await this.waitForVisible(t)){return this.elementSelector(t)[0].parentElement.parentElement.querySelectorAll("input[type=checkbox]:checked").forEach((e=>e.click())),this.click(t)}return!1}async optIn(){return this.click("xpath///button[contains(., 'Sprejmi vse') or contains(., 'Prihvati sve') or contains(., 'Godkänn alla') or contains(., 'Prijať všetko') or contains(., 'Принять все') or contains(., 'Aceptar todo') or contains(., 'Αποδοχή όλων') or contains(., 'Zaakceptuj wszystkie') or contains(., 'Accetta tutto') or contains(., 'Priimti visus') or contains(., 'Pieņemt visu') or contains(., 'Tümünü kabul et') or contains(., 'Az összes elfogadása') or contains(., 'Accept all') or contains(., 'Приемане на всички') or contains(., 'Accepter alle') or contains(., 'Hyväksy kaikki') or contains(., 'Tout accepter') or contains(., 'Alles accepteren') or contains(., 'Aktsepteeri kõik') or contains(., 'Přijmout vše') or contains(., 'Alles akzeptieren') or contains(., 'Aceitar tudo') or contains(., 'Acceptați tot')]")}}],v=class{constructor(e){this.autoconsentInstance=e}click(e,t=!1){const o=this.elementSelector(e);return this.autoconsentInstance.config.logs.rulesteps&&console.log("[click]",e,t,o),o.length>0&&(t?o.forEach((e=>e.click())):o[0].click()),o.length>0}elementExists(e){return this.elementSelector(e).length>0}elementVisible(e,t){const o=this.elementSelector(e),i=new Array(o.length);return o.forEach(((e,t)=>{i[t]=_(e)})),"none"===t?i.every((e=>!e)):0!==i.length&&("any"===t?i.some((e=>e)):i.every((e=>e)))}waitForElement(e,t=1e4){const o=Math.ceil(t/200);return this.autoconsentInstance.config.logs.rulesteps&&console.log("[waitForElement]",e),b((()=>this.elementSelector(e).length>0),o,200)}waitForVisible(e,t=1e4,o="any"){const i=Math.ceil(t/200);return this.autoconsentInstance.config.logs.rulesteps&&console.log("[waitForVisible]",e),b((()=>this.elementVisible(e,o)),i,200)}async waitForThenClick(e,t=1e4,o=!1){return await this.waitForElement(e,t),this.click(e,o)}wait(e){return this.autoconsentInstance.config.logs.rulesteps&&console.log("[wait]",e),new Promise((t=>{setTimeout((()=>{t(!0)}),e)}))}hide(e,t){this.autoconsentInstance.config.logs.rulesteps&&console.log("[hide]",e);return k(h(),e,t)}prehide(e){const t=h("autoconsent-prehide");return this.autoconsentInstance.config.logs.lifecycle&&console.log("[prehide]",t,location.href),k(t,e,"opacity")}undoPrehide(){const e=h("autoconsent-prehide");return this.autoconsentInstance.config.logs.lifecycle&&console.log("[undoprehide]",e,location.href),e&&e.remove(),!!e}querySingleReplySelector(e,t=document){if(e.startsWith("aria/"))return[];if(e.startsWith("xpath/")){const o=e.slice(6),i=document.evaluate(o,t,null,XPathResult.ANY_TYPE,null);let c=null;const n=[];for(;c=i.iterateNext();)n.push(c);return n}return e.startsWith("text/")||e.startsWith("pierce/")?[]:t.shadowRoot?Array.from(t.shadowRoot.querySelectorAll(e)):Array.from(t.querySelectorAll(e))}querySelectorChain(e){let t,o=document;for(const i of e){if(t=this.querySingleReplySelector(i,o),0===t.length)return[];o=t[0]}return t}elementSelector(e){return"string"==typeof e?this.querySingleReplySelector(e):this.querySelectorChain(e)}};var f=[{name:"192.com",detectCmp:[{exists:".ont-cookies"}],detectPopup:[{visible:".ont-cookies"}],optIn:[{click:".ont-btn-main.ont-cookies-btn.js-ont-btn-ok2"}],optOut:[{click:".ont-cookes-btn-manage"},{click:".ont-btn-main.ont-cookies-btn.js-ont-btn-choose"}],test:[{eval:"EVAL_ONENINETWO_0"}]},{name:"1password-com",cosmetic:!0,prehideSelectors:['footer #footer-root [aria-label="Cookie Consent"]'],detectCmp:[{exists:'footer #footer-root [aria-label="Cookie Consent"]'}],detectPopup:[{visible:'footer #footer-root [aria-label="Cookie Consent"]'}],optIn:[{click:'footer #footer-root [aria-label="Cookie Consent"] button'}],optOut:[{hide:'footer #footer-root [aria-label="Cookie Consent"]'}]},{name:"aa",vendorUrl:"https://aa.com",prehideSelectors:[],cosmetic:!0,detectCmp:[{exists:"#aa_optoutmulti-Modal,#cookieBannerMessage"}],detectPopup:[{visible:"#aa_optoutmulti-Modal,#cookieBannerMessage"}],optIn:[{hide:"#aa_optoutmulti-Modal,#cookieBannerMessage"},{waitForThenClick:"#aa_optoutmulti_checkBox"},{waitForThenClick:"#aa_optoutmulti-Modal button.optoutmulti_button"}],optOut:[{hide:"#aa_optoutmulti-Modal,#cookieBannerMessage"}]},{name:"abc",vendorUrl:"https://abc.net.au",runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?abc\\.net\\.au/"},prehideSelectors:[],detectCmp:[{exists:"[data-component=CookieBanner]"}],detectPopup:[{visible:"[data-component=CookieBanner] [data-component=CookieBanner_AcceptAll]"}],optIn:[{waitForThenClick:"[data-component=CookieBanner] [data-component=CookieBanner_AcceptAll]"}],optOut:[{waitForThenClick:"[data-component=CookieBanner] [data-component=CookieBanner_AcceptABCRequired]"}],test:[{eval:"EVAL_ABC_TEST"}]},{name:"abconcerts.be",vendorUrl:"https://unknown",intermediate:!1,prehideSelectors:["dialog.cookie-consent"],detectCmp:[{exists:"dialog.cookie-consent form.cookie-consent__form"}],detectPopup:[{visible:"dialog.cookie-consent form.cookie-consent__form"}],optIn:[{waitForThenClick:"dialog.cookie-consent form.cookie-consent__form button[value=yes]"}],optOut:[{if:{exists:"dialog.cookie-consent form.cookie-consent__form button[value=no]"},then:[{click:"dialog.cookie-consent form.cookie-consent__form button[value=no]"}],else:[{click:"dialog.cookie-consent form.cookie-consent__form button.cookie-consent__options-toggle"},{waitForThenClick:'dialog.cookie-consent form.cookie-consent__form button[value="save_options"]'}]}]},{name:"acris",prehideSelectors:["div.acris-cookie-consent"],detectCmp:[{exists:"[data-acris-cookie-consent]"}],detectPopup:[{visible:".acris-cookie-consent.is--modal"}],optIn:[{waitForVisible:"#ccConsentAcceptAllButton",check:"any"},{wait:500},{waitForThenClick:"#ccConsentAcceptAllButton"}],optOut:[{waitForVisible:"#ccAcceptOnlyFunctional",check:"any"},{wait:500},{waitForThenClick:"#ccAcceptOnlyFunctional"}]},{name:"activobank.pt",runContext:{urlPattern:"^https://(www\\.)?activobank\\.pt"},prehideSelectors:["aside#cookies,.overlay-cookies"],detectCmp:[{exists:"#cookies .cookies-btn"}],detectPopup:[{visible:"#cookies #submitCookies"}],optIn:[{waitForThenClick:"#cookies #submitCookies"}],optOut:[{waitForThenClick:"#cookies #rejectCookies"}]},{name:"Adroll",prehideSelectors:["#adroll_consent_container"],detectCmp:[{exists:"#adroll_consent_container"}],detectPopup:[{visible:"#adroll_consent_container"}],optIn:[{waitForThenClick:"#adroll_consent_accept"}],optOut:[{waitForThenClick:"#adroll_consent_reject"}],test:[{eval:"EVAL_ADROLL_0"}]},{name:"affinity.serif.com",detectCmp:[{exists:".c-cookie-banner button[data-qa='allow-all-cookies']"}],detectPopup:[{visible:".c-cookie-banner"}],optIn:[{click:'button[data-qa="allow-all-cookies"]'}],optOut:[{click:'button[data-qa="manage-cookies"]'},{waitFor:'.c-cookie-banner ~ [role="dialog"]'},{waitForThenClick:'.c-cookie-banner ~ [role="dialog"] input[type="checkbox"][value="true"]',all:!0},{click:'.c-cookie-banner ~ [role="dialog"] .c-modal__action button'}],test:[{wait:500},{eval:"EVAL_AFFINITY_SERIF_COM_0"}]},{name:"agolde.com",cosmetic:!0,prehideSelectors:["#modal-1 div[data-micromodal-close]"],detectCmp:[{exists:"#modal-1 div[aria-labelledby=modal-1-title]"}],detectPopup:[{exists:"#modal-1 div[data-micromodal-close]"}],optIn:[{click:'button[aria-label="Close modal"]'}],optOut:[{hide:"#modal-1 div[data-micromodal-close]"}]},{name:"aliexpress",vendorUrl:"https://aliexpress.com/",runContext:{urlPattern:"^https://.*\\.aliexpress\\.com/"},prehideSelectors:["#gdpr-new-container"],detectCmp:[{exists:"#gdpr-new-container,#voyager-gdpr > div"}],detectPopup:[{visible:"#gdpr-new-container,#voyager-gdpr > div"}],optIn:[{waitForThenClick:"#gdpr-new-container .btn-accept,#voyager-gdpr > div > div > button:nth-child(1)"}],optOut:[{if:{exists:"#voyager-gdpr > div"},then:[{waitForThenClick:"#voyager-gdpr > div > div > button:nth-child(2)"}],else:[{waitForThenClick:"#gdpr-new-container .btn-more"},{waitFor:"#gdpr-new-container .gdpr-dialog-switcher"},{click:"#gdpr-new-container .switcher-on",all:!0,optional:!0},{click:"#gdpr-new-container .btn-save"}]}]},{name:"almacmp",prehideSelectors:["#alma-cmpv2-container"],detectCmp:[{exists:"#alma-cmpv2-container"}],detectPopup:[{visible:"#alma-cmpv2-container #almacmp-modal-layer1"}],optIn:[{waitForThenClick:"#alma-cmpv2-container #almacmp-modal-layer1 #almacmp-modalConfirmBtn"}],optOut:[{waitForThenClick:"#alma-cmpv2-container #almacmp-modal-layer1 #almacmp-modalSettingBtn"},{waitFor:"#alma-cmpv2-container #almacmp-modal-layer2"},{waitForThenClick:"#alma-cmpv2-container #almacmp-modal-layer2 #almacmp-reject-all-layer2"}],test:[{eval:"EVAL_ALMACMP_0"}]},{name:"altium.com",cosmetic:!0,prehideSelectors:[".altium-privacy-bar"],detectCmp:[{exists:".altium-privacy-bar"}],detectPopup:[{exists:".altium-privacy-bar"}],optIn:[{click:"a.altium-privacy-bar__btn"}],optOut:[{hide:".altium-privacy-bar"}]},{name:"amazon.com",prehideSelectors:['span[data-action="sp-cc"][data-sp-cc*="rejectAllAction"]'],detectCmp:[{exists:'span[data-action="sp-cc"][data-sp-cc*="rejectAllAction"]'}],detectPopup:[{visible:'span[data-action="sp-cc"][data-sp-cc*="rejectAllAction"]'}],optIn:[{waitForVisible:"#sp-cc-accept"},{wait:500},{click:"#sp-cc-accept"}],optOut:[{waitForVisible:"#sp-cc-rejectall-link"},{wait:500},{click:"#sp-cc-rejectall-link"}]},{name:"amex",vendorUrl:"https://www.americanexpress.com/",cosmetic:!1,prehideSelectors:["#user-consent-management-granular-banner-overlay"],detectCmp:[{exists:"#user-consent-management-granular-banner-overlay"}],detectPopup:[{visible:"#user-consent-management-granular-banner-overlay"}],optIn:[{waitForThenClick:"[data-testid=granular-banner-button-accept-all]"}],optOut:[{waitForThenClick:"[data-testid=granular-banner-button-decline-all]"}]},{name:"aquasana.com",prehideSelectors:["#consent-tracking"],detectCmp:[{exists:"#consent-tracking"}],detectPopup:[{exists:"#consent-tracking"}],optIn:[{waitForThenClick:"#consent-tracking .affirm.btn"}],optOut:[{if:{exists:"#consent-tracking .decline.btn"},then:[{click:"#consent-tracking .decline.btn"}],else:[{hide:"#consent-tracking"}]}]},{name:"arbeitsagentur",vendorUrl:"https://www.arbeitsagentur.de/",prehideSelectors:[".modal-open bahf-cookie-disclaimer-dpl3"],detectCmp:[{exists:"bahf-cookie-disclaimer-dpl3"}],detectPopup:[{visible:"bahf-cookie-disclaimer-dpl3"}],optIn:[{waitForThenClick:["bahf-cookie-disclaimer-dpl3","bahf-cd-modal-dpl3 .ba-btn-primary"]}],optOut:[{waitForThenClick:["bahf-cookie-disclaimer-dpl3","bahf-cd-modal-dpl3 .ba-btn-contrast"]}],test:[{eval:"EVAL_ARBEITSAGENTUR_TEST"}]},{name:"asus",vendorUrl:"https://www.asus.com/",runContext:{urlPattern:"^https://www\\.asus\\.com/"},prehideSelectors:["#cookie-policy-info,#cookie-policy-info-bg"],detectCmp:[{exists:"#cookie-policy-info"}],detectPopup:[{visible:"#cookie-policy-info"}],optIn:[{waitForThenClick:'#cookie-policy-info [data-agree="Accept Cookies"]'}],optOut:[{if:{exists:"#cookie-policy-info .btn-reject"},then:[{waitForThenClick:"#cookie-policy-info .btn-reject"}],else:[{waitForThenClick:"#cookie-policy-info .btn-setting"},{waitForThenClick:'#cookie-policy-lightbox-wrapper [data-agree="Save Settings"]'}]}]},{name:"athlinks-com",runContext:{urlPattern:"^https://(www\\.)?athlinks\\.com/"},cosmetic:!0,prehideSelectors:["#footer-container ~ div"],detectCmp:[{exists:"#footer-container ~ div"}],detectPopup:[{visible:"#footer-container > div"}],optIn:[{click:"#footer-container ~ div button"}],optOut:[{hide:"#footer-container ~ div"}]},{name:"ausopen.com",cosmetic:!0,detectCmp:[{exists:".gdpr-popup__message"}],detectPopup:[{visible:".gdpr-popup__message"}],optOut:[{hide:".gdpr-popup__message"}],optIn:[{click:".gdpr-popup__message button"}]},{name:"automattic-cmp-optout",prehideSelectors:['form[class*="cookie-banner"][method="post"]'],detectCmp:[{exists:'form[class*="cookie-banner"][method="post"]'}],detectPopup:[{visible:'form[class*="cookie-banner"][method="post"]'}],optIn:[{click:'a[class*="accept-all-button"]'}],optOut:[{click:'form[class*="cookie-banner"] div[class*="simple-options"] a[class*="customize-button"]'},{waitForThenClick:"input[type=checkbox][checked]:not([disabled])",all:!0},{click:'a[class*="accept-selection-button"]'}]},{name:"aws.amazon.com",prehideSelectors:["#awsccc-cb-content","#awsccc-cs-container","#awsccc-cs-modalOverlay","#awsccc-cs-container-inner"],detectCmp:[{exists:"#awsccc-cb-content"}],detectPopup:[{visible:"#awsccc-cb-content"}],optIn:[{click:"button[data-id=awsccc-cb-btn-accept"}],optOut:[{click:"button[data-id=awsccc-cb-btn-customize]"},{waitFor:"input[aria-checked]"},{click:"input[aria-checked=true]",all:!0,optional:!0},{click:"button[data-id=awsccc-cs-btn-save]"}]},{name:"axeptio",prehideSelectors:[".axeptio_widget"],detectCmp:[{exists:".axeptio_widget"}],detectPopup:[{visible:".axeptio_widget"}],optIn:[{waitFor:".axeptio-widget--open"},{click:"button#axeptio_btn_acceptAll"}],optOut:[{waitFor:".axeptio-widget--open"},{click:"button#axeptio_btn_dismiss"}],test:[{eval:"EVAL_AXEPTIO_0"}]},{name:"baden-wuerttemberg.de",prehideSelectors:[".cookie-alert.t-dark"],cosmetic:!0,detectCmp:[{exists:".cookie-alert.t-dark"}],detectPopup:[{visible:".cookie-alert.t-dark"}],optIn:[{click:".cookie-alert__form input:not([disabled]):not([checked])"},{click:".cookie-alert__button button"}],optOut:[{hide:".cookie-alert.t-dark"}]},{name:"bahn-de",vendorUrl:"https://www.bahn.de/",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?bahn\\.de/"},intermediate:!1,prehideSelectors:[],detectCmp:[{exists:["body > div:first-child","#consent-layer"]}],detectPopup:[{visible:["body > div:first-child","#consent-layer"]}],optIn:[{waitForThenClick:["body > div:first-child","#consent-layer .js-accept-all-cookies"]}],optOut:[{waitForThenClick:["body > div:first-child","#consent-layer .js-accept-essential-cookies"]}],test:[{eval:"EVAL_BAHN_TEST"}]},{name:"bbb.org",runContext:{urlPattern:"^https://www\\.bbb\\.org/"},cosmetic:!0,prehideSelectors:['div[aria-label="use of cookies on bbb.org"]'],detectCmp:[{exists:'div[aria-label="use of cookies on bbb.org"]'}],detectPopup:[{visible:'div[aria-label="use of cookies on bbb.org"]'}],optIn:[{click:'div[aria-label="use of cookies on bbb.org"] button.bds-button-unstyled span.visually-hidden'}],optOut:[{hide:'div[aria-label="use of cookies on bbb.org"]'}]},{name:"bing.com",prehideSelectors:["#bnp_container"],detectCmp:[{exists:"#bnp_cookie_banner"}],detectPopup:[{visible:"#bnp_cookie_banner"}],optIn:[{click:"#bnp_btn_accept"}],optOut:[{click:"#bnp_btn_preference"},{click:"#mcp_savesettings"}],test:[{eval:"EVAL_BING_0"}]},{name:"blocksy",vendorUrl:"https://creativethemes.com/blocksy/docs/extensions/cookies-consent/",cosmetic:!1,runContext:{main:!0,frame:!1},intermediate:!1,prehideSelectors:[".cookie-notification"],detectCmp:[{exists:"#blocksy-ext-cookies-consent-styles-css"}],detectPopup:[{visible:".cookie-notification"}],optIn:[{click:".cookie-notification .ct-cookies-decline-button"}],optOut:[{waitForThenClick:".cookie-notification .ct-cookies-decline-button"}],test:[{eval:"EVAL_BLOCKSY_0"}]},{name:"borlabs",detectCmp:[{exists:"._brlbs-block-content"}],detectPopup:[{visible:"._brlbs-bar-wrap,._brlbs-box-wrap"}],optIn:[{click:"a[data-cookie-accept-all]"}],optOut:[{click:"a[data-cookie-individual]"},{waitForVisible:".cookie-preference"},{click:"input[data-borlabs-cookie-checkbox]:checked",all:!0,optional:!0},{click:"#CookiePrefSave"},{wait:500}],prehideSelectors:["#BorlabsCookieBox"],test:[{eval:"EVAL_BORLABS_0"}]},{name:"bundesregierung.de",prehideSelectors:[".bpa-cookie-banner"],detectCmp:[{exists:".bpa-cookie-banner"}],detectPopup:[{visible:".bpa-cookie-banner .bpa-module-full-hero"}],optIn:[{click:".bpa-accept-all-button"}],optOut:[{wait:500,comment:"click is not immediately recognized"},{waitForThenClick:".bpa-close-button"}],test:[{eval:"EVAL_BUNDESREGIERUNG_DE_0"}]},{name:"burpee.com",cosmetic:!0,prehideSelectors:["#notice-cookie-block"],detectCmp:[{exists:"#notice-cookie-block"}],detectPopup:[{exists:"#html-body #notice-cookie-block"}],optIn:[{click:"#btn-cookie-allow"}],optOut:[{hide:"#html-body #notice-cookie-block, #notice-cookie"}]},{name:"canva.com",prehideSelectors:['div[role="dialog"] a[data-anchor-id="cookie-policy"]'],detectCmp:[{exists:'div[role="dialog"] a[data-anchor-id="cookie-policy"]'}],detectPopup:[{exists:'div[role="dialog"] a[data-anchor-id="cookie-policy"]'}],optIn:[{click:'div[role="dialog"] button:nth-child(1)'}],optOut:[{if:{exists:'div[role="dialog"] button:nth-child(3)'},then:[{click:'div[role="dialog"] button:nth-child(2)'}],else:[{click:'div[role="dialog"] button:nth-child(2)'},{waitFor:'div[role="dialog"] a[data-anchor-id="cookie-policy"]'},{waitFor:'div[role="dialog"] button[role=switch]'},{click:'div[role="dialog"] button:nth-child(2):not([role])'},{click:'div[role="dialog"] div:last-child button:only-child'}]}],test:[{eval:"EVAL_CANVA_0"}]},{name:"canyon.com",runContext:{urlPattern:"^https://www\\.canyon\\.com/"},prehideSelectors:["div.modal.cookiesModal.is-open"],detectCmp:[{exists:"div.modal.cookiesModal.is-open"}],detectPopup:[{visible:"div.modal.cookiesModal.is-open"}],optIn:[{click:'div.cookiesModal__buttonWrapper > button[data-closecause="close-by-submit"]'}],optOut:[{click:'div.cookiesModal__buttonWrapper > button[data-closecause="close-by-manage-cookies"]'},{waitForThenClick:"button#js-manage-data-privacy-save-button"}]},{name:"cc-banner-springer",prehideSelectors:[".cc-banner[data-cc-banner]"],detectCmp:[{exists:".cc-banner[data-cc-banner]"}],detectPopup:[{visible:".cc-banner[data-cc-banner]"}],optIn:[{waitForThenClick:".cc-banner[data-cc-banner] button[data-cc-action=accept]"}],optOut:[{if:{exists:".cc-banner[data-cc-banner] button[data-cc-action=reject]"},then:[{click:".cc-banner[data-cc-banner] button[data-cc-action=reject]"}],else:[{waitForThenClick:".cc-banner[data-cc-banner] button[data-cc-action=preferences]"},{waitFor:".cc-preferences[data-cc-preferences]"},{click:".cc-preferences[data-cc-preferences] input[type=radio][data-cc-action=toggle-category][value=off]",all:!0,optional:!0},{if:{exists:".cc-preferences[data-cc-preferences] button[data-cc-action=reject]"},then:[{click:".cc-preferences[data-cc-preferences] button[data-cc-action=reject]"}],else:[{click:".cc-preferences[data-cc-preferences] button[data-cc-action=save]"}]}]}],test:[{eval:"EVAL_CC_BANNER2_0"}]},{name:"cc_banner",cosmetic:!0,prehideSelectors:[".cc_banner-wrapper"],detectCmp:[{exists:".cc_banner-wrapper"}],detectPopup:[{visible:".cc_banner"}],optIn:[{click:".cc_btn_accept_all"}],optOut:[{hide:".cc_banner-wrapper"}]},{name:"check24-partnerprogramm-de",prehideSelectors:["[data-modal-content]:has([data-toggle-target^='cookie'])"],detectCmp:[{exists:"[data-toggle-target^='cookie']"}],detectPopup:[{visible:"[data-toggle-target^='cookie']",check:"any"}],optIn:[{waitForThenClick:"[data-cookie-accept-all]"}],optOut:[{waitForThenClick:"[data-cookie-dismiss-all]"}]},{name:"ciaopeople.it",prehideSelectors:["#cp-gdpr-choices"],detectCmp:[{exists:"#cp-gdpr-choices"}],detectPopup:[{visible:"#cp-gdpr-choices"}],optIn:[{waitForThenClick:".gdpr-btm__right > button:nth-child(2)"}],optOut:[{waitForThenClick:".gdpr-top-content > button"},{waitFor:".gdpr-top-back"},{waitForThenClick:".gdpr-btm__right > button:nth-child(1)"}],test:[{visible:"#cp-gdpr-choices",check:"none"}]},{vendorUrl:"https://www.civicuk.com/cookie-control/",name:"civic-cookie-control",prehideSelectors:["#ccc-module,#ccc-overlay"],detectCmp:[{exists:"#ccc-module"}],detectPopup:[{visible:"#ccc"},{visible:"#ccc-module"}],optOut:[{click:"#ccc-reject-settings"}],optIn:[{click:"#ccc-recommended-settings"}]},{name:"click.io",prehideSelectors:["#cl-consent"],detectCmp:[{exists:"#cl-consent"}],detectPopup:[{visible:"#cl-consent"}],optIn:[{waitForThenClick:'#cl-consent [data-role="b_agree"]'}],optOut:[{waitFor:'#cl-consent [data-role="b_options"]'},{wait:500},{click:'#cl-consent [data-role="b_options"]'},{waitFor:'.cl-consent-popup.cl-consent-visible [data-role="alloff"]'},{click:'.cl-consent-popup.cl-consent-visible [data-role="alloff"]',all:!0},{click:'[data-role="b_save"]'}],test:[{eval:"EVAL_CLICKIO_0",comment:"TODO: this only checks if we interacted at all"}]},{name:"clinch",intermediate:!1,runContext:{frame:!1,main:!0},prehideSelectors:[".consent-modal[role=dialog]"],detectCmp:[{exists:".consent-modal[role=dialog]"}],detectPopup:[{visible:".consent-modal[role=dialog]"}],optIn:[{click:"#consent_agree"}],optOut:[{if:{exists:"#consent_reject"},then:[{click:"#consent_reject"}],else:[{click:"#manage_cookie_preferences"},{click:"#cookie_consent_preferences input:checked",all:!0,optional:!0},{click:"#consent_save"}]}],test:[{eval:"EVAL_CLINCH_0"}]},{name:"clustrmaps.com",runContext:{urlPattern:"^https://(www\\.)?clustrmaps\\.com/"},cosmetic:!0,prehideSelectors:["#gdpr-cookie-message"],detectCmp:[{exists:"#gdpr-cookie-message"}],detectPopup:[{visible:"#gdpr-cookie-message"}],optIn:[{click:"button#gdpr-cookie-accept"}],optOut:[{hide:"#gdpr-cookie-message"}]},{name:"coinbase",intermediate:!1,runContext:{frame:!0,main:!0,urlPattern:"^https://(www|help)\\.coinbase\\.com"},prehideSelectors:[],detectCmp:[{exists:"div[class^=CookieBannerContent__Container]"}],detectPopup:[{visible:"div[class^=CookieBannerContent__Container]"}],optIn:[{click:"div[class^=CookieBannerContent__CTA] :nth-last-child(1)"}],optOut:[{click:"button[class^=CookieBannerContent__Settings]"},{click:"div[class^=CookiePreferencesModal__CategoryContainer] input:checked",all:!0,optional:!0},{click:"div[class^=CookiePreferencesModal__ButtonContainer] > button"}],test:[{eval:"EVAL_COINBASE_0"}]},{name:"Complianz banner",prehideSelectors:["#cmplz-cookiebanner-container"],detectCmp:[{exists:"#cmplz-cookiebanner-container .cmplz-cookiebanner"}],detectPopup:[{visible:"#cmplz-cookiebanner-container .cmplz-cookiebanner",check:"any"}],optIn:[{waitForThenClick:".cmplz-cookiebanner .cmplz-accept"}],optOut:[{waitForThenClick:".cmplz-cookiebanner .cmplz-deny"}],test:[{eval:"EVAL_COMPLIANZ_BANNER_0"}]},{name:"Complianz categories",prehideSelectors:['.cc-type-categories[aria-describedby="cookieconsent:desc"]'],detectCmp:[{exists:'.cc-type-categories[aria-describedby="cookieconsent:desc"]'}],detectPopup:[{visible:'.cc-type-categories[aria-describedby="cookieconsent:desc"]'}],optIn:[{any:[{click:".cc-accept-all"},{click:".cc-allow-all"},{click:".cc-allow"},{click:".cc-dismiss"}]}],optOut:[{if:{exists:'.cc-type-categories[aria-describedby="cookieconsent:desc"] .cc-dismiss'},then:[{click:".cc-dismiss"}],else:[{click:".cc-type-categories input[type=checkbox]:not([disabled]):checked",all:!0,optional:!0},{click:".cc-save"}]}]},{name:"Complianz notice",prehideSelectors:['.cc-type-info[aria-describedby="cookieconsent:desc"]'],cosmetic:!0,detectCmp:[{exists:'.cc-type-info[aria-describedby="cookieconsent:desc"] .cc-compliance .cc-btn'}],detectPopup:[{visible:'.cc-type-info[aria-describedby="cookieconsent:desc"] .cc-compliance .cc-btn'}],optIn:[{click:".cc-accept-all",optional:!0},{click:".cc-allow",optional:!0},{click:".cc-dismiss",optional:!0}],optOut:[{if:{exists:".cc-deny"},then:[{click:".cc-deny"}],else:[{hide:'[aria-describedby="cookieconsent:desc"]'}]}]},{name:"Complianz opt-both",prehideSelectors:['[aria-describedby="cookieconsent:desc"] .cc-type-opt-both'],detectCmp:[{exists:'[aria-describedby="cookieconsent:desc"] .cc-type-opt-both'}],detectPopup:[{visible:'[aria-describedby="cookieconsent:desc"] .cc-type-opt-both'}],optIn:[{click:".cc-accept-all",optional:!0},{click:".cc-allow",optional:!0},{click:".cc-dismiss",optional:!0}],optOut:[{waitForThenClick:".cc-deny"}]},{name:"Complianz opt-out",prehideSelectors:['[aria-describedby="cookieconsent:desc"].cc-type-opt-out'],detectCmp:[{exists:'[aria-describedby="cookieconsent:desc"].cc-type-opt-out'}],detectPopup:[{visible:'[aria-describedby="cookieconsent:desc"].cc-type-opt-out'}],optIn:[{click:".cc-accept-all",optional:!0},{click:".cc-allow",optional:!0},{click:".cc-dismiss",optional:!0}],optOut:[{if:{exists:".cc-deny"},then:[{click:".cc-deny"}],else:[{if:{exists:".cmp-pref-link"},then:[{click:".cmp-pref-link"},{waitForThenClick:".cmp-body [id*=rejectAll]"},{waitForThenClick:".cmp-body .cmp-save-btn"}]}]}]},{name:"Complianz optin",prehideSelectors:['.cc-type-opt-in[aria-describedby="cookieconsent:desc"]'],detectCmp:[{exists:'.cc-type-opt-in[aria-describedby="cookieconsent:desc"]'}],detectPopup:[{visible:'.cc-type-opt-in[aria-describedby="cookieconsent:desc"]'}],optIn:[{any:[{click:".cc-accept-all"},{click:".cc-allow"},{click:".cc-dismiss"}]}],optOut:[{if:{visible:".cc-deny"},then:[{click:".cc-deny"}],else:[{if:{visible:".cc-settings"},then:[{waitForThenClick:".cc-settings"},{waitForVisible:".cc-settings-view"},{click:".cc-settings-view input[type=checkbox]:not([disabled]):checked",all:!0,optional:!0},{click:".cc-settings-view .cc-btn-accept-selected"}],else:[{click:".cc-dismiss"}]}]}]},{name:"cookie-law-info",prehideSelectors:["#cookie-law-info-bar"],detectCmp:[{exists:"#cookie-law-info-bar"},{eval:"EVAL_COOKIE_LAW_INFO_DETECT"}],detectPopup:[{visible:"#cookie-law-info-bar"}],optIn:[{click:'[data-cli_action="accept_all"]'}],optOut:[{hide:"#cookie-law-info-bar"},{eval:"EVAL_COOKIE_LAW_INFO_0"}],test:[{eval:"EVAL_COOKIE_LAW_INFO_1"}]},{name:"cookie-manager-popup",cosmetic:!1,runContext:{main:!0,frame:!1},intermediate:!1,detectCmp:[{exists:"#notice-cookie-block #allow-functional-cookies, #notice-cookie-block #btn-cookie-settings"}],detectPopup:[{visible:"#notice-cookie-block"}],optIn:[{click:"#btn-cookie-allow"}],optOut:[{if:{exists:"#allow-functional-cookies"},then:[{click:"#allow-functional-cookies"}],else:[{waitForThenClick:"#btn-cookie-settings"},{waitForVisible:".modal-body"},{click:'.modal-body input:checked, .switch[data-switch="on"]',all:!0,optional:!0},{click:'[role="dialog"] .modal-footer button'}]}],prehideSelectors:["#btn-cookie-settings"],test:[{eval:"EVAL_COOKIE_MANAGER_POPUP_0"}]},{name:"cookie-notice",prehideSelectors:["#cookie-notice"],cosmetic:!0,detectCmp:[{visible:"#cookie-notice .cookie-notice-container"}],detectPopup:[{visible:"#cookie-notice"}],optIn:[{click:"#cn-accept-cookie"}],optOut:[{hide:"#cookie-notice"}]},{name:"cookie-script",vendorUrl:"https://cookie-script.com/",prehideSelectors:["#cookiescript_injected"],detectCmp:[{exists:"#cookiescript_injected"}],detectPopup:[{visible:"#cookiescript_injected"}],optOut:[{if:{exists:"#cookiescript_reject"},then:[{wait:100},{click:"#cookiescript_reject"}],else:[{click:"#cookiescript_manage"},{waitForVisible:".cookiescript_fsd_main"},{waitForThenClick:"#cookiescript_reject"}]}],optIn:[{click:"#cookiescript_accept"}]},{name:"cookieacceptbar",vendorUrl:"https://unknown",cosmetic:!0,prehideSelectors:["#cookieAcceptBar.cookieAcceptBar"],detectCmp:[{exists:"#cookieAcceptBar.cookieAcceptBar"}],detectPopup:[{visible:"#cookieAcceptBar.cookieAcceptBar"}],optIn:[{waitForThenClick:"#cookieAcceptBarConfirm"}],optOut:[{hide:"#cookieAcceptBar.cookieAcceptBar"}]},{name:"cookiealert",intermediate:!1,prehideSelectors:[],runContext:{frame:!0,main:!0},detectCmp:[{exists:".cookie-alert-extended"}],detectPopup:[{visible:".cookie-alert-extended-modal"}],optIn:[{click:"button[data-controller='cookie-alert/extended/button/accept']"},{eval:"EVAL_COOKIEALERT_0"}],optOut:[{click:"a[data-controller='cookie-alert/extended/detail-link']"},{click:".cookie-alert-configuration-input:checked",all:!0,optional:!0},{click:"button[data-controller='cookie-alert/extended/button/configuration']"},{eval:"EVAL_COOKIEALERT_0"}],test:[{eval:"EVAL_COOKIEALERT_2"}]},{name:"cookieconsent2",vendorUrl:"https://www.github.com/orestbida/cookieconsent",comment:"supports v2.x.x of the library",prehideSelectors:["#cc--main"],detectCmp:[{exists:"#cc--main"}],detectPopup:[{visible:"#cm"},{exists:"#s-all-bn"}],optIn:[{waitForThenClick:"#s-all-bn"}],optOut:[{waitForThenClick:"#s-rall-bn"}],test:[{eval:"EVAL_COOKIECONSENT2_TEST"}]},{name:"cookieconsent3",vendorUrl:"https://www.github.com/orestbida/cookieconsent",comment:"supports v3.x.x of the library",prehideSelectors:["#cc-main"],detectCmp:[{exists:"#cc-main"}],detectPopup:[{visible:"#cc-main .cm-wrapper"}],optIn:[{waitForThenClick:".cm__btn[data-role=all]"}],optOut:[{waitForThenClick:".cm__btn[data-role=necessary]"}],test:[{eval:"EVAL_COOKIECONSENT3_TEST"}]},{name:"cookiecuttr",vendorUrl:"https://github.com/cdwharton/cookieCuttr",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:""},prehideSelectors:[".cc-cookies"],detectCmp:[{exists:".cc-cookies .cc-cookie-accept"}],detectPopup:[{visible:".cc-cookies .cc-cookie-accept"}],optIn:[{waitForThenClick:".cc-cookies .cc-cookie-accept"}],optOut:[{if:{exists:".cc-cookies .cc-cookie-decline"},then:[{click:".cc-cookies .cc-cookie-decline"}],else:[{hide:".cc-cookies"}]}]},{name:"cookiefirst.com",prehideSelectors:["#cookiefirst-root,.cookiefirst-root,[aria-labelledby=cookie-preference-panel-title]"],detectCmp:[{exists:"#cookiefirst-root,.cookiefirst-root"}],detectPopup:[{visible:"#cookiefirst-root,.cookiefirst-root"}],optIn:[{click:"button[data-cookiefirst-action=accept]"}],optOut:[{if:{exists:"button[data-cookiefirst-action=adjust]"},then:[{click:"button[data-cookiefirst-action=adjust]"},{waitForVisible:"[data-cookiefirst-widget=modal]",timeout:1e3},{eval:"EVAL_COOKIEFIRST_1"},{wait:1e3},{click:"button[data-cookiefirst-action=save]"}],else:[{click:"button[data-cookiefirst-action=reject]"}]}],test:[{eval:"EVAL_COOKIEFIRST_0"}]},{name:"Cookie Information Banner",prehideSelectors:["#cookie-information-template-wrapper"],detectCmp:[{exists:"#cookie-information-template-wrapper"}],detectPopup:[{visible:"#cookie-information-template-wrapper"}],optIn:[{eval:"EVAL_COOKIEINFORMATION_1"}],optOut:[{hide:"#cookie-information-template-wrapper",comment:"some templates don't hide the banner automatically"},{eval:"EVAL_COOKIEINFORMATION_0"}],test:[{eval:"EVAL_COOKIEINFORMATION_2"}]},{name:"cookieyes",prehideSelectors:[".cky-overlay,.cky-consent-container"],detectCmp:[{exists:".cky-consent-container"}],detectPopup:[{visible:".cky-consent-container"}],optIn:[{waitForThenClick:".cky-consent-container [data-cky-tag=accept-button]"}],optOut:[{if:{exists:".cky-consent-container [data-cky-tag=reject-button]"},then:[{waitForThenClick:".cky-consent-container [data-cky-tag=reject-button]"}],else:[{if:{exists:".cky-consent-container [data-cky-tag=settings-button]"},then:[{click:".cky-consent-container [data-cky-tag=settings-button]"},{waitFor:".cky-modal-open input[type=checkbox]"},{click:".cky-modal-open input[type=checkbox]:checked",all:!0,optional:!0},{waitForThenClick:".cky-modal [data-cky-tag=detail-save-button]"}],else:[{hide:".cky-consent-container,.cky-overlay"}]}]}],test:[{eval:"EVAL_COOKIEYES_0"}]},{name:"corona-in-zahlen.de",prehideSelectors:[".cookiealert"],detectCmp:[{exists:".cookiealert"}],detectPopup:[{visible:".cookiealert"}],optOut:[{click:".configurecookies"},{click:".confirmcookies"}],optIn:[{click:".acceptcookies"}]},{name:"crossfit-com",cosmetic:!0,prehideSelectors:['body #modal > div > div[class^="_wrapper_"]'],detectCmp:[{exists:'body #modal > div > div[class^="_wrapper_"]'}],detectPopup:[{visible:'body #modal > div > div[class^="_wrapper_"]'}],optIn:[{click:'button[aria-label="accept cookie policy"]'}],optOut:[{hide:'body #modal > div > div[class^="_wrapper_"]'}]},{name:"csu-landtag-de",runContext:{urlPattern:"^https://(www\\.|)?csu-landtag\\.de"},prehideSelectors:["#cookie-disclaimer"],detectCmp:[{exists:"#cookie-disclaimer"}],detectPopup:[{visible:"#cookie-disclaimer"}],optIn:[{click:"#cookieall"}],optOut:[{click:"#cookiesel"}]},{name:"dailymotion-us",cosmetic:!0,prehideSelectors:['div[class*="CookiePopup__desktopContainer"]:has(div[class*="CookiePopup"])'],detectCmp:[{exists:'div[class*="CookiePopup__desktopContainer"]'}],detectPopup:[{visible:'div[class*="CookiePopup__desktopContainer"]'}],optIn:[{click:'div[class*="CookiePopup__desktopContainer"] > button > span'}],optOut:[{hide:'div[class*="CookiePopup__desktopContainer"]'}]},{name:"dailymotion.com",runContext:{urlPattern:"^https://(www\\.)?dailymotion\\.com/"},prehideSelectors:['div[class*="Overlay__container"]:has(div[class*="TCF2Popup"])'],detectCmp:[{exists:'div[class*="TCF2Popup"]'}],detectPopup:[{visible:'[class*="TCF2Popup"] a[href^="https://www.dailymotion.com/legal/cookiemanagement"]'}],optIn:[{waitForThenClick:'button[class*="TCF2Popup__button"]:not([class*="TCF2Popup__personalize"])'}],optOut:[{waitForThenClick:'button[class*="TCF2ContinueWithoutAcceptingButton"]'}],test:[{eval:"EVAL_DAILYMOTION_0"}]},{name:"dan-com",vendorUrl:"https://unknown",runContext:{main:!0,frame:!1},prehideSelectors:[],detectCmp:[{exists:".cookie-banner.show .cookie-banner__content-all-btn"}],detectPopup:[{visible:".cookie-banner.show .cookie-banner__content-all-btn"}],optIn:[{waitForThenClick:".cookie-banner__content-all-btn"}],optOut:[{waitForThenClick:".cookie-banner__content-essential-btn"}]},{name:"deepl.com",prehideSelectors:[".dl_cookieBanner_container"],detectCmp:[{exists:".dl_cookieBanner_container"}],detectPopup:[{visible:".dl_cookieBanner_container"}],optOut:[{click:".dl_cookieBanner--buttonSelected"}],optIn:[{click:".dl_cookieBanner--buttonAll"}]},{name:"delta.com",runContext:{urlPattern:"^https://www\\.delta\\.com/"},cosmetic:!0,prehideSelectors:["ngc-cookie-banner"],detectCmp:[{exists:"div.cookie-footer-container"}],detectPopup:[{visible:"div.cookie-footer-container"}],optIn:[{click:" button.cookie-close-icon"}],optOut:[{hide:"div.cookie-footer-container"}]},{name:"dmgmedia-us",prehideSelectors:["#mol-ads-cmp-iframe, div.mol-ads-cmp > form > div"],detectCmp:[{exists:"div.mol-ads-cmp > form > div"}],detectPopup:[{waitForVisible:"div.mol-ads-cmp > form > div"}],optIn:[{waitForThenClick:"button.mol-ads-cmp--btn-primary"}],optOut:[{waitForThenClick:"div.mol-ads-ccpa--message > u > a"},{waitForVisible:".mol-ads-cmp--modal-dialog"},{waitForThenClick:"a.mol-ads-cmp-footer-privacy"},{waitForThenClick:"button.mol-ads-cmp--btn-secondary"}]},{name:"dmgmedia",prehideSelectors:['[data-project="mol-fe-cmp"]'],detectCmp:[{exists:'[data-project="mol-fe-cmp"] [class*=footer]'}],detectPopup:[{visible:'[data-project="mol-fe-cmp"] [class*=footer]'}],optIn:[{waitForThenClick:'[data-project="mol-fe-cmp"] button[class*=primary]'}],optOut:[{waitForThenClick:'[data-project="mol-fe-cmp"] button[class*=basic]'},{waitForVisible:'[data-project="mol-fe-cmp"] div[class*="tabContent"]'},{waitForThenClick:'[data-project="mol-fe-cmp"] div[class*="toggle"][class*="enabled"]',all:!0},{waitForThenClick:['[data-project="mol-fe-cmp"] [class*=footer]',"xpath///button[contains(., 'Save & Exit')]"]}]},{name:"dndbeyond",vendorUrl:"https://www.dndbeyond.com/",runContext:{urlPattern:"^https://(www\\.)?dndbeyond\\.com/"},prehideSelectors:["[id^=cookie-consent-banner]"],detectCmp:[{exists:"[id^=cookie-consent-banner]"}],detectPopup:[{visible:"[id^=cookie-consent-banner]"}],optIn:[{waitForThenClick:"#cookie-consent-granted"}],optOut:[{waitForThenClick:"#cookie-consent-denied"}],test:[{eval:"EVAL_DNDBEYOND_TEST"}]},{name:"dpgmedia-nl",prehideSelectors:["#pg-shadow-host"],detectCmp:[{exists:"#pg-shadow-host"}],detectPopup:[{visible:["#pg-shadow-host","#pg-modal"]}],optIn:[{waitForThenClick:["#pg-shadow-host","#pg-accept-btn"]}],optOut:[{waitForThenClick:["#pg-shadow-host","#pg-configure-btn"]},{waitForThenClick:["#pg-shadow-host","#pg-reject-btn"]}]},{name:"Drupal",detectCmp:[{exists:"#drupalorg-crosssite-gdpr"}],detectPopup:[{visible:"#drupalorg-crosssite-gdpr"}],optOut:[{click:".no"}],optIn:[{click:".yes"}]},{name:"WP DSGVO Tools",link:"https://wordpress.org/plugins/shapepress-dsgvo/",prehideSelectors:[".sp-dsgvo"],cosmetic:!0,detectCmp:[{exists:".sp-dsgvo.sp-dsgvo-popup-overlay"}],detectPopup:[{visible:".sp-dsgvo.sp-dsgvo-popup-overlay",check:"any"}],optIn:[{click:".sp-dsgvo-privacy-btn-accept-all",all:!0}],optOut:[{hide:".sp-dsgvo.sp-dsgvo-popup-overlay"}],test:[{eval:"EVAL_DSGVO_0"}]},{name:"dunelm.com",prehideSelectors:["div[data-testid=cookie-consent-modal-backdrop]"],detectCmp:[{exists:"div[data-testid=cookie-consent-message-contents]"}],detectPopup:[{visible:"div[data-testid=cookie-consent-message-contents]"}],optIn:[{click:'[data-testid="cookie-consent-allow-all"]'}],optOut:[{click:"button[data-testid=cookie-consent-adjust-settings]"},{click:"button[data-testid=cookie-consent-preferences-save]"}],test:[{eval:"EVAL_DUNELM_0"}]},{name:"ebay",vendorUrl:"https://ebay.com",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?ebay\\.([.a-z]+)/"},prehideSelectors:["#gdpr-banner"],detectCmp:[{exists:"#gdpr-banner"}],detectPopup:[{visible:"#gdpr-banner"}],optIn:[{waitForThenClick:"#gdpr-banner-accept"}],optOut:[{waitForThenClick:"#gdpr-banner-decline"}]},{name:"ecosia",vendorUrl:"https://www.ecosia.org/",runContext:{urlPattern:"^https://www\\.ecosia\\.org/"},prehideSelectors:[".cookie-wrapper"],detectCmp:[{exists:".cookie-wrapper > .cookie-notice"}],detectPopup:[{visible:".cookie-wrapper > .cookie-notice"}],optIn:[{waitForThenClick:"[data-test-id=cookie-notice-accept]"}],optOut:[{waitForThenClick:"[data-test-id=cookie-notice-reject]"}]},{name:"Ensighten ensModal",prehideSelectors:[".ensModal"],detectCmp:[{exists:".ensModal"}],detectPopup:[{visible:"#ensModalWrapper[style*=block]"}],optIn:[{waitForThenClick:"#modalAcceptButton"}],optOut:[{wait:500},{visible:"#ensModalWrapper[style*=block]"},{waitForThenClick:".ensCheckbox:checked",all:!0},{waitForThenClick:"#ensSave"}]},{name:"Ensighten ensNotifyBanner",prehideSelectors:["#ensNotifyBanner"],detectCmp:[{exists:"#ensNotifyBanner"}],detectPopup:[{visible:"#ensNotifyBanner[style*=block]"}],optIn:[{waitForThenClick:"#ensCloseBanner"}],optOut:[{wait:500},{visible:"#ensNotifyBanner[style*=block]"},{waitForThenClick:"#ensRejectAll,#rejectAll,#ensRejectBanner,.rejectAll,#ensCloseBanner",timeout:2e3}]},{name:"espace-personnel.agirc-arrco.fr",runContext:{urlPattern:"^https://espace-personnel\\.agirc-arrco\\.fr/"},prehideSelectors:[".cdk-overlay-container"],detectCmp:[{exists:".cdk-overlay-container app-esaa-cookie-component"}],detectPopup:[{visible:".cdk-overlay-container app-esaa-cookie-component"}],optIn:[{waitForThenClick:".btn-cookie-accepter"}],optOut:[{waitForThenClick:".btn-cookie-refuser"}]},{name:"etsy",prehideSelectors:["#gdpr-single-choice-overlay","#gdpr-privacy-settings"],detectCmp:[{exists:"#gdpr-single-choice-overlay"}],detectPopup:[{visible:"#gdpr-single-choice-overlay"}],optOut:[{click:"button[data-gdpr-open-full-settings]"},{waitForVisible:".gdpr-overlay-body input",timeout:3e3},{wait:1e3},{eval:"EVAL_ETSY_0"},{eval:"EVAL_ETSY_1"}],optIn:[{click:"button[data-gdpr-single-choice-accept]"}]},{name:"eu-cookie-compliance-banner",detectCmp:[{exists:"body.eu-cookie-compliance-popup-open"}],detectPopup:[{exists:"body.eu-cookie-compliance-popup-open"}],optIn:[{click:".agree-button"}],optOut:[{if:{visible:".decline-button,.eu-cookie-compliance-save-preferences-button"},then:[{click:".decline-button,.eu-cookie-compliance-save-preferences-button"}]},{hide:".eu-cookie-compliance-banner-info, #sliding-popup"}],test:[{eval:"EVAL_EU_COOKIE_COMPLIANCE_0"}]},{name:"EU Cookie Law",prehideSelectors:[".pea_cook_wrapper,.pea_cook_more_info_popover"],cosmetic:!0,detectCmp:[{exists:".pea_cook_wrapper"}],detectPopup:[{wait:500},{visible:".pea_cook_wrapper"}],optIn:[{click:"#pea_cook_btn"}],optOut:[{hide:".pea_cook_wrapper"}],test:[{eval:"EVAL_EU_COOKIE_LAW_0"}]},{name:"europa-eu",vendorUrl:"https://ec.europa.eu/",runContext:{urlPattern:"^https://[^/]*europa\\.eu/"},prehideSelectors:["#cookie-consent-banner"],detectCmp:[{exists:".cck-container"}],detectPopup:[{visible:".cck-container"}],optIn:[{waitForThenClick:'.cck-actions-button[href="#accept"]'}],optOut:[{waitForThenClick:'.cck-actions-button[href="#refuse"]',hide:".cck-container"}]},{name:"EZoic",prehideSelectors:["#ez-cookie-dialog-wrapper"],detectCmp:[{exists:"#ez-cookie-dialog-wrapper"}],detectPopup:[{visible:"#ez-cookie-dialog-wrapper"}],optIn:[{click:"#ez-accept-all",optional:!0},{eval:"EVAL_EZOIC_0",optional:!0}],optOut:[{wait:500},{click:"#ez-manage-settings"},{waitFor:"#ez-cookie-dialog input[type=checkbox]"},{click:"#ez-cookie-dialog input[type=checkbox]:checked",all:!0},{click:"#ez-save-settings"}],test:[{eval:"EVAL_EZOIC_1"}]},{name:"facebook",runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?facebook\\.com/"},prehideSelectors:['div[data-testid="cookie-policy-manage-dialog"]'],detectCmp:[{exists:'div[data-testid="cookie-policy-manage-dialog"]'}],detectPopup:[{visible:'div[data-testid="cookie-policy-manage-dialog"]'}],optIn:[{waitForThenClick:'button[data-cookiebanner="accept_button"]'},{waitForVisible:'div[data-testid="cookie-policy-manage-dialog"]',check:"none"}],optOut:[{waitForThenClick:'button[data-cookiebanner="accept_only_essential_button"]'},{waitForVisible:'div[data-testid="cookie-policy-manage-dialog"]',check:"none"}]},{name:"fides",vendorUrl:"https://github.com/ethyca/fides",prehideSelectors:["#fides-overlay"],detectCmp:[{exists:"#fides-overlay #fides-banner"}],detectPopup:[{visible:"#fides-overlay #fides-banner"},{eval:"EVAL_FIDES_DETECT_POPUP"}],optIn:[{waitForThenClick:"#fides-banner .fides-accept-all-button"}],optOut:[{waitForThenClick:"#fides-banner .fides-reject-all-button"}]},{name:"funding-choices",prehideSelectors:[".fc-consent-root,.fc-dialog-container,.fc-dialog-overlay,.fc-dialog-content"],detectCmp:[{exists:".fc-consent-root"}],detectPopup:[{exists:".fc-dialog-container"}],optOut:[{click:".fc-cta-do-not-consent,.fc-cta-manage-options"},{click:".fc-preference-consent:checked,.fc-preference-legitimate-interest:checked",all:!0,optional:!0},{click:".fc-confirm-choices",optional:!0}],optIn:[{click:".fc-cta-consent"}]},{name:"geeks-for-geeks",runContext:{urlPattern:"^https://www\\.geeksforgeeks\\.org/"},cosmetic:!0,prehideSelectors:[".cookie-consent"],detectCmp:[{exists:".cookie-consent"}],detectPopup:[{visible:".cookie-consent"}],optIn:[{click:".cookie-consent button.consent-btn"}],optOut:[{hide:".cookie-consent"}]},{name:"google-consent-standalone",prehideSelectors:[],detectCmp:[{exists:'a[href^="https://policies.google.com/technologies/cookies"'},{exists:'form[action^="https://consent.google."][action$="/save"]'}],detectPopup:[{visible:'a[href^="https://policies.google.com/technologies/cookies"'}],optIn:[{waitForThenClick:'form[action^="https://consent.google."][action$="/save"]:has(input[name=set_eom][value=false]) button'}],optOut:[{waitForThenClick:'form[action^="https://consent.google."][action$="/save"]:has(input[name=set_eom][value=true]) button'}]},{name:"google-cookiebar",vendorUrl:"https://www.android.com/better-together/quick-share-app/",cosmetic:!1,prehideSelectors:[".glue-cookie-notification-bar"],detectCmp:[{exists:".glue-cookie-notification-bar"}],detectPopup:[{visible:".glue-cookie-notification-bar"}],optIn:[{waitForThenClick:".glue-cookie-notification-bar__accept"}],optOut:[{if:{exists:".glue-cookie-notification-bar__reject"},then:[{click:".glue-cookie-notification-bar__reject"}],else:[{hide:".glue-cookie-notification-bar"}]}],test:[]},{name:"google.com",prehideSelectors:[".HTjtHe#xe7COe"],detectCmp:[{exists:".HTjtHe#xe7COe"},{exists:'.HTjtHe#xe7COe a[href^="https://policies.google.com/technologies/cookies"]'}],detectPopup:[{visible:".HTjtHe#xe7COe button#W0wltc"}],optIn:[{waitForThenClick:".HTjtHe#xe7COe button#L2AGLb"}],optOut:[{waitForThenClick:".HTjtHe#xe7COe button#W0wltc"}],test:[{eval:"EVAL_GOOGLE_0"}]},{name:"gov.uk",detectCmp:[{exists:"#global-cookie-message"}],detectPopup:[{exists:"#global-cookie-message"}],optIn:[{click:"button[data-accept-cookies=true]"}],optOut:[{click:"button[data-reject-cookies=true],#reject-cookies"},{click:"button[data-hide-cookie-banner=true],#hide-cookie-decision"}]},{name:"hashicorp",vendorUrl:"https://hashicorp.com/",runContext:{urlPattern:"^https://[^.]*\\.hashicorp\\.com/"},prehideSelectors:["[data-testid=consent-banner]"],detectCmp:[{exists:"[data-testid=consent-banner]"}],detectPopup:[{visible:"[data-testid=consent-banner]"}],optIn:[{waitForThenClick:"[data-testid=accept]"}],optOut:[{waitForThenClick:"[data-testid=manage-preferences]"},{waitForThenClick:"[data-testid=consent-mgr-dialog] [data-ga-button=save-preferences]"}]},{name:"healthline-media",prehideSelectors:["#modal-host > div.no-hash > div.window-wrapper"],detectCmp:[{exists:"#modal-host > div.no-hash > div.window-wrapper, div[data-testid=qualtrics-container]"}],detectPopup:[{exists:"#modal-host > div.no-hash > div.window-wrapper, div[data-testid=qualtrics-container]"}],optIn:[{click:"#modal-host > div.no-hash > div.window-wrapper > div:last-child button"}],optOut:[{if:{exists:'#modal-host > div.no-hash > div.window-wrapper > div:last-child a[href="/privacy-settings"]'},then:[{click:'#modal-host > div.no-hash > div.window-wrapper > div:last-child a[href="/privacy-settings"]'}],else:[{waitForVisible:"div#__next"},{click:"#__next div:nth-child(1) > button:first-child"}]}]},{name:"hema",prehideSelectors:[".cookie-modal"],detectCmp:[{visible:".cookie-modal .cookie-accept-btn"}],detectPopup:[{visible:".cookie-modal .cookie-accept-btn"}],optIn:[{waitForThenClick:".cookie-modal .cookie-accept-btn"}],optOut:[{waitForThenClick:".cookie-modal .js-cookie-reject-btn"}],test:[{eval:"EVAL_HEMA_TEST_0"}]},{name:"hetzner.com",runContext:{urlPattern:"^https://www\\.hetzner\\.com/"},prehideSelectors:["#CookieConsent"],detectCmp:[{exists:"#CookieConsent"}],detectPopup:[{visible:"#CookieConsent"}],optIn:[{click:"#CookieConsentGiven"}],optOut:[{click:"#CookieConsentDeclined"}]},{name:"hl.co.uk",prehideSelectors:[".cookieModalContent","#cookie-banner-overlay"],detectCmp:[{exists:"#cookie-banner-overlay"}],detectPopup:[{exists:"#cookie-banner-overlay"}],optIn:[{click:"#acceptCookieButton"}],optOut:[{click:"#manageCookie"},{hide:".cookieSettingsModal"},{waitFor:"#AOCookieToggle"},{click:"#AOCookieToggle[aria-pressed=true]",optional:!0},{waitFor:"#TPCookieToggle"},{click:"#TPCookieToggle[aria-pressed=true]",optional:!0},{click:"#updateCookieButton"}]},{name:"holidaymedia",vendorUrl:"https://holidaymedia.nl/",prehideSelectors:["dialog[data-cookie-consent]"],detectCmp:[{exists:"dialog[data-cookie-consent]"}],detectPopup:[{visible:"dialog[data-cookie-consent]"}],optIn:[{waitForThenClick:"button.cookie-consent__button--accept-all"}],optOut:[{waitForThenClick:'a[data-cookie-accept="functional"]',timeout:2e3}]},{name:"hu-manity",vendorUrl:"https://hu-manity.co/",prehideSelectors:["#hu.hu-wrapper"],detectCmp:[{exists:"#hu.hu-visible"}],detectPopup:[{visible:"#hu.hu-visible"}],optIn:[{waitForThenClick:"[data-hu-action=cookies-notice-consent-choices-3]"},{waitForThenClick:"#hu-cookies-save"}],optOut:[{waitForThenClick:"#hu-cookies-save"}]},{name:"hubspot",detectCmp:[{exists:"#hs-eu-cookie-confirmation"}],detectPopup:[{visible:"#hs-eu-cookie-confirmation"}],optIn:[{click:"#hs-eu-confirmation-button"}],optOut:[{click:"#hs-eu-decline-button"}]},{name:"indeed.com",cosmetic:!0,prehideSelectors:["#CookiePrivacyNotice"],detectCmp:[{exists:"#CookiePrivacyNotice"}],detectPopup:[{visible:"#CookiePrivacyNotice"}],optIn:[{click:"#CookiePrivacyNotice button[data-gnav-element-name=CookiePrivacyNoticeOk]"}],optOut:[{hide:"#CookiePrivacyNotice"}]},{name:"ing.de",runContext:{urlPattern:"^https://www\\.ing\\.de/"},cosmetic:!0,prehideSelectors:['div[slot="backdrop"]'],detectCmp:[{exists:'[data-tag-name="ing-cc-dialog-frame"]'}],detectPopup:[{visible:'[data-tag-name="ing-cc-dialog-frame"]'}],optIn:[{click:['[data-tag-name="ing-cc-dialog-level0"]','[data-tag-name="ing-cc-button"][class*="accept"]']}],optOut:[{click:['[data-tag-name="ing-cc-dialog-level0"]','[data-tag-name="ing-cc-button"][class*="more"]']}]},{name:"instagram",vendorUrl:"https://instagram.com",runContext:{urlPattern:"^https://www\\.instagram\\.com/"},prehideSelectors:[],detectCmp:[{exists:'xpath///span[contains(., "Vill du tillåta användningen av cookies från Instagram i den här webbläsaren?") or contains(., "Allow the use of cookies from Instagram on this browser?") or contains(., "Povolit v prohlížeči použití souborů cookie z Instagramu?") or contains(., "Dopustiti upotrebu kolačića s Instagrama na ovom pregledniku?") or contains(., "Разрешить использование файлов cookie от Instagram в этом браузере?") or contains(., "Vuoi consentire l\'uso dei cookie di Instagram su questo browser?") or contains(., "Povoliť používanie cookies zo služby Instagram v tomto prehliadači?") or contains(., "Die Verwendung von Cookies durch Instagram in diesem Browser erlauben?") or contains(., "Sallitaanko Instagramin evästeiden käyttö tällä selaimella?") or contains(., "Engedélyezed az Instagram cookie-jainak használatát ebben a böngészőben?") or contains(., "Het gebruik van cookies van Instagram toestaan in deze browser?") or contains(., "Bu tarayıcıda Instagram\'dan çerez kullanımına izin verilsin mi?") or contains(., "Permitir o uso de cookies do Instagram neste navegador?") or contains(., "Permiţi folosirea modulelor cookie de la Instagram în acest browser?") or contains(., "Autoriser l’utilisation des cookies d’Instagram sur ce navigateur ?") or contains(., "¿Permitir el uso de cookies de Instagram en este navegador?") or contains(., "Zezwolić na użycie plików cookie z Instagramu w tej przeglądarce?") or contains(., "Να επιτρέπεται η χρήση cookies από τo Instagram σε αυτό το πρόγραμμα περιήγησης;") or contains(., "Разрешавате ли използването на бисквитки от Instagram на този браузър?") or contains(., "Vil du tillade brugen af cookies fra Instagram i denne browser?") or contains(., "Vil du tillate bruk av informasjonskapsler fra Instagram i denne nettleseren?")]'}],detectPopup:[{visible:'xpath///span[contains(., "Vill du tillåta användningen av cookies från Instagram i den här webbläsaren?") or contains(., "Allow the use of cookies from Instagram on this browser?") or contains(., "Povolit v prohlížeči použití souborů cookie z Instagramu?") or contains(., "Dopustiti upotrebu kolačića s Instagrama na ovom pregledniku?") or contains(., "Разрешить использование файлов cookie от Instagram в этом браузере?") or contains(., "Vuoi consentire l\'uso dei cookie di Instagram su questo browser?") or contains(., "Povoliť používanie cookies zo služby Instagram v tomto prehliadači?") or contains(., "Die Verwendung von Cookies durch Instagram in diesem Browser erlauben?") or contains(., "Sallitaanko Instagramin evästeiden käyttö tällä selaimella?") or contains(., "Engedélyezed az Instagram cookie-jainak használatát ebben a böngészőben?") or contains(., "Het gebruik van cookies van Instagram toestaan in deze browser?") or contains(., "Bu tarayıcıda Instagram\'dan çerez kullanımına izin verilsin mi?") or contains(., "Permitir o uso de cookies do Instagram neste navegador?") or contains(., "Permiţi folosirea modulelor cookie de la Instagram în acest browser?") or contains(., "Autoriser l’utilisation des cookies d’Instagram sur ce navigateur ?") or contains(., "¿Permitir el uso de cookies de Instagram en este navegador?") or contains(., "Zezwolić na użycie plików cookie z Instagramu w tej przeglądarce?") or contains(., "Να επιτρέπεται η χρήση cookies από τo Instagram σε αυτό το πρόγραμμα περιήγησης;") or contains(., "Разрешавате ли използването на бисквитки от Instagram на този браузър?") or contains(., "Vil du tillade brugen af cookies fra Instagram i denne browser?") or contains(., "Vil du tillate bruk av informasjonskapsler fra Instagram i denne nettleseren?")]'}],optIn:[{waitForThenClick:"xpath///button[contains(., 'Tillad alle cookies') or contains(., 'Alle Cookies erlauben') or contains(., 'Allow all cookies') or contains(., 'Разрешаване на всички бисквитки') or contains(., 'Tillåt alla cookies') or contains(., 'Povolit všechny soubory cookie') or contains(., 'Tüm çerezlere izin ver') or contains(., 'Permite toate modulele cookie') or contains(., 'Να επιτρέπονται όλα τα cookies') or contains(., 'Tillat alle informasjonskapsler') or contains(., 'Povoliť všetky cookies') or contains(., 'Permitir todas las cookies') or contains(., 'Permitir todos os cookies') or contains(., 'Alle cookies toestaan') or contains(., 'Salli kaikki evästeet') or contains(., 'Consenti tutti i cookie') or contains(., 'Az összes cookie engedélyezése') or contains(., 'Autoriser tous les cookies') or contains(., 'Zezwól na wszystkie pliki cookie') or contains(., 'Разрешить все cookie') or contains(., 'Dopusti sve kolačiće')]"}],optOut:[{waitForThenClick:"xpath///button[contains(., 'Отклонить необязательные файлы cookie') or contains(., 'Decline optional cookies') or contains(., 'Refuser les cookies optionnels') or contains(., 'Hylkää valinnaiset evästeet') or contains(., 'Afvis valgfrie cookies') or contains(., 'Odmietnuť nepovinné cookies') or contains(., 'Απόρριψη προαιρετικών cookies') or contains(., 'Neka valfria cookies') or contains(., 'Optionale Cookies ablehnen') or contains(., 'Rifiuta cookie facoltativi') or contains(., 'Odbij neobavezne kolačiće') or contains(., 'Avvis valgfrie informasjonskapsler') or contains(., 'İsteğe bağlı çerezleri reddet') or contains(., 'Recusar cookies opcionais') or contains(., 'Optionele cookies afwijzen') or contains(., 'Rechazar cookies opcionales') or contains(., 'Odrzuć opcjonalne pliki cookie') or contains(., 'Отхвърляне на бисквитките по избор') or contains(., 'Odmítnout volitelné soubory cookie') or contains(., 'Refuză modulele cookie opţionale') or contains(., 'A nem kötelező cookie-k elutasítása')]"},{wait:2e3}]},{name:"ionos.de",prehideSelectors:[".privacy-consent--backdrop",".privacy-consent--modal"],detectCmp:[{exists:".privacy-consent--modal"}],detectPopup:[{visible:".privacy-consent--modal"}],optIn:[{click:"#selectAll"}],optOut:[{click:".footer-config-link"},{click:"#confirmSelection"}]},{name:"itopvpn.com",cosmetic:!0,prehideSelectors:[".pop-cookie"],detectCmp:[{exists:".pop-cookie"}],detectPopup:[{exists:".pop-cookie"}],optIn:[{click:"#_pcookie"}],optOut:[{hide:".pop-cookie"}]},{name:"iubenda",prehideSelectors:["#iubenda-cs-banner"],detectCmp:[{exists:"#iubenda-cs-banner"}],detectPopup:[{visible:".iubenda-cs-accept-btn"}],optIn:[{waitForThenClick:".iubenda-cs-accept-btn"}],optOut:[{waitForThenClick:".iubenda-cs-customize-btn"},{eval:"EVAL_IUBENDA_0"},{waitForThenClick:"#iubFooterBtn"}],test:[{eval:"EVAL_IUBENDA_1"}]},{name:"iWink",prehideSelectors:["body.cookies-request #cookie-bar"],detectCmp:[{exists:"body.cookies-request #cookie-bar"}],detectPopup:[{visible:"body.cookies-request #cookie-bar"}],optIn:[{waitForThenClick:"body.cookies-request #cookie-bar .allow-cookies"}],optOut:[{waitForThenClick:"body.cookies-request #cookie-bar .disallow-cookies"}],test:[{eval:"EVAL_IWINK_TEST"}]},{name:"jdsports",vendorUrl:"https://www.jdsports.co.uk/",runContext:{urlPattern:"^https://(www|m)\\.jdsports\\."},prehideSelectors:[".miniConsent,#PrivacyPolicyBanner"],detectCmp:[{exists:".miniConsent,#PrivacyPolicyBanner"}],detectPopup:[{visible:".miniConsent,#PrivacyPolicyBanner"}],optIn:[{waitForThenClick:".miniConsent .accept-all-cookies"}],optOut:[{if:{exists:"#PrivacyPolicyBanner"},then:[{hide:"#PrivacyPolicyBanner"}],else:[{waitForThenClick:"#cookie-settings"},{waitForThenClick:"#reject-all-cookies"}]}]},{name:"johnlewis.com",prehideSelectors:["div[class^=pecr-cookie-banner-]"],detectCmp:[{exists:"div[class^=pecr-cookie-banner-]"}],detectPopup:[{exists:"div[class^=pecr-cookie-banner-]"}],optOut:[{click:"button[data-test^=manage-cookies]"},{wait:"500"},{click:"label[data-test^=toggle][class*=checked]:not([class*=disabled])",all:!0,optional:!0},{click:"button[data-test=save-preferences]"}],optIn:[{click:"button[data-test=allow-all]"}]},{name:"jquery.cookieBar",vendorUrl:"https://github.com/kovarp/jquery.cookieBar",prehideSelectors:[".cookie-bar"],cosmetic:!0,detectCmp:[{exists:".cookie-bar .cookie-bar__message,.cookie-bar .cookie-bar__buttons"}],detectPopup:[{visible:".cookie-bar .cookie-bar__message,.cookie-bar .cookie-bar__buttons",check:"any"}],optIn:[{click:".cookie-bar .cookie-bar__btn"}],optOut:[{hide:".cookie-bar"}],test:[{visible:".cookie-bar .cookie-bar__message,.cookie-bar .cookie-bar__buttons",check:"none"},{eval:"EVAL_JQUERY_COOKIEBAR_0"}]},{name:"justwatch.com",prehideSelectors:[".consent-banner"],detectCmp:[{exists:".consent-banner .consent-banner__actions"}],detectPopup:[{visible:".consent-banner .consent-banner__actions"}],optIn:[{click:".consent-banner__actions button.basic-button.primary"}],optOut:[{click:".consent-banner__actions button.basic-button.secondary"},{waitForThenClick:".consent-modal__footer button.basic-button.secondary"},{waitForThenClick:".consent-modal ion-content > div > a:nth-child(9)"},{click:"label.consent-switch input[type=checkbox]:checked",all:!0,optional:!0},{waitForVisible:".consent-modal__footer button.basic-button.primary"},{click:".consent-modal__footer button.basic-button.primary"}]},{name:"ketch",vendorUrl:"https://www.ketch.com",runContext:{frame:!1,main:!0},intermediate:!1,prehideSelectors:["#lanyard_root div[role='dialog']"],detectCmp:[{exists:"#lanyard_root div[role='dialog']"}],detectPopup:[{visible:"#lanyard_root div[role='dialog']"}],optIn:[{if:{exists:"#lanyard_root button[class='confirmButton']"},then:[{waitForThenClick:"#lanyard_root div[class*=buttons] > :nth-child(2)"},{click:"#lanyard_root button[class='confirmButton']"}],else:[{waitForThenClick:"#lanyard_root div[class*=buttons] > :nth-child(2)"}]}],optOut:[{if:{exists:"#lanyard_root [aria-describedby=banner-description]"},then:[{waitForThenClick:"#lanyard_root div[class*=buttons] > button[class*=secondaryButton], #lanyard_root button[class*=buttons-secondary]",comment:"can be either settings or reject button"}]},{waitFor:"#lanyard_root [aria-describedby=preference-description],#lanyard_root [aria-describedby=modal-description], #ketch-preferences",timeout:1e3,optional:!0},{if:{exists:"#lanyard_root [aria-describedby=preference-description],#lanyard_root [aria-describedby=modal-description], #ketch-preferences"},then:[{waitForThenClick:"#lanyard_root button[class*=rejectButton], #lanyard_root button[class*=rejectAllButton]"},{click:"#lanyard_root button[class*=confirmButton],#lanyard_root div[class*=actions_] > button:nth-child(1), #lanyard_root button[class*=actionButton]"}]}],test:[{eval:"EVAL_KETCH_TEST"}]},{name:"kleinanzeigen-de",runContext:{urlPattern:"^https?://(www\\.)?kleinanzeigen\\.de"},prehideSelectors:["#gdpr-banner-container"],detectCmp:[{any:[{exists:"#gdpr-banner-container #gdpr-banner [data-testid=gdpr-banner-cmp-button]"},{exists:"#ConsentManagementPage"}]}],detectPopup:[{any:[{visible:"#gdpr-banner-container #gdpr-banner [data-testid=gdpr-banner-cmp-button]"},{visible:"#ConsentManagementPage"}]}],optIn:[{if:{exists:"#gdpr-banner-container #gdpr-banner"},then:[{click:"#gdpr-banner-container #gdpr-banner [data-testid=gdpr-banner-accept]"}],else:[{click:"#ConsentManagementPage .Button-primary"}]}],optOut:[{if:{exists:"#gdpr-banner-container #gdpr-banner"},then:[{click:"#gdpr-banner-container #gdpr-banner [data-testid=gdpr-banner-cmp-button]"}],else:[{click:"#ConsentManagementPage .Button-secondary"}]}]},{name:"lightbox",prehideSelectors:[".darken-layer.open,.lightbox.lightbox--cookie-consent"],detectCmp:[{exists:"body.cookie-consent-is-active div.lightbox--cookie-consent > div.lightbox__content > div.cookie-consent[data-jsb]"}],detectPopup:[{visible:"body.cookie-consent-is-active div.lightbox--cookie-consent > div.lightbox__content > div.cookie-consent[data-jsb]"}],optOut:[{click:".cookie-consent__footer > button[type='submit']:not([data-button='selectAll'])"}],optIn:[{click:".cookie-consent__footer > button[type='submit'][data-button='selectAll']"}]},{name:"lineagrafica",vendorUrl:"https://addons.prestashop.com/en/legal/8734-eu-cookie-law-gdpr-banner-blocker.html",cosmetic:!0,prehideSelectors:["#lgcookieslaw_banner,#lgcookieslaw_modal,.lgcookieslaw-overlay"],detectCmp:[{exists:"#lgcookieslaw_banner,#lgcookieslaw_modal,.lgcookieslaw-overlay"}],detectPopup:[{exists:"#lgcookieslaw_banner,#lgcookieslaw_modal,.lgcookieslaw-overlay"}],optIn:[{waitForThenClick:"#lgcookieslaw_accept"}],optOut:[{hide:"#lgcookieslaw_banner,#lgcookieslaw_modal,.lgcookieslaw-overlay"}]},{name:"linkedin.com",prehideSelectors:[".artdeco-global-alert[type=COOKIE_CONSENT]"],detectCmp:[{exists:".artdeco-global-alert[type=COOKIE_CONSENT]"}],detectPopup:[{visible:".artdeco-global-alert[type=COOKIE_CONSENT]"}],optIn:[{waitForVisible:".artdeco-global-alert[type=COOKIE_CONSENT] button[action-type=ACCEPT]"},{wait:500},{waitForThenClick:".artdeco-global-alert[type=COOKIE_CONSENT] button[action-type=ACCEPT]"}],optOut:[{waitForVisible:".artdeco-global-alert[type=COOKIE_CONSENT] button[action-type=DENY]"},{wait:500},{waitForThenClick:".artdeco-global-alert[type=COOKIE_CONSENT] button[action-type=DENY]"}],test:[{waitForVisible:".artdeco-global-alert[type=COOKIE_CONSENT]",check:"none"}]},{name:"livejasmin",vendorUrl:"https://www.livejasmin.com/",runContext:{urlPattern:"^https://(m|www)\\.livejasmin\\.com/"},prehideSelectors:["#consent_modal"],detectCmp:[{exists:"#consent_modal"}],detectPopup:[{visible:"#consent_modal"}],optIn:[{waitForThenClick:"#consent_modal button[data-testid=ButtonStyledButton]:first-of-type"}],optOut:[{waitForThenClick:"#consent_modal button[data-testid=ButtonStyledButton]:nth-of-type(2)"},{waitForVisible:"[data-testid=PrivacyPreferenceCenterWithConsentCookieContent]"},{click:"[data-testid=PrivacyPreferenceCenterWithConsentCookieContent] input[data-testid=PrivacyPreferenceCenterWithConsentCookieSwitch]:checked",optional:!0,all:!0},{waitForThenClick:"[data-testid=PrivacyPreferenceCenterWithConsentCookieContent] button[data-testid=ButtonStyledButton]:last-child"}]},{name:"macpaw.com",cosmetic:!0,prehideSelectors:['div[data-banner="cookies"]'],detectCmp:[{exists:'div[data-banner="cookies"]'}],detectPopup:[{exists:'div[data-banner="cookies"]'}],optIn:[{click:'button[data-banner-close="cookies"]'}],optOut:[{hide:'div[data-banner="cookies"]'}]},{name:"marksandspencer.com",cosmetic:!0,detectCmp:[{exists:".navigation-cookiebbanner"}],detectPopup:[{visible:".navigation-cookiebbanner"}],optOut:[{hide:".navigation-cookiebbanner"}],optIn:[{click:".navigation-cookiebbanner__submit"}]},{name:"mediamarkt.de",prehideSelectors:["div[aria-labelledby=pwa-consent-layer-title]","div[class^=StyledConsentLayerWrapper-]"],detectCmp:[{exists:"div[aria-labelledby^=pwa-consent-layer-title]"}],detectPopup:[{exists:"div[aria-labelledby^=pwa-consent-layer-title]"}],optOut:[{click:"button[data-test^=pwa-consent-layer-deny-all]"}],optIn:[{click:"button[data-test^=pwa-consent-layer-accept-all"}]},{name:"Mediavine",prehideSelectors:['[data-name="mediavine-gdpr-cmp"]'],detectCmp:[{exists:'[data-name="mediavine-gdpr-cmp"]'}],detectPopup:[{wait:500},{visible:'[data-name="mediavine-gdpr-cmp"]'}],optIn:[{waitForThenClick:'[data-name="mediavine-gdpr-cmp"] [format="primary"]'}],optOut:[{waitForThenClick:'[data-name="mediavine-gdpr-cmp"] [data-view="manageSettings"]'},{waitFor:'[data-name="mediavine-gdpr-cmp"] input[type=checkbox]'},{eval:"EVAL_MEDIAVINE_0",optional:!0},{click:'[data-name="mediavine-gdpr-cmp"] [format="secondary"]'}]},{name:"medium",vendorUrl:"https://medium.com",cosmetic:!0,runContext:{main:!0,frame:!1,urlPattern:"^https://([a-z0-9-]+\\.)?medium\\.com/"},prehideSelectors:[],detectCmp:[{exists:'div:has(> div > div > div[role=alert] > a[href^="https://policy.medium.com/medium-privacy-policy-"])'}],detectPopup:[{visible:'div:has(> div > div > div[role=alert] > a[href^="https://policy.medium.com/medium-privacy-policy-"])'}],optIn:[{waitForThenClick:"[data-testid=close-button]"}],optOut:[{hide:'div:has(> div > div > div[role=alert] > a[href^="https://policy.medium.com/medium-privacy-policy-"])'}]},{name:"microsoft.com",prehideSelectors:["#wcpConsentBannerCtrl"],detectCmp:[{exists:"#wcpConsentBannerCtrl"}],detectPopup:[{exists:"#wcpConsentBannerCtrl"}],optOut:[{eval:"EVAL_MICROSOFT_0"}],optIn:[{eval:"EVAL_MICROSOFT_1"}],test:[{eval:"EVAL_MICROSOFT_2"}]},{name:"midway-usa",runContext:{urlPattern:"^https://www\\.midwayusa\\.com/"},cosmetic:!0,prehideSelectors:["#cookie-container"],detectCmp:[{exists:['div[aria-label="Cookie Policy Banner"]']}],detectPopup:[{visible:"#cookie-container"}],optIn:[{click:"button#cookie-btn"}],optOut:[{hide:'div[aria-label="Cookie Policy Banner"]'}]},{name:"moneysavingexpert.com",detectCmp:[{exists:"dialog[data-testid=accept-our-cookies-dialog]"}],detectPopup:[{visible:"dialog[data-testid=accept-our-cookies-dialog]"}],optIn:[{click:"#banner-accept"}],optOut:[{click:"#banner-manage"},{click:"#pc-confirm"}]},{name:"monzo.com",prehideSelectors:[".cookie-alert, cookie-alert__content"],detectCmp:[{exists:'div.cookie-alert[role="dialog"]'},{exists:'a[href*="monzo"]'}],detectPopup:[{visible:".cookie-alert__content"}],optIn:[{click:".js-accept-cookie-policy"}],optOut:[{click:".js-decline-cookie-policy"}]},{name:"Moove",prehideSelectors:["#moove_gdpr_cookie_info_bar"],detectCmp:[{exists:"#moove_gdpr_cookie_info_bar"}],detectPopup:[{visible:"#moove_gdpr_cookie_info_bar:not(.moove-gdpr-info-bar-hidden)"}],optIn:[{waitForThenClick:".moove-gdpr-infobar-allow-all"}],optOut:[{if:{exists:"#moove_gdpr_cookie_info_bar .change-settings-button"},then:[{click:"#moove_gdpr_cookie_info_bar .change-settings-button"},{waitForVisible:"#moove_gdpr_cookie_modal"},{eval:"EVAL_MOOVE_0"},{click:".moove-gdpr-modal-save-settings"}],else:[{hide:"#moove_gdpr_cookie_info_bar"}]}],test:[{visible:"#moove_gdpr_cookie_info_bar",check:"none"}]},{name:"national-lottery.co.uk",detectCmp:[{exists:".cuk_cookie_consent"}],detectPopup:[{visible:".cuk_cookie_consent",check:"any"}],optOut:[{click:".cuk_cookie_consent_manage_pref"},{click:".cuk_cookie_consent_save_pref"},{click:".cuk_cookie_consent_close"}],optIn:[{click:".cuk_cookie_consent_accept_all"}]},{name:"nba.com",runContext:{urlPattern:"^https://(www\\.)?nba.com/"},cosmetic:!0,prehideSelectors:["#onetrust-banner-sdk"],detectCmp:[{exists:"#onetrust-banner-sdk"}],detectPopup:[{visible:"#onetrust-banner-sdk"}],optIn:[{click:"#onetrust-accept-btn-handler"}],optOut:[{hide:"#onetrust-banner-sdk"}]},{name:"netbeat.de",runContext:{urlPattern:"^https://(www\\.)?netbeat\\.de/"},prehideSelectors:["div#cookieWarning"],detectCmp:[{exists:"div#cookieWarning"}],detectPopup:[{visible:"div#cookieWarning"}],optIn:[{waitForThenClick:"a#btnCookiesAcceptAll"}],optOut:[{waitForThenClick:"a#btnCookiesDenyAll"}]},{name:"netflix.de",detectCmp:[{exists:"#cookie-disclosure"}],detectPopup:[{visible:".cookie-disclosure-message",check:"any"}],optIn:[{click:".btn-accept"}],optOut:[{hide:"#cookie-disclosure"},{click:".btn-reject"}]},{name:"nhs.uk",prehideSelectors:["#nhsuk-cookie-banner"],detectCmp:[{exists:"#nhsuk-cookie-banner"}],detectPopup:[{exists:"#nhsuk-cookie-banner"}],optOut:[{click:"#nhsuk-cookie-banner__link_accept"}],optIn:[{click:"#nhsuk-cookie-banner__link_accept_analytics"}]},{name:"nike",vendorUrl:"https://nike.com",runContext:{urlPattern:"^https://(www\\.)?nike\\.com/"},prehideSelectors:[],detectCmp:[{exists:"[data-testid=cookie-dialog-root]"}],detectPopup:[{visible:"[data-testid=cookie-dialog-root]"}],optIn:[{waitForThenClick:"[data-testid=dialog-accept-button]"}],optOut:[{waitForThenClick:"input[type=radio][id$=-declineLabel]",all:!0},{waitForThenClick:"[data-testid=confirm-choice-button]"}]},{name:"notice-cookie",prehideSelectors:[".button--notice"],cosmetic:!0,detectCmp:[{exists:".notice--cookie"}],detectPopup:[{visible:".notice--cookie"}],optIn:[{click:".button--notice"}],optOut:[{hide:".notice--cookie"}]},{name:"nrk.no",cosmetic:!0,prehideSelectors:[".nrk-masthead__info-banner--cookie"],detectCmp:[{exists:".nrk-masthead__info-banner--cookie"}],detectPopup:[{exists:".nrk-masthead__info-banner--cookie"}],optIn:[{click:"div.nrk-masthead__info-banner--cookie button > span:has(+ svg.nrk-close)"}],optOut:[{hide:".nrk-masthead__info-banner--cookie"}]},{name:"obi.de",prehideSelectors:[".disc-cp--active"],detectCmp:[{exists:".disc-cp-modal__modal"}],detectPopup:[{visible:".disc-cp-modal__modal"}],optIn:[{click:".js-disc-cp-accept-all"}],optOut:[{click:".js-disc-cp-deny-all"}]},{name:"om",vendorUrl:"https://olli-machts.de/en/extension/cookie-manager",prehideSelectors:[".tx-om-cookie-consent"],detectCmp:[{exists:".tx-om-cookie-consent .active[data-omcookie-panel]"}],detectPopup:[{exists:".tx-om-cookie-consent .active[data-omcookie-panel]"}],optIn:[{waitForThenClick:"[data-omcookie-panel-save=all]"}],optOut:[{if:{exists:"[data-omcookie-panel-save=min]"},then:[{waitForThenClick:"[data-omcookie-panel-save=min]"}],else:[{click:"input[data-omcookie-panel-grp]:checked:not(:disabled)",all:!0,optional:!0},{waitForThenClick:"[data-omcookie-panel-save=save]"}]}]},{name:"onlyFans.com",runContext:{urlPattern:"^https://onlyfans\\.com/"},prehideSelectors:["div.b-cookies-informer"],detectCmp:[{exists:"div.b-cookies-informer"}],detectPopup:[{exists:"div.b-cookies-informer"}],optIn:[{click:"div.b-cookies-informer__nav > button:nth-child(2)"}],optOut:[{click:"div.b-cookies-informer__nav > button:nth-child(1)"},{if:{exists:"div.b-cookies-informer__switchers"},then:[{click:"div.b-cookies-informer__switchers input:not([disabled])",all:!0},{click:"div.b-cookies-informer__nav > button"}]}]},{name:"openai",vendorUrl:"https://platform.openai.com/",cosmetic:!1,runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?openai\\.com/"},prehideSelectors:["[data-testid=cookie-consent-banner]"],detectCmp:[{exists:"[data-testid=cookie-consent-banner]"}],detectPopup:[{visible:"[data-testid=cookie-consent-banner]"}],optIn:[{waitForThenClick:"xpath///button[contains(., 'Accept all')]"}],optOut:[{waitForThenClick:"xpath///button[contains(., 'Reject all')]"}],test:[{wait:500},{eval:"EVAL_OPENAI_TEST"}]},{name:"openli",vendorUrl:"https://openli.com",prehideSelectors:[".legalmonster-cleanslate"],detectCmp:[{exists:".legalmonster-cleanslate"}],detectPopup:[{visible:".legalmonster-cleanslate #lm-cookie-wall-container",check:"any"}],optIn:[{waitForThenClick:"#lm-accept-all"}],optOut:[{waitForThenClick:"#lm-accept-necessary"}]},{name:"opera.com",vendorUrl:"https://unknown",cosmetic:!1,runContext:{main:!0,frame:!1},intermediate:!1,prehideSelectors:[],detectCmp:[{exists:"#cookie-consent .manage-cookies__btn"}],detectPopup:[{visible:"#cookie-consent .cookie-basic-consent__btn"}],optIn:[{waitForThenClick:"#cookie-consent .cookie-basic-consent__btn"}],optOut:[{waitForThenClick:"#cookie-consent .manage-cookies__btn"},{waitForThenClick:"#cookie-consent .active.marketing_option_switch.cookie-consent__switch",all:!0},{waitForThenClick:"#cookie-consent .cookie-selection__btn"}],test:[{eval:"EVAL_OPERA_0"}]},{name:"osano",prehideSelectors:[".osano-cm-window,.osano-cm-dialog"],detectCmp:[{exists:".osano-cm-window"}],detectPopup:[{visible:".osano-cm-dialog"}],optIn:[{click:".osano-cm-accept-all",optional:!0}],optOut:[{waitForThenClick:".osano-cm-denyAll"}]},{name:"otto.de",prehideSelectors:[".cookieBanner--visibility"],detectCmp:[{exists:".cookieBanner--visibility"}],detectPopup:[{visible:".cookieBanner__wrapper"}],optIn:[{click:".js_cookieBannerPermissionButton"}],optOut:[{click:".js_cookieBannerProhibitionButton"}]},{name:"ourworldindata",vendorUrl:"https://ourworldindata.org/",runContext:{urlPattern:"^https://ourworldindata\\.org/"},prehideSelectors:[".cookie-manager"],detectCmp:[{exists:".cookie-manager"}],detectPopup:[{visible:".cookie-manager .cookie-notice.open"}],optIn:[{waitForThenClick:".cookie-notice [data-test=accept]"}],optOut:[{waitForThenClick:".cookie-notice [data-test=reject]"}]},{name:"pabcogypsum",vendorUrl:"https://unknown",prehideSelectors:[".js-cookie-notice:has(#cookie_settings-form)"],detectCmp:[{exists:".js-cookie-notice #cookie_settings-form"}],detectPopup:[{visible:".js-cookie-notice #cookie_settings-form"}],optIn:[{waitForThenClick:".js-cookie-notice button[value=allow]"}],optOut:[{waitForThenClick:".js-cookie-notice button[value=disable]"}]},{name:"paypal-us",prehideSelectors:["#ccpaCookieContent_wrapper, article.ppvx_modal--overpanel"],detectCmp:[{exists:"#ccpaCookieBanner, .privacy-sheet-content"}],detectPopup:[{visible:"#ccpaCookieBanner, .privacy-sheet-content"}],optIn:[{click:"#acceptAllButton"}],optOut:[{if:{exists:"#bannerDeclineButton"},then:[{click:"#bannerDeclineButton"}],else:[{if:{exists:"a#manageCookiesLink"},then:[{click:"a#manageCookiesLink"}],else:[{waitForVisible:".privacy-sheet-content #formContent"},{click:"#formContent .cookiepref-11m2iee-checkbox_base input:checked",all:!0,optional:!0},{click:".cookieAction.saveCookie,.confirmCookie #submitCookiesBtn"}]}]}]},{name:"paypal.com",prehideSelectors:["#gdprCookieBanner"],detectCmp:[{exists:"#gdprCookieBanner"}],detectPopup:[{visible:"#gdprCookieContent_wrapper"}],optIn:[{click:"#acceptAllButton"}],optOut:[{wait:200},{click:".gdprCookieBanner_decline-button"}],test:[{wait:500},{eval:"EVAL_PAYPAL_0"}]},{name:"pinetools.com",cosmetic:!0,prehideSelectors:["#aviso_cookies"],detectCmp:[{exists:"#aviso_cookies"}],detectPopup:[{exists:".lang_en #aviso_cookies"}],optIn:[{click:"#aviso_cookies .a_boton_cerrar"}],optOut:[{hide:"#aviso_cookies"}]},{name:"pinterest-business",vendorUrl:"https://business.pinterest.com/",runContext:{urlPattern:"^https://.*\\.pinterest\\.com/"},prehideSelectors:[".BusinessCookieConsent"],detectCmp:[{exists:".BusinessCookieConsent"}],detectPopup:[{visible:".BusinessCookieConsent [data-id=cookie-consent-banner-buttons]"}],optIn:[{waitForThenClick:"[data-id=cookie-consent-banner-buttons] > div:nth-child(1) button"}],optOut:[{waitForThenClick:"[data-id=cookie-consent-banner-buttons] > div:nth-child(2) button"}]},{name:"pmc",cosmetic:!0,prehideSelectors:["#pmc-pp-tou--notice"],detectCmp:[{exists:"#pmc-pp-tou--notice"}],detectPopup:[{visible:"#pmc-pp-tou--notice"}],optIn:[{click:"span.pmc-pp-tou--notice-close-btn"}],optOut:[{hide:"#pmc-pp-tou--notice"}]},{name:"pornhub.com",runContext:{urlPattern:"^https://(www\\.)?pornhub\\.com/"},cosmetic:!0,prehideSelectors:[".cookiesBanner"],detectCmp:[{exists:".cookiesBanner"}],detectPopup:[{visible:".cookiesBanner"}],optIn:[{click:".cookiesBanner .okButton"}],optOut:[{hide:".cookiesBanner"}]},{name:"pornpics.com",cosmetic:!0,prehideSelectors:["#cookie-contract"],detectCmp:[{exists:"#cookie-contract"}],detectPopup:[{visible:"#cookie-contract"}],optIn:[{click:"#cookie-contract .icon-cross"}],optOut:[{hide:"#cookie-contract"}]},{name:"PrimeBox CookieBar",prehideSelectors:["#cookie-bar"],detectCmp:[{exists:"#cookie-bar .cb-enable,#cookie-bar .cb-disable,#cookie-bar .cb-policy"}],detectPopup:[{visible:"#cookie-bar .cb-enable,#cookie-bar .cb-disable,#cookie-bar .cb-policy",check:"any"}],optIn:[{waitForThenClick:"#cookie-bar .cb-enable"}],optOut:[{click:"#cookie-bar .cb-disable",optional:!0},{hide:"#cookie-bar"}],test:[{eval:"EVAL_PRIMEBOX_0"}]},{name:"privacymanager.io",prehideSelectors:["#gdpr-consent-tool-wrapper",'iframe[src^="https://cmp-consent-tool.privacymanager.io"]'],runContext:{urlPattern:"^https://cmp-consent-tool\\.privacymanager\\.io/",main:!1,frame:!0},detectCmp:[{exists:"button#save"}],detectPopup:[{visible:"button#save"}],optIn:[{click:"button#save"}],optOut:[{if:{exists:"#denyAll"},then:[{click:"#denyAll"},{waitForThenClick:".okButton"}],else:[{waitForThenClick:"#manageSettings"},{waitFor:".purposes-overview-list"},{waitFor:"button#saveAndExit"},{click:"span[role=checkbox][aria-checked=true]",all:!0,optional:!0},{click:"button#saveAndExit"}]}]},{name:"productz.com",vendorUrl:"https://productz.com/",runContext:{urlPattern:"^https://productz\\.com/"},prehideSelectors:[],detectCmp:[{exists:".c-modal.is-active"}],detectPopup:[{visible:".c-modal.is-active"}],optIn:[{waitForThenClick:".c-modal.is-active .is-accept"}],optOut:[{waitForThenClick:".c-modal.is-active .is-dismiss"}]},{name:"pubtech",prehideSelectors:["#pubtech-cmp"],detectCmp:[{exists:"#pubtech-cmp"}],detectPopup:[{visible:"#pubtech-cmp #pt-actions"}],optIn:[{if:{exists:"#pt-accept-all"},then:[{click:"#pubtech-cmp #pt-actions #pt-accept-all"}],else:[{click:"#pubtech-cmp #pt-actions button:nth-of-type(2)"}]}],optOut:[{click:"#pubtech-cmp #pt-close"}],test:[{eval:"EVAL_PUBTECH_0"}]},{name:"quantcast",prehideSelectors:["#qc-cmp2-main,#qc-cmp2-container"],detectCmp:[{exists:"#qc-cmp2-container"}],detectPopup:[{visible:"#qc-cmp2-ui"}],optOut:[{waitFor:'.qc-cmp2-summary-buttons > button[mode="secondary"]',timeout:2e3},{if:{exists:'.qc-cmp2-summary-buttons > button[mode="secondary"]:nth-of-type(2)'},then:[{click:'.qc-cmp2-summary-buttons > button[mode="secondary"]:nth-of-type(2)'}],else:[{click:'.qc-cmp2-summary-buttons > button[mode="secondary"]:nth-of-type(1)'},{waitFor:"#qc-cmp2-ui"},{click:'.qc-cmp2-toggle-switch > button[aria-checked="true"]',all:!0,optional:!0},{click:'.qc-cmp2-main button[aria-label="REJECT ALL"]',optional:!0},{waitForThenClick:'.qc-cmp2-main button[aria-label="SAVE & EXIT"],.qc-cmp2-buttons-desktop > button[mode="primary"]',timeout:5e3}]}],optIn:[{click:'.qc-cmp2-summary-buttons > button[mode="primary"]'}]},{name:"reddit.com",runContext:{urlPattern:"^https://www\\.reddit\\.com/"},prehideSelectors:["[bundlename=reddit_cookie_banner]"],detectCmp:[{exists:"reddit-cookie-banner"}],detectPopup:[{visible:"reddit-cookie-banner"}],optIn:[{waitForThenClick:["reddit-cookie-banner","#accept-all-cookies-button > button"]}],optOut:[{waitForThenClick:["reddit-cookie-banner","#reject-nonessential-cookies-button > button"]}],test:[{eval:"EVAL_REDDIT_0"}]},{name:"roblox",vendorUrl:"https://roblox.com",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?roblox\\.com/"},prehideSelectors:[],detectCmp:[{exists:".cookie-banner-wrapper"}],detectPopup:[{visible:".cookie-banner-wrapper .cookie-banner"}],optIn:[{waitForThenClick:".cookie-banner-wrapper button.btn-cta-lg"}],optOut:[{waitForThenClick:".cookie-banner-wrapper button.btn-secondary-lg"}],test:[{eval:"EVAL_ROBLOX_TEST"}]},{name:"rog-forum.asus.com",runContext:{urlPattern:"^https://rog-forum\\.asus\\.com/"},prehideSelectors:["#cookie-policy-info"],detectCmp:[{exists:"#cookie-policy-info"}],detectPopup:[{visible:"#cookie-policy-info"}],optIn:[{click:'div.cookie-btn-box > div[aria-label="Accept"]'}],optOut:[{click:'div.cookie-btn-box > div[aria-label="Reject"]'},{waitForThenClick:'.cookie-policy-lightbox-bottom > div[aria-label="Save Settings"]'}]},{name:"roofingmegastore.co.uk",runContext:{urlPattern:"^https://(www\\.)?roofingmegastore\\.co\\.uk"},prehideSelectors:["#m-cookienotice"],detectCmp:[{exists:"#m-cookienotice"}],detectPopup:[{visible:"#m-cookienotice"}],optIn:[{click:"#accept-cookies"}],optOut:[{click:"#manage-cookies"},{waitForThenClick:"#accept-selected"}]},{name:"samsung.com",runContext:{urlPattern:"^https://www\\.samsung\\.com/"},cosmetic:!0,prehideSelectors:["div.cookie-bar"],detectCmp:[{exists:"div.cookie-bar"}],detectPopup:[{visible:"div.cookie-bar"}],optIn:[{click:"div.cookie-bar__manage > a"}],optOut:[{hide:"div.cookie-bar"}]},{name:"setapp.com",vendorUrl:"https://setapp.com/",cosmetic:!0,runContext:{urlPattern:"^https://setapp\\.com/"},prehideSelectors:[],detectCmp:[{exists:".cookie-banner.js-cookie-banner"}],detectPopup:[{visible:".cookie-banner.js-cookie-banner"}],optIn:[{waitForThenClick:".cookie-banner.js-cookie-banner button"}],optOut:[{hide:".cookie-banner.js-cookie-banner"}]},{name:"sibbo",prehideSelectors:["sibbo-cmp-layout"],detectCmp:[{exists:"sibbo-cmp-layout"}],detectPopup:[{visible:"#rejectAllMain"}],optIn:[{click:"#acceptAllMain"}],optOut:[{click:"#rejectAllMain"}]},{name:"similarweb.com",cosmetic:!0,prehideSelectors:[".app-cookies-notification"],detectCmp:[{exists:".app-cookies-notification"}],detectPopup:[{exists:".app-layout .app-cookies-notification"}],optIn:[{click:"button.app-cookies-notification__dismiss"}],optOut:[{hide:".app-layout .app-cookies-notification"}]},{name:"Sirdata",cosmetic:!1,prehideSelectors:["#sd-cmp"],detectCmp:[{exists:"#sd-cmp"}],detectPopup:[{visible:"#sd-cmp"}],optIn:[{waitForThenClick:"#sd-cmp .sd-cmp-3cRQ2"}],optOut:[{waitForThenClick:["#sd-cmp","xpath///span[contains(., 'Do not accept') or contains(., 'Acceptera inte') or contains(., 'No aceptar') or contains(., 'Ikke acceptere') or contains(., 'Nicht akzeptieren') or contains(., 'Не приемам') or contains(., 'Να μην γίνει αποδοχή') or contains(., 'Niet accepteren') or contains(., 'Nepřijímat') or contains(., 'Nie akceptuj') or contains(., 'Nu acceptați') or contains(., 'Não aceitar') or contains(., 'Continuer sans accepter') or contains(., 'Non accettare') or contains(., 'Nem fogad el')]"]}]},{name:"snigel",detectCmp:[{exists:".snigel-cmp-framework"}],detectPopup:[{visible:".snigel-cmp-framework"}],optOut:[{click:"#sn-b-custom"},{click:"#sn-b-save"}],test:[{eval:"EVAL_SNIGEL_0"}],optIn:[{click:".snigel-cmp-framework #accept-choices"}]},{name:"steampowered.com",detectCmp:[{exists:".cookiepreferences_popup"},{visible:".cookiepreferences_popup"}],detectPopup:[{visible:".cookiepreferences_popup"}],optOut:[{click:"#rejectAllButton"}],optIn:[{click:"#acceptAllButton"}],test:[{wait:1e3},{eval:"EVAL_STEAMPOWERED_0"}]},{name:"strato.de",prehideSelectors:[".consent__wrapper"],runContext:{urlPattern:"^https://www\\.strato\\.de/"},detectCmp:[{exists:".consent"}],detectPopup:[{visible:".consent"}],optIn:[{click:"button.consentAgree"}],optOut:[{click:"button.consentSettings"},{waitForThenClick:"button#consentSubmit"}]},{name:"svt.se",vendorUrl:"https://www.svt.se/",runContext:{urlPattern:"^https://www\\.svt\\.se/"},prehideSelectors:["[class*=CookieConsent__root___]"],detectCmp:[{exists:"[class*=CookieConsent__root___]"}],detectPopup:[{visible:"[class*=CookieConsent__modal___]"}],optIn:[{waitForThenClick:"[class*=CookieConsent__modal___] > div > button[class*=primary]"}],optOut:[{waitForThenClick:"[class*=CookieConsent__modal___] > div > button[class*=secondary]:nth-child(2)"}],test:[{eval:"EVAL_SVT_TEST"}]},{name:"takealot.com",cosmetic:!0,prehideSelectors:['div[class^="cookies-banner-module_"]'],detectCmp:[{exists:'div[class^="cookies-banner-module_cookie-banner_"]'}],detectPopup:[{exists:'div[class^="cookies-banner-module_cookie-banner_"]'}],optIn:[{click:'button[class*="cookies-banner-module_dismiss-button_"]'}],optOut:[{hide:'div[class^="cookies-banner-module_"]'},{if:{exists:'div[class^="cookies-banner-module_small-cookie-banner_"]'},then:[{eval:"EVAL_TAKEALOT_0"}],else:[]}]},{name:"tarteaucitron.js",prehideSelectors:["#tarteaucitronRoot"],detectCmp:[{exists:"#tarteaucitronRoot"}],detectPopup:[{visible:"#tarteaucitronRoot #tarteaucitronAlertBig",check:"any"}],optIn:[{eval:"EVAL_TARTEAUCITRON_1"}],optOut:[{eval:"EVAL_TARTEAUCITRON_0"}],test:[{eval:"EVAL_TARTEAUCITRON_2",comment:"sometimes there are required categories, so we check that at least something is false"}]},{name:"taunton",vendorUrl:"https://www.taunton.com/",prehideSelectors:["#taunton-user-consent__overlay"],detectCmp:[{exists:"#taunton-user-consent__overlay"}],detectPopup:[{exists:"#taunton-user-consent__overlay:not([aria-hidden=true])"}],optIn:[{click:"#taunton-user-consent__toolbar input[type=checkbox]:not(:checked)"},{click:"#taunton-user-consent__toolbar button[type=submit]"}],optOut:[{click:"#taunton-user-consent__toolbar input[type=checkbox]:checked",optional:!0,all:!0},{click:"#taunton-user-consent__toolbar button[type=submit]"}],test:[{eval:"EVAL_TAUNTON_TEST"}]},{name:"Tealium",prehideSelectors:["#__tealiumGDPRecModal,#__tealiumGDPRcpPrefs,#__tealiumImplicitmodal,#consent-layer"],detectCmp:[{exists:"#__tealiumGDPRecModal *,#__tealiumGDPRcpPrefs *,#__tealiumImplicitmodal *"},{eval:"EVAL_TEALIUM_0"}],detectPopup:[{visible:"#__tealiumGDPRecModal *,#__tealiumGDPRcpPrefs *,#__tealiumImplicitmodal *",check:"any"}],optOut:[{eval:"EVAL_TEALIUM_1"},{eval:"EVAL_TEALIUM_DONOTSELL"},{hide:"#__tealiumGDPRecModal,#__tealiumGDPRcpPrefs,#__tealiumImplicitmodal"},{waitForThenClick:"#cm-acceptNone,.js-accept-essential-cookies",timeout:1e3,optional:!0}],optIn:[{hide:"#__tealiumGDPRecModal,#__tealiumGDPRcpPrefs"},{eval:"EVAL_TEALIUM_2"}],test:[{eval:"EVAL_TEALIUM_3"},{eval:"EVAL_TEALIUM_DONOTSELL_CHECK"},{visible:"#__tealiumGDPRecModal,#__tealiumGDPRcpPrefs",check:"none"}]},{name:"temu",vendorUrl:"https://temu.com",runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?temu\\.com/"},prehideSelectors:[],detectCmp:[{exists:'div > div > div > div > span[href*="/cookie-and-similar-technologies-policy.html"]'}],detectPopup:[{visible:'div > div > div > div > span[href*="/cookie-and-similar-technologies-policy.html"]'}],optIn:[{waitForThenClick:'div > div > div:has(> div > span[href*="/cookie-and-similar-technologies-policy.html"]) > [role=button]:nth-child(3)'}],optOut:[{if:{exists:"xpath///span[contains(., 'Alle afwijzen') or contains(., 'Reject all') or contains(., 'Tümünü reddet') or contains(., 'Odrzuć wszystko')]"},then:[{waitForThenClick:"xpath///span[contains(., 'Alle afwijzen') or contains(., 'Reject all') or contains(., 'Tümünü reddet') or contains(., 'Odrzuć wszystko')]"}],else:[{waitForThenClick:'div > div > div:has(> div > span[href*="/cookie-and-similar-technologies-policy.html"]) > [role=button]:nth-child(2)'}]}]},{name:"Termly",prehideSelectors:["#termly-code-snippet-support"],detectCmp:[{exists:"#termly-code-snippet-support"}],detectPopup:[{visible:"#termly-code-snippet-support div"}],optIn:[{waitForThenClick:'[data-tid="banner-accept"]'}],optOut:[{if:{exists:'[data-tid="banner-decline"]'},then:[{click:'[data-tid="banner-decline"]'}],else:[{click:".t-preference-button"},{wait:500},{if:{exists:".t-declineAllButton"},then:[{click:".t-declineAllButton"}],else:[{waitForThenClick:".t-preference-modal input[type=checkbox][checked]:not([disabled])",all:!0},{waitForThenClick:".t-saveButton"}]}]}]},{name:"termsfeed",vendorUrl:"https://termsfeed.com",comment:"v4.x.x",prehideSelectors:[".termsfeed-com---nb"],detectCmp:[{exists:".termsfeed-com---nb"}],detectPopup:[{visible:".termsfeed-com---nb"}],optIn:[{waitForThenClick:".cc-nb-okagree"}],optOut:[{waitForThenClick:".cc-nb-reject"}]},{name:"termsfeed3",vendorUrl:"https://termsfeed.com",comment:"v3.x.x",prehideSelectors:[".cc_dialog.cc_css_reboot,.cc_overlay_lock"],detectCmp:[{exists:".cc_dialog.cc_css_reboot"}],detectPopup:[{visible:".cc_dialog.cc_css_reboot"}],optIn:[{waitForThenClick:".cc_dialog.cc_css_reboot .cc_b_ok"}],optOut:[{if:{exists:".cc_dialog.cc_css_reboot .cc_b_cp"},then:[{click:".cc_dialog.cc_css_reboot .cc_b_cp"},{waitForVisible:".cookie-consent-preferences-dialog .cc_cp_f_save button"},{waitForThenClick:".cookie-consent-preferences-dialog .cc_cp_f_save button"}],else:[{hide:".cc_dialog.cc_css_reboot,.cc_overlay_lock"}]}]},{name:"tesla",vendorUrl:"https://tesla.com/",runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?tesla\\.com/"},prehideSelectors:[],detectCmp:[{exists:"#cookie_banner"}],detectPopup:[{visible:"#cookie_banner"}],optIn:[{waitForThenClick:"#tsla-accept-cookie"}],optOut:[{waitForThenClick:"#tsla-reject-cookie"}],test:[{eval:"EVAL_TESLA_TEST"}]},{name:"Test page cosmetic CMP",cosmetic:!0,prehideSelectors:["#privacy-test-page-cmp-test-prehide"],detectCmp:[{exists:"#privacy-test-page-cmp-test-banner"}],detectPopup:[{visible:"#privacy-test-page-cmp-test-banner"}],optIn:[{waitFor:"#accept-all"},{click:"#accept-all"}],optOut:[{hide:"#privacy-test-page-cmp-test-banner"}],test:[{wait:500},{eval:"EVAL_TESTCMP_COSMETIC_0"}]},{name:"Test page CMP",prehideSelectors:["#reject-all"],detectCmp:[{exists:"#privacy-test-page-cmp-test"}],detectPopup:[{visible:"#privacy-test-page-cmp-test"}],optIn:[{waitFor:"#accept-all"},{click:"#accept-all"}],optOut:[{waitFor:"#reject-all"},{click:"#reject-all"}],test:[{eval:"EVAL_TESTCMP_0"}]},{name:"thalia.de",prehideSelectors:[".consent-banner-box"],detectCmp:[{exists:"consent-banner[component=consent-banner]"}],detectPopup:[{visible:".consent-banner-box"}],optIn:[{click:".button-zustimmen"}],optOut:[{click:"button[data-consent=disagree]"}]},{name:"thefreedictionary.com",prehideSelectors:["#cmpBanner"],detectCmp:[{exists:"#cmpBanner"}],detectPopup:[{visible:"#cmpBanner"}],optIn:[{eval:"EVAL_THEFREEDICTIONARY_1"}],optOut:[{eval:"EVAL_THEFREEDICTIONARY_0"}]},{name:"theverge",runContext:{frame:!1,main:!0,urlPattern:"^https://(www)?\\.theverge\\.com"},intermediate:!1,prehideSelectors:[".duet--cta--cookie-banner"],detectCmp:[{exists:".duet--cta--cookie-banner"}],detectPopup:[{visible:".duet--cta--cookie-banner"}],optIn:[{click:".duet--cta--cookie-banner button.tracking-12",all:!1}],optOut:[{click:".duet--cta--cookie-banner button.tracking-12 > span"}],test:[{eval:"EVAL_THEVERGE_0"}]},{name:"tidbits-com",cosmetic:!0,prehideSelectors:["#eu_cookie_law_widget-2"],detectCmp:[{exists:"#eu_cookie_law_widget-2"}],detectPopup:[{visible:"#eu_cookie_law_widget-2"}],optIn:[{click:"#eu-cookie-law form > input.accept"}],optOut:[{hide:"#eu_cookie_law_widget-2"}]},{name:"tractor-supply",runContext:{urlPattern:"^https://www\\.tractorsupply\\.com/"},cosmetic:!0,prehideSelectors:[".tsc-cookie-banner"],detectCmp:[{exists:".tsc-cookie-banner"}],detectPopup:[{visible:".tsc-cookie-banner"}],optIn:[{click:"#cookie-banner-cancel"}],optOut:[{hide:".tsc-cookie-banner"}]},{name:"trader-joes-com",cosmetic:!0,prehideSelectors:['div.aem-page > div[class^="CookiesAlert_cookiesAlert__"]'],detectCmp:[{exists:'div.aem-page > div[class^="CookiesAlert_cookiesAlert__"]'}],detectPopup:[{visible:'div.aem-page > div[class^="CookiesAlert_cookiesAlert__"]'}],optIn:[{click:'div[class^="CookiesAlert_cookiesAlert__container__"] button'}],optOut:[{hide:'div.aem-page > div[class^="CookiesAlert_cookiesAlert__"]'}]},{name:"transcend",vendorUrl:"https://unknown",cosmetic:!0,prehideSelectors:["#transcend-consent-manager"],detectCmp:[{exists:"#transcend-consent-manager"}],detectPopup:[{visible:"#transcend-consent-manager"}],optIn:[{waitForThenClick:["#transcend-consent-manager","#consentManagerMainDialog .inner-container button"]}],optOut:[{hide:"#transcend-consent-manager"}]},{name:"transip-nl",runContext:{urlPattern:"^https://www\\.transip\\.nl/"},prehideSelectors:["#consent-modal"],detectCmp:[{any:[{exists:"#consent-modal"},{exists:"#privacy-settings-content"}]}],detectPopup:[{any:[{visible:"#consent-modal"},{visible:"#privacy-settings-content"}]}],optIn:[{click:'button[type="submit"]'}],optOut:[{if:{exists:"#privacy-settings-content"},then:[{click:'button[type="submit"]'}],else:[{click:"div.one-modal__action-footer-column--secondary > a"}]}]},{name:"tropicfeel-com",prehideSelectors:["#shopify-section-cookies-controller"],detectCmp:[{exists:"#shopify-section-cookies-controller"}],detectPopup:[{visible:"#shopify-section-cookies-controller #cookies-controller-main-pane",check:"any"}],optIn:[{waitForThenClick:"#cookies-controller-main-pane form[data-form-allow-all] button"}],optOut:[{click:"#cookies-controller-main-pane a[data-tab-target=manage-cookies]"},{waitFor:"#manage-cookies-pane.active"},{click:"#manage-cookies-pane.active input[type=checkbox][checked]:not([disabled])",all:!0},{click:"#manage-cookies-pane.active button[type=submit]"}],test:[]},{name:"true-car",runContext:{urlPattern:"^https://www\\.truecar\\.com/"},cosmetic:!0,prehideSelectors:[['div[aria-labelledby="cookie-banner-heading"]']],detectCmp:[{exists:'div[aria-labelledby="cookie-banner-heading"]'}],detectPopup:[{visible:'div[aria-labelledby="cookie-banner-heading"]'}],optIn:[{click:'div[aria-labelledby="cookie-banner-heading"] > button[aria-label="Close"]'}],optOut:[{hide:'div[aria-labelledby="cookie-banner-heading"]'}]},{name:"truyo",prehideSelectors:["#truyo-consent-module"],detectCmp:[{exists:"#truyo-cookieBarContent"}],detectPopup:[{visible:"#truyo-consent-module"}],optIn:[{click:"button#acceptAllCookieButton"}],optOut:[{click:"button#declineAllCookieButton"}]},{name:"twcc",vendorUrl:"https://unknown",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:""},prehideSelectors:["#twcc__mechanism"],detectCmp:[{exists:"#twcc__mechanism .twcc__notice"}],detectPopup:[{visible:"#twcc__mechanism .twcc__notice"}],optIn:[{waitForThenClick:"#twcc__accept-button"}],optOut:[{waitForThenClick:"#twcc__decline-button"}],test:[{eval:"EVAL_TWCC_TEST"}]},{name:"twitch-mobile",vendorUrl:"https://m.twitch.tv/",cosmetic:!0,runContext:{urlPattern:"^https?://m\\.twitch\\.tv"},prehideSelectors:[],detectCmp:[{exists:'.ReactModal__Overlay [href="https://www.twitch.tv/p/cookie-policy"]'}],detectPopup:[{visible:'.ReactModal__Overlay [href="https://www.twitch.tv/p/cookie-policy"]'}],optIn:[{waitForThenClick:'.ReactModal__Overlay:has([href="https://www.twitch.tv/p/cookie-policy"]) button'}],optOut:[{hide:'.ReactModal__Overlay:has([href="https://www.twitch.tv/p/cookie-policy"])'}]},{name:"twitch.tv",runContext:{urlPattern:"^https?://(www\\.)?twitch\\.tv"},prehideSelectors:["div:has(> .consent-banner .consent-banner__content--gdpr-v2),.ReactModalPortal:has([data-a-target=consent-modal-save])"],detectCmp:[{exists:".consent-banner .consent-banner__content--gdpr-v2"}],detectPopup:[{visible:".consent-banner .consent-banner__content--gdpr-v2"}],optIn:[{click:'button[data-a-target="consent-banner-accept"]'}],optOut:[{hide:"div:has(> .consent-banner .consent-banner__content--gdpr-v2)"},{click:'button[data-a-target="consent-banner-manage-preferences"]'},{waitFor:"input[type=checkbox][data-a-target=tw-checkbox]"},{click:"input[type=checkbox][data-a-target=tw-checkbox][checked]:not([disabled])",all:!0,optional:!0},{waitForThenClick:"[data-a-target=consent-modal-save]"},{waitForVisible:".ReactModalPortal:has([data-a-target=consent-modal-save])",check:"none"}]},{name:"twitter",runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?(twitter|x)\\.com/"},prehideSelectors:['[data-testid="BottomBar"]'],detectCmp:[{exists:'[data-testid="BottomBar"] div'}],detectPopup:[{visible:'[data-testid="BottomBar"] div'}],optIn:[{waitForThenClick:'[data-testid="BottomBar"] > div:has(>div:first-child>div:last-child>button[role=button]>span) > div:last-child > button[role=button]:first-child'}],optOut:[{waitForThenClick:'[data-testid="BottomBar"] > div:has(>div:first-child>div:last-child>button[role=button]>span) > div:last-child > button[role=button]:last-child'}],TODOtest:[{eval:"EVAL_document.cookie.includes('d_prefs=MjoxLGNvbnNlbnRfdmVyc2lvbjoy')"}]},{name:"ubuntu.com",prehideSelectors:["dialog.cookie-policy"],detectCmp:[{any:[{exists:"dialog.cookie-policy header"},{exists:'xpath///*[@id="modal"]/div/header'}]}],detectPopup:[{any:[{visible:"dialog header"},{visible:'xpath///*[@id="modal"]/div/header'}]}],optIn:[{any:[{waitForThenClick:"#cookie-policy-button-accept"},{waitForThenClick:'xpath///*[@id="cookie-policy-button-accept"]'}]}],optOut:[{any:[{waitForThenClick:"button.js-manage"},{waitForThenClick:'xpath///*[@id="cookie-policy-content"]/p[4]/button[2]'}]},{waitForThenClick:"dialog.cookie-policy .p-switch__input:checked",optional:!0,all:!0,timeout:500},{any:[{waitForThenClick:"dialog.cookie-policy .js-save-preferences"},{waitForThenClick:'xpath///*[@id="modal"]/div/button'}]}],test:[{eval:"EVAL_UBUNTU_COM_0"}]},{name:"UK Cookie Consent",prehideSelectors:["#catapult-cookie-bar"],cosmetic:!0,detectCmp:[{exists:"#catapult-cookie-bar"}],detectPopup:[{exists:".has-cookie-bar #catapult-cookie-bar"}],optIn:[{click:"#catapultCookie"}],optOut:[{hide:"#catapult-cookie-bar"}],test:[{eval:"EVAL_UK_COOKIE_CONSENT_0"}]},{name:"urbanarmorgear-com",cosmetic:!0,prehideSelectors:['div[class^="Layout__CookieBannerContainer-"]'],detectCmp:[{exists:'div[class^="Layout__CookieBannerContainer-"]'}],detectPopup:[{visible:'div[class^="Layout__CookieBannerContainer-"]'}],optIn:[{click:'button[class^="CookieBanner__AcceptButton"]'}],optOut:[{hide:'div[class^="Layout__CookieBannerContainer-"]'}]},{name:"usercentrics-api",detectCmp:[{exists:"#usercentrics-root,#usercentrics-cmp-ui"}],detectPopup:[{eval:"EVAL_USERCENTRICS_API_0"},{if:{exists:"#usercentrics-cmp-ui"},then:[{waitForVisible:"#usercentrics-cmp-ui",timeout:2e3}],else:[{exists:["#usercentrics-root","[data-testid=uc-container]"]},{waitForVisible:"#usercentrics-root",timeout:2e3}]}],optIn:[{eval:"EVAL_USERCENTRICS_API_3"},{eval:"EVAL_USERCENTRICS_API_1"},{eval:"EVAL_USERCENTRICS_API_5"}],optOut:[{eval:"EVAL_USERCENTRICS_API_1"},{eval:"EVAL_USERCENTRICS_API_2"}],test:[{eval:"EVAL_USERCENTRICS_API_6"}]},{name:"usercentrics-button",detectCmp:[{exists:"#usercentrics-button"}],detectPopup:[{visible:"#usercentrics-button #uc-btn-accept-banner"}],optIn:[{click:"#usercentrics-button #uc-btn-accept-banner"}],optOut:[{click:"#usercentrics-button #uc-btn-deny-banner"}],test:[{eval:"EVAL_USERCENTRICS_BUTTON_0"}]},{name:"uswitch.com",runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?uswitch\\.com/"},prehideSelectors:[".ucb"],detectCmp:[{exists:".ucb-banner"}],detectPopup:[{visible:".ucb-banner"}],optIn:[{waitForThenClick:".ucb-banner .ucb-btn-accept"}],optOut:[{waitForThenClick:".ucb-banner .ucb-btn-save"}]},{name:"vodafone.de",runContext:{urlPattern:"^https://www\\.vodafone\\.de/"},prehideSelectors:[".dip-consent,.dip-consent-container"],detectCmp:[{exists:".dip-consent-container"}],detectPopup:[{visible:".dip-consent-content"}],optOut:[{click:'.dip-consent-btn[tabindex="2"]'}],optIn:[{click:'.dip-consent-btn[tabindex="1"]'}]},{name:"waitrose.com",prehideSelectors:["div[aria-labelledby=CookieAlertModalHeading]","section[data-test=initial-waitrose-cookie-consent-banner]","section[data-test=cookie-consent-modal]"],detectCmp:[{exists:"section[data-test=initial-waitrose-cookie-consent-banner]"}],detectPopup:[{visible:"section[data-test=initial-waitrose-cookie-consent-banner]"}],optIn:[{click:"button[data-test=accept-all]"}],optOut:[{click:"button[data-test=manage-cookies]"},{wait:200},{eval:"EVAL_WAITROSE_0"},{click:"button[data-test=submit]"}],test:[{eval:"EVAL_WAITROSE_1"}]},{name:"webflow",vendorUrl:"https://webflow.com/",prehideSelectors:[".fs-cc-components"],detectCmp:[{exists:".fs-cc-components"}],detectPopup:[{visible:".fs-cc-components"},{visible:"[fs-cc=banner]"}],optIn:[{wait:500},{waitForThenClick:"[fs-cc=banner] [fs-cc=allow]"}],optOut:[{wait:500},{waitForThenClick:"[fs-cc=banner] [fs-cc=deny]"}]},{name:"wetransfer.com",detectCmp:[{exists:".welcome__cookie-notice"}],detectPopup:[{visible:".welcome__cookie-notice"}],optIn:[{click:".welcome__button--accept"}],optOut:[{click:".welcome__button--decline"}]},{name:"whitepages.com",runContext:{urlPattern:"^https://www\\.whitepages\\.com/"},cosmetic:!0,prehideSelectors:[".cookie-wrapper, .cookie-overlay"],detectCmp:[{exists:".cookie-wrapper"}],detectPopup:[{visible:".cookie-overlay"}],optIn:[{click:'button[aria-label="Got it"]'}],optOut:[{hide:".cookie-wrapper"}]},{name:"wolframalpha",vendorUrl:"https://www.wolframalpha.com",prehideSelectors:[],cosmetic:!0,runContext:{urlPattern:"^https://www\\.wolframalpha\\.com/"},detectCmp:[{exists:"section._a_yb"}],detectPopup:[{visible:"section._a_yb"}],optIn:[{waitForThenClick:"section._a_yb button"}],optOut:[{hide:"section._a_yb"}]},{name:"woo-commerce-com",prehideSelectors:[".wccom-comp-privacy-banner .wccom-privacy-banner"],detectCmp:[{exists:".wccom-comp-privacy-banner .wccom-privacy-banner"}],detectPopup:[{exists:".wccom-comp-privacy-banner .wccom-privacy-banner"}],optIn:[{click:".wccom-privacy-banner__content-buttons button.is-primary"}],optOut:[{click:".wccom-privacy-banner__content-buttons button.is-secondary"},{waitForThenClick:"input[type=checkbox][checked]:not([disabled])",all:!0},{click:"div.wccom-modal__footer > button"}]},{name:"WP Cookie Notice for GDPR",vendorUrl:"https://wordpress.org/plugins/gdpr-cookie-consent/",prehideSelectors:["#gdpr-cookie-consent-bar"],detectCmp:[{exists:"#gdpr-cookie-consent-bar"}],detectPopup:[{visible:"#gdpr-cookie-consent-bar"}],optIn:[{waitForThenClick:"#gdpr-cookie-consent-bar #cookie_action_accept"}],optOut:[{waitForThenClick:"#gdpr-cookie-consent-bar #cookie_action_reject"}],test:[{eval:"EVAL_WP_COOKIE_NOTICE_0"}]},{name:"wpcc",cosmetic:!0,prehideSelectors:[".wpcc-container"],detectCmp:[{exists:".wpcc-container"}],detectPopup:[{exists:".wpcc-container .wpcc-message"}],optIn:[{click:".wpcc-compliance .wpcc-btn"}],optOut:[{hide:".wpcc-container"}]},{name:"xe.com",vendorUrl:"https://www.xe.com/",runContext:{urlPattern:"^https://www\\.xe\\.com/"},prehideSelectors:["[class*=ConsentBanner]"],detectCmp:[{exists:"[class*=ConsentBanner]"}],detectPopup:[{visible:"[class*=ConsentBanner]"}],optIn:[{waitForThenClick:"[class*=ConsentBanner] .egnScw"}],optOut:[{wait:1e3},{waitForThenClick:"[class*=ConsentBanner] .frDWEu"},{waitForThenClick:"[class*=ConsentBanner] .hXIpFU"}],test:[{eval:"EVAL_XE_TEST"}]},{name:"xhamster-eu",prehideSelectors:[".cookies-modal"],detectCmp:[{exists:".cookies-modal"}],detectPopup:[{exists:".cookies-modal"}],optIn:[{click:"button.cmd-button-accept-all"}],optOut:[{click:"button.cmd-button-reject-all"}]},{name:"xhamster-us",runContext:{urlPattern:"^https://(www\\.)?xhamster\\d?\\.com"},cosmetic:!0,prehideSelectors:[".cookie-announce"],detectCmp:[{exists:".cookie-announce"}],detectPopup:[{visible:".cookie-announce .announce-text"}],optIn:[{click:".cookie-announce button.xh-button"}],optOut:[{hide:".cookie-announce"}]},{name:"xing.com",detectCmp:[{exists:"div[class^=cookie-consent-CookieConsent]"}],detectPopup:[{exists:"div[class^=cookie-consent-CookieConsent]"}],optIn:[{click:"#consent-accept-button"}],optOut:[{click:"#consent-settings-button"},{click:".consent-banner-button-accept-overlay"}],test:[{eval:"EVAL_XING_0"}]},{name:"xnxx-com",cosmetic:!0,prehideSelectors:["#cookies-use-alert"],detectCmp:[{exists:"#cookies-use-alert"}],detectPopup:[{visible:"#cookies-use-alert"}],optIn:[{click:"#cookies-use-alert .close"}],optOut:[{hide:"#cookies-use-alert"}]},{name:"xvideos",vendorUrl:"https://xvideos.com",runContext:{urlPattern:"^https://[^/]*xvideos\\.com/"},prehideSelectors:[],detectCmp:[{exists:".disclaimer-opened #disclaimer-cookies"}],detectPopup:[{visible:".disclaimer-opened #disclaimer-cookies"}],optIn:[{waitForThenClick:"#disclaimer-accept_cookies"}],optOut:[{waitForThenClick:"#disclaimer-reject_cookies"}]},{name:"Yahoo",runContext:{urlPattern:"^https://consent\\.yahoo\\.com/v2/"},prehideSelectors:["#reject-all"],detectCmp:[{exists:"#consent-page"}],detectPopup:[{visible:"#consent-page"}],optIn:[{waitForThenClick:"#consent-page button[value=agree]"}],optOut:[{waitForThenClick:"#consent-page button[value=reject]"}]},{name:"youporn.com",cosmetic:!0,prehideSelectors:[".euCookieModal, #js_euCookieModal"],detectCmp:[{exists:".euCookieModal"}],detectPopup:[{exists:".euCookieModal, #js_euCookieModal"}],optIn:[{click:'button[name="user_acceptCookie"]'}],optOut:[{hide:".euCookieModal"}]},{name:"youtube-desktop",prehideSelectors:["tp-yt-iron-overlay-backdrop.opened","ytd-consent-bump-v2-lightbox"],detectCmp:[{exists:"ytd-consent-bump-v2-lightbox tp-yt-paper-dialog"},{exists:'ytd-consent-bump-v2-lightbox tp-yt-paper-dialog a[href^="https://consent.youtube.com/"]'}],detectPopup:[{visible:"ytd-consent-bump-v2-lightbox tp-yt-paper-dialog"}],optIn:[{waitForThenClick:"ytd-consent-bump-v2-lightbox .eom-buttons .eom-button-row:first-child ytd-button-renderer:last-child #button,ytd-consent-bump-v2-lightbox .eom-buttons .eom-button-row:first-child ytd-button-renderer:last-child button"},{wait:500}],optOut:[{waitForThenClick:"ytd-consent-bump-v2-lightbox .eom-buttons .eom-button-row:first-child ytd-button-renderer:first-child #button,ytd-consent-bump-v2-lightbox .eom-buttons .eom-button-row:first-child ytd-button-renderer:first-child button"},{wait:500}],test:[{wait:500},{eval:"EVAL_YOUTUBE_DESKTOP_0"}]},{name:"youtube-mobile",prehideSelectors:[".consent-bump-v2-lightbox"],detectCmp:[{exists:"ytm-consent-bump-v2-renderer"}],detectPopup:[{visible:"ytm-consent-bump-v2-renderer"}],optIn:[{waitForThenClick:"ytm-consent-bump-v2-renderer .privacy-terms + .one-col-dialog-buttons c3-material-button:first-child button, ytm-consent-bump-v2-renderer .privacy-terms + .one-col-dialog-buttons ytm-button-renderer:first-child button"},{wait:500}],optOut:[{waitForThenClick:"ytm-consent-bump-v2-renderer .privacy-terms + .one-col-dialog-buttons c3-material-button:nth-child(2) button, ytm-consent-bump-v2-renderer .privacy-terms + .one-col-dialog-buttons ytm-button-renderer:nth-child(2) button"},{wait:500}],test:[{wait:500},{eval:"EVAL_YOUTUBE_MOBILE_0"}]},{name:"zdf",prehideSelectors:["#zdf-cmp-banner-sdk"],detectCmp:[{exists:"#zdf-cmp-banner-sdk"}],detectPopup:[{visible:"#zdf-cmp-main.zdf-cmp-show"}],optIn:[{waitForThenClick:"#zdf-cmp-main #zdf-cmp-accept-btn"}],optOut:[{waitForThenClick:"#zdf-cmp-main #zdf-cmp-deny-btn"}],test:[]},{name:"zentralruf-de",runContext:{urlPattern:"^https://(www\\.)?zentralruf\\.de"},prehideSelectors:["#cookie_modal_wrapper"],detectCmp:[{exists:"#cookie_modal_wrapper"}],detectPopup:[{visible:"#cookie_modal_wrapper"}],optIn:[{waitForThenClick:"#cookie_modal_wrapper #cookie_modal_button_consent_all"}],optOut:[{waitForThenClick:"#cookie_modal_wrapper #cookie_modal_button_choose"}]}],A={"didomi.io":{detectors:[{presentMatcher:{target:{selector:"#didomi-host, #didomi-notice"},type:"css"},showingMatcher:{target:{selector:"body.didomi-popup-open, .didomi-notice-banner"},type:"css"}}],methods:[{action:{target:{selector:".didomi-popup-notice-buttons .didomi-button:not(.didomi-button-highlight), .didomi-notice-banner .didomi-learn-more-button"},type:"click"},name:"OPEN_OPTIONS"},{action:{actions:[{retries:50,target:{selector:"#didomi-purpose-cookies"},type:"waitcss",waitTime:50},{consents:[{description:"Share (everything) with others",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-share_whith_others]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-share_whith_others]:last-child"},type:"click"},type:"X"},{description:"Information storage and access",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-cookies]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-cookies]:last-child"},type:"click"},type:"D"},{description:"Content selection, offers and marketing",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-CL-T1Rgm7]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-CL-T1Rgm7]:last-child"},type:"click"},type:"E"},{description:"Analytics",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-analytics]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-analytics]:last-child"},type:"click"},type:"B"},{description:"Analytics",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-M9NRHJe3G]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-M9NRHJe3G]:last-child"},type:"click"},type:"B"},{description:"Ad and content selection",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-advertising_personalization]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-advertising_personalization]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection",falseAction:{parent:{childFilter:{target:{selector:"#didomi-purpose-pub-ciblee"}},selector:".didomi-consent-popup-data-processing, .didomi-components-accordion-label-container"},target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-pub-ciblee]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-pub-ciblee]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection - basics",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-q4zlJqdcD]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-q4zlJqdcD]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection - partners and subsidiaries",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-partenaire-cAsDe8jC]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-partenaire-cAsDe8jC]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection - social networks",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-p4em9a8m]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-p4em9a8m]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection - others",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-autres-pub]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-autres-pub]:last-child"},type:"click"},type:"F"},{description:"Social networks",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-reseauxsociaux]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-reseauxsociaux]:last-child"},type:"click"},type:"A"},{description:"Social networks",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-social_media]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-social_media]:last-child"},type:"click"},type:"A"},{description:"Content selection",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-content_personalization]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-content_personalization]:last-child"},type:"click"},type:"E"},{description:"Ad delivery",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-ad_delivery]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-ad_delivery]:last-child"},type:"click"},type:"F"}],type:"consent"},{action:{consents:[{matcher:{childFilter:{target:{selector:":not(.didomi-components-radio__option--selected)"}},type:"css"},trueAction:{target:{selector:":nth-child(2)"},type:"click"},falseAction:{target:{selector:":first-child"},type:"click"},type:"X"}],type:"consent"},target:{selector:".didomi-components-radio"},type:"foreach"}],type:"list"},name:"DO_CONSENT"},{action:{parent:{selector:".didomi-consent-popup-footer .didomi-consent-popup-actions"},target:{selector:".didomi-components-button:first-child"},type:"click"},name:"SAVE_CONSENT"}]},oil:{detectors:[{presentMatcher:{target:{selector:".as-oil-content-overlay"},type:"css"},showingMatcher:{target:{selector:".as-oil-content-overlay"},type:"css"}}],methods:[{action:{actions:[{target:{selector:".as-js-advanced-settings"},type:"click"},{retries:"10",target:{selector:".as-oil-cpc__purpose-container"},type:"waitcss",waitTime:"250"}],type:"list"},name:"OPEN_OPTIONS"},{action:{actions:[{consents:[{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Information storage and access","Opbevaring af og adgang til oplysninger på din enhed"]},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Information storage and access","Opbevaring af og adgang til oplysninger på din enhed"]},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"D"},{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Personlige annoncer","Personalisation"]},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Personlige annoncer","Personalisation"]},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"E"},{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Annoncevalg, levering og rapportering","Ad selection, delivery, reporting"]},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Annoncevalg, levering og rapportering","Ad selection, delivery, reporting"]},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"F"},{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Personalisering af indhold","Content selection, delivery, reporting"]},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Personalisering af indhold","Content selection, delivery, reporting"]},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"E"},{matcher:{parent:{childFilter:{target:{selector:".as-oil-cpc__purpose-header",textFilter:["Måling","Measurement"]}},selector:".as-oil-cpc__purpose-container"},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{childFilter:{target:{selector:".as-oil-cpc__purpose-header",textFilter:["Måling","Measurement"]}},selector:".as-oil-cpc__purpose-container"},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"B"},{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:"Google"},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:"Google"},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"F"}],type:"consent"}],type:"list"},name:"DO_CONSENT"},{action:{target:{selector:".as-oil__btn-optin"},type:"click"},name:"SAVE_CONSENT"},{action:{target:{selector:"div.as-oil"},type:"hide"},name:"HIDE_CMP"}]},optanon:{detectors:[{presentMatcher:{target:{selector:"#optanon-menu, .optanon-alert-box-wrapper"},type:"css"},showingMatcher:{target:{displayFilter:!0,selector:".optanon-alert-box-wrapper"},type:"css"}}],methods:[{action:{actions:[{target:{selector:".optanon-alert-box-wrapper .optanon-toggle-display, a[onclick*='OneTrust.ToggleInfoDisplay()'], a[onclick*='Optanon.ToggleInfoDisplay()']"},type:"click"}],type:"list"},name:"OPEN_OPTIONS"},{action:{actions:[{target:{selector:".preference-menu-item #Your-privacy"},type:"click"},{target:{selector:"#optanon-vendor-consent-text"},type:"click"},{action:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"X"}],type:"consent"},target:{selector:"#optanon-vendor-consent-list .vendor-item"},type:"foreach"},{target:{selector:".vendor-consent-back-link"},type:"click"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-performance"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-performance"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-functional"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-functional"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-advertising"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-advertising"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-social"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-social"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Social Media Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Social Media Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Personalisation"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Personalisation"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Site monitoring cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Site monitoring cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Third party privacy-enhanced content"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Third party privacy-enhanced content"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"X"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Performance & Advertising Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Performance & Advertising Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Information storage and access"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Information storage and access"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"D"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Ad selection, delivery, reporting"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Ad selection, delivery, reporting"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Content selection, delivery, reporting"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Content selection, delivery, reporting"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Measurement"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Measurement"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Recommended Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Recommended Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"X"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Unclassified Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Unclassified Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"X"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Analytical Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Analytical Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Marketing Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Marketing Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Personalization"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Personalization"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Ad Selection, Delivery & Reporting"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Ad Selection, Delivery & Reporting"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Content Selection, Delivery & Reporting"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Content Selection, Delivery & Reporting"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"}],type:"list"},name:"DO_CONSENT"},{action:{parent:{selector:".optanon-save-settings-button"},target:{selector:".optanon-white-button-middle"},type:"click"},name:"SAVE_CONSENT"},{action:{actions:[{target:{selector:"#optanon-popup-wrapper"},type:"hide"},{target:{selector:"#optanon-popup-bg"},type:"hide"},{target:{selector:".optanon-alert-box-wrapper"},type:"hide"}],type:"list"},name:"HIDE_CMP"}]},quantcast2:{detectors:[{presentMatcher:{target:{selector:"[data-tracking-opt-in-overlay]"},type:"css"},showingMatcher:{target:{selector:"[data-tracking-opt-in-overlay] [data-tracking-opt-in-learn-more]"},type:"css"}}],methods:[{action:{target:{selector:"[data-tracking-opt-in-overlay] [data-tracking-opt-in-learn-more]"},type:"click"},name:"OPEN_OPTIONS"},{action:{actions:[{type:"wait",waitTime:500},{action:{actions:[{target:{selector:"div",textFilter:["Information storage and access"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"D"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Personalization"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"F"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Ad selection, delivery, reporting"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"F"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Content selection, delivery, reporting"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"E"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Measurement"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"B"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Other Partners"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"X"}],type:"consent"},type:"ifcss"}],type:"list"},parent:{childFilter:{target:{selector:"input"}},selector:"[data-tracking-opt-in-overlay] > div > div"},target:{childFilter:{target:{selector:"input"}},selector:":scope > div"},type:"foreach"}],type:"list"},name:"DO_CONSENT"},{action:{target:{selector:"[data-tracking-opt-in-overlay] [data-tracking-opt-in-save]"},type:"click"},name:"SAVE_CONSENT"}]},springer:{detectors:[{presentMatcher:{parent:null,target:{selector:".cmp-app_gdpr"},type:"css"},showingMatcher:{parent:null,target:{displayFilter:!0,selector:".cmp-popup_popup"},type:"css"}}],methods:[{action:{actions:[{target:{selector:".cmp-intro_rejectAll"},type:"click"},{type:"wait",waitTime:250},{target:{selector:".cmp-purposes_purposeItem:not(.cmp-purposes_selectedPurpose)"},type:"click"}],type:"list"},name:"OPEN_OPTIONS"},{action:{consents:[{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Przechowywanie informacji na urządzeniu lub dostęp do nich",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Przechowywanie informacji na urządzeniu lub dostęp do nich",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"D"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór podstawowych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór podstawowych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"F"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Tworzenie profilu spersonalizowanych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Tworzenie profilu spersonalizowanych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"F"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór spersonalizowanych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór spersonalizowanych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"E"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Tworzenie profilu spersonalizowanych treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Tworzenie profilu spersonalizowanych treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"E"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór spersonalizowanych treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór spersonalizowanych treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"B"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Pomiar wydajności reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Pomiar wydajności reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"B"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Pomiar wydajności treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Pomiar wydajności treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"B"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Stosowanie badań rynkowych w celu generowania opinii odbiorców",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Stosowanie badań rynkowych w celu generowania opinii odbiorców",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"X"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Opracowywanie i ulepszanie produktów",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Opracowywanie i ulepszanie produktów",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"X"}],type:"consent"},name:"DO_CONSENT"},{action:{target:{selector:".cmp-details_save"},type:"click"},name:"SAVE_CONSENT"}]},wordpressgdpr:{detectors:[{presentMatcher:{parent:null,target:{selector:".wpgdprc-consent-bar"},type:"css"},showingMatcher:{parent:null,target:{displayFilter:!0,selector:".wpgdprc-consent-bar"},type:"css"}}],methods:[{action:{parent:null,target:{selector:".wpgdprc-consent-bar .wpgdprc-consent-bar__settings",textFilter:null},type:"click"},name:"OPEN_OPTIONS"},{action:{actions:[{target:{selector:".wpgdprc-consent-modal .wpgdprc-button",textFilter:"Eyeota"},type:"click"},{consents:[{description:"Eyeota Cookies",matcher:{parent:{selector:".wpgdprc-consent-modal__description",textFilter:"Eyeota"},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".wpgdprc-consent-modal__description",textFilter:"Eyeota"},target:{selector:"label"},type:"click"},type:"X"}],type:"consent"},{target:{selector:".wpgdprc-consent-modal .wpgdprc-button",textFilter:"Advertising"},type:"click"},{consents:[{description:"Advertising Cookies",matcher:{parent:{selector:".wpgdprc-consent-modal__description",textFilter:"Advertising"},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".wpgdprc-consent-modal__description",textFilter:"Advertising"},target:{selector:"label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},name:"DO_CONSENT"},{action:{parent:null,target:{selector:".wpgdprc-button",textFilter:"Save my settings"},type:"click"},name:"SAVE_CONSENT"}]}},E={autoconsent:f,consentomatic:A},x=Object.freeze({__proto__:null,autoconsent:f,consentomatic:A,default:E});const O=new class{constructor(e,t=null,o=null){if(this.id=a(),this.rules=[],this.foundCmp=null,this.state={lifecycle:"loading",prehideOn:!1,findCmpAttempts:0,detectedCmps:[],detectedPopups:[],selfTest:null},r.sendContentMessage=e,this.sendContentMessage=e,this.rules=[],this.updateState({lifecycle:"loading"}),this.addDynamicRules(),t)this.initialize(t,o);else{o&&this.parseDeclarativeRules(o);e({type:"init",url:window.location.href}),this.updateState({lifecycle:"waitingForInitResponse"})}this.domActions=new v(this)}initialize(e,t){const o=g(e);if(o.logs.lifecycle&&console.log("autoconsent init",window.location.href),this.config=o,o.enabled){if(t&&this.parseDeclarativeRules(t),this.rules=function(e,t){return e.filter((e=>(!t.disabledCmps||!t.disabledCmps.includes(e.name))&&(t.enableCosmeticRules||!e.isCosmetic)))}(this.rules,o),e.enablePrehide)if(document.documentElement)this.prehideElements();else{const e=()=>{window.removeEventListener("DOMContentLoaded",e),this.prehideElements()};window.addEventListener("DOMContentLoaded",e)}if("loading"===document.readyState){const e=()=>{window.removeEventListener("DOMContentLoaded",e),this.start()};window.addEventListener("DOMContentLoaded",e)}else this.start();this.updateState({lifecycle:"initialized"})}else o.logs.lifecycle&&console.log("autoconsent is disabled")}addDynamicRules(){C.forEach((e=>{this.rules.push(new e(this))}))}parseDeclarativeRules(e){Object.keys(e.consentomatic).forEach((t=>{this.addConsentomaticCMP(t,e.consentomatic[t])})),e.autoconsent.forEach((e=>{this.addDeclarativeCMP(e)}))}addDeclarativeCMP(e){this.rules.push(new u(e,this))}addConsentomaticCMP(e,t){this.rules.push(new m(`com_${e}`,t))}start(){window.requestIdleCallback?window.requestIdleCallback((()=>this._start()),{timeout:500}):this._start()}async _start(){const e=this.config.logs;e.lifecycle&&console.log(`Detecting CMPs on ${window.location.href}`),this.updateState({lifecycle:"started"});const t=await this.findCmp(this.config.detectRetries);if(this.updateState({detectedCmps:t.map((e=>e.name))}),0===t.length)return e.lifecycle&&console.log("no CMP found",location.href),this.config.enablePrehide&&this.undoPrehide(),this.updateState({lifecycle:"nothingDetected"}),!1;this.updateState({lifecycle:"cmpDetected"});const o=[],i=[];for(const e of t)e.isCosmetic?i.push(e):o.push(e);let c=!1,n=await this.detectPopups(o,(async e=>{c=await this.handlePopup(e)}));if(0===n.length&&(n=await this.detectPopups(i,(async e=>{c=await this.handlePopup(e)}))),0===n.length)return e.lifecycle&&console.log("no popup found"),this.config.enablePrehide&&this.undoPrehide(),!1;if(n.length>1){const t={msg:"Found multiple CMPs, check the detection rules.",cmps:n.map((e=>e.name))};e.errors&&console.warn(t.msg,t.cmps),this.sendContentMessage({type:"autoconsentError",details:t})}return c}async findCmp(e){const t=this.config.logs;this.updateState({findCmpAttempts:this.state.findCmpAttempts+1});const o=[];for(const e of this.rules)try{if(!e.checkRunContext())continue;await e.detectCmp()&&(t.lifecycle&&console.log(`Found CMP: ${e.name} ${window.location.href}`),this.sendContentMessage({type:"cmpDetected",url:location.href,cmp:e.name}),o.push(e))}catch(o){t.errors&&console.warn(`error detecting ${e.name}`,o)}return 0===o.length&&e>0?(await this.domActions.wait(500),this.findCmp(e-1)):o}async detectPopup(e){if(await this.waitForPopup(e).catch((t=>(this.config.logs.errors&&console.warn(`error waiting for a popup for ${e.name}`,t),!1))))return this.updateState({detectedPopups:this.state.detectedPopups.concat([e.name])}),this.sendContentMessage({type:"popupFound",cmp:e.name,url:location.href}),e;throw new Error("Popup is not shown")}async detectPopups(e,t){const o=e.map((e=>this.detectPopup(e)));await Promise.any(o).then((e=>{t(e)})).catch((()=>null));const i=await Promise.allSettled(o),c=[];for(const e of i)"fulfilled"===e.status&&c.push(e.value);return c}async handlePopup(e){return this.updateState({lifecycle:"openPopupDetected"}),this.config.enablePrehide&&!this.state.prehideOn&&this.prehideElements(),this.foundCmp=e,"optOut"===this.config.autoAction?await this.doOptOut():"optIn"===this.config.autoAction?await this.doOptIn():(this.config.logs.lifecycle&&console.log("waiting for opt-out signal...",location.href),!0)}async doOptOut(){const e=this.config.logs;let t;return this.updateState({lifecycle:"runningOptOut"}),this.foundCmp?(e.lifecycle&&console.log(`CMP ${this.foundCmp.name}: opt out on ${window.location.href}`),t=await this.foundCmp.optOut(),e.lifecycle&&console.log(`${this.foundCmp.name}: opt out result ${t}`)):(e.errors&&console.log("no CMP to opt out"),t=!1),this.config.enablePrehide&&this.undoPrehide(),this.sendContentMessage({type:"optOutResult",cmp:this.foundCmp?this.foundCmp.name:"none",result:t,scheduleSelfTest:this.foundCmp&&this.foundCmp.hasSelfTest,url:location.href}),t&&!this.foundCmp.isIntermediate?(this.sendContentMessage({type:"autoconsentDone",cmp:this.foundCmp.name,isCosmetic:this.foundCmp.isCosmetic,url:location.href}),this.updateState({lifecycle:"done"})):this.updateState({lifecycle:t?"optOutSucceeded":"optOutFailed"}),t}async doOptIn(){const e=this.config.logs;let t;return this.updateState({lifecycle:"runningOptIn"}),this.foundCmp?(e.lifecycle&&console.log(`CMP ${this.foundCmp.name}: opt in on ${window.location.href}`),t=await this.foundCmp.optIn(),e.lifecycle&&console.log(`${this.foundCmp.name}: opt in result ${t}`)):(e.errors&&console.log("no CMP to opt in"),t=!1),this.config.enablePrehide&&this.undoPrehide(),this.sendContentMessage({type:"optInResult",cmp:this.foundCmp?this.foundCmp.name:"none",result:t,scheduleSelfTest:!1,url:location.href}),t&&!this.foundCmp.isIntermediate?(this.sendContentMessage({type:"autoconsentDone",cmp:this.foundCmp.name,isCosmetic:this.foundCmp.isCosmetic,url:location.href}),this.updateState({lifecycle:"done"})):this.updateState({lifecycle:t?"optInSucceeded":"optInFailed"}),t}async doSelfTest(){const e=this.config.logs;let t;return this.foundCmp?(e.lifecycle&&console.log(`CMP ${this.foundCmp.name}: self-test on ${window.location.href}`),t=await this.foundCmp.test()):(e.errors&&console.log("no CMP to self test"),t=!1),this.sendContentMessage({type:"selfTestResult",cmp:this.foundCmp?this.foundCmp.name:"none",result:t,url:location.href}),this.updateState({selfTest:t}),t}async waitForPopup(e,t=5,o=500){const i=this.config.logs;i.lifecycle&&console.log("checking if popup is open...",e.name);const c=await e.detectPopup().catch((t=>(i.errors&&console.warn(`error detecting popup for ${e.name}`,t),!1)));return!c&&t>0?(await this.domActions.wait(o),this.waitForPopup(e,t-1,o)):(i.lifecycle&&console.log(e.name,"popup is "+(c?"open":"not open")),c)}prehideElements(){const e=this.config.logs,t=this.rules.filter((e=>e.prehideSelectors&&e.checkRunContext())).reduce(((e,t)=>[...e,...t.prehideSelectors]),["#didomi-popup,.didomi-popup-container,.didomi-popup-notice,.didomi-consent-popup-preferences,#didomi-notice,.didomi-popup-backdrop,.didomi-screen-medium"]);return this.updateState({prehideOn:!0}),setTimeout((()=>{this.config.enablePrehide&&this.state.prehideOn&&!["runningOptOut","runningOptIn"].includes(this.state.lifecycle)&&(e.lifecycle&&console.log("Process is taking too long, unhiding elements"),this.undoPrehide())}),this.config.prehideTimeout||2e3),this.domActions.prehide(t.join(","))}undoPrehide(){return this.updateState({prehideOn:!1}),this.domActions.undoPrehide()}updateState(e){Object.assign(this.state,e),this.sendContentMessage({type:"report",instanceId:this.id,url:window.location.href,mainFrame:window.top===window.self,state:this.state})}async receiveMessageCallback(e){const t=this.config?.logs;switch(t?.messages&&console.log("received from background",e,window.location.href),e.type){case"initResp":this.initialize(e.config,e.rules);break;case"optIn":await this.doOptIn();break;case"optOut":await this.doOptOut();break;case"selfTest":await this.doSelfTest();break;case"evalResp":!function(e,t){const o=r.pending.get(e);o?(r.pending.delete(e),o.timer&&window.clearTimeout(o.timer),o.resolve(t)):console.warn("no eval #",e)}(e.id,e.result)}}}((e=>{AutoconsentAndroid.process(JSON.stringify(e))}),null,x);window.autoconsentMessageCallback=e=>{O.receiveMessageCallback(e)}}(); +!function(){"use strict";var e=class e{static setBase(t){e.base=t}static findElement(t,o=null,i=!1){let n=null;return n=null!=o?Array.from(o.querySelectorAll(t.selector)):null!=e.base?Array.from(e.base.querySelectorAll(t.selector)):Array.from(document.querySelectorAll(t.selector)),null!=t.textFilter&&(n=n.filter((e=>{const o=e.textContent.toLowerCase();if(Array.isArray(t.textFilter)){let e=!1;for(const i of t.textFilter)if(-1!==o.indexOf(i.toLowerCase())){e=!0;break}return e}return null!=t.textFilter&&-1!==o.indexOf(t.textFilter.toLowerCase())}))),null!=t.styleFilters&&(n=n.filter((e=>{const o=window.getComputedStyle(e);let i=!0;for(const e of t.styleFilters){const t=o[e.option];i=e.negated?i&&t!==e.value:i&&t===e.value}return i}))),null!=t.displayFilter&&(n=n.filter((e=>t.displayFilter?0!==e.offsetHeight:0===e.offsetHeight))),null!=t.iframeFilter&&(n=n.filter((()=>t.iframeFilter?window.location!==window.parent.location:window.location===window.parent.location))),null!=t.childFilter&&(n=n.filter((o=>{const i=e.base;e.setBase(o);const n=e.find(t.childFilter);return e.setBase(i),null!=n.target}))),i?n:(n.length>1&&console.warn("Multiple possible targets: ",n,t,o),n[0])}static find(t,o=!1){const i=[];if(null!=t.parent){const n=e.findElement(t.parent,null,o);if(null!=n){if(n instanceof Array)return n.forEach((n=>{const s=e.findElement(t.target,n,o);s instanceof Array?s.forEach((e=>{i.push({parent:n,target:e})})):i.push({parent:n,target:s})})),i;{const s=e.findElement(t.target,n,o);s instanceof Array?s.forEach((e=>{i.push({parent:n,target:e})})):i.push({parent:n,target:s})}}}else{const n=e.findElement(t.target,null,o);n instanceof Array?n.forEach((e=>{i.push({parent:null,target:e})})):i.push({parent:null,target:n})}return 0===i.length&&i.push({parent:null,target:null}),o?i:(1!==i.length&&console.warn("Multiple results found, even though multiple false",i),i[0])}};e.base=null;var t=e;function o(e){const o=t.find(e);return"css"===e.type?!!o.target:"checkbox"===e.type?!!o.target&&o.target.checked:void 0}async function i(e,c){switch(e.type){case"click":return async function(e){const o=t.find(e);null!=o.target&&o.target.click();return s(n)}(e);case"list":return async function(e,t){for(const o of e.actions)await i(o,t)}(e,c);case"consent":return async function(e,t){for(const n of e.consents){const e=-1!==t.indexOf(n.type);if(n.matcher&&n.toggleAction){o(n.matcher)!==e&&await i(n.toggleAction)}else e?await i(n.trueAction):await i(n.falseAction)}}(e,c);case"ifcss":return async function(e,o){const n=t.find(e);n.target?e.falseAction&&await i(e.falseAction,o):e.trueAction&&await i(e.trueAction,o)}(e,c);case"waitcss":return async function(e){await new Promise((o=>{let i=e.retries||10;const n=e.waitTime||250,s=()=>{const c=t.find(e);(e.negated&&c.target||!e.negated&&!c.target)&&i>0?(i-=1,setTimeout(s,n)):o()};s()}))}(e);case"foreach":return async function(e,o){const n=t.find(e,!0),s=t.base;for(const s of n)s.target&&(t.setBase(s.target),await i(e.action,o));t.setBase(s)}(e,c);case"hide":return async function(e){const o=t.find(e);o.target&&o.target.classList.add("Autoconsent-Hidden")}(e);case"slide":return async function(e){const o=t.find(e),i=t.find(e.dragTarget);if(o.target){const e=o.target.getBoundingClientRect(),t=i.target.getBoundingClientRect();let n=t.top-e.top,s=t.left-e.left;"y"===this.config.axis.toLowerCase()&&(s=0),"x"===this.config.axis.toLowerCase()&&(n=0);const c=window.screenX+e.left+e.width/2,r=window.screenY+e.top+e.height/2,a=e.left+e.width/2,l=e.top+e.height/2,p=document.createEvent("MouseEvents");p.initMouseEvent("mousedown",!0,!0,window,0,c,r,a,l,!1,!1,!1,!1,0,o.target);const d=document.createEvent("MouseEvents");d.initMouseEvent("mousemove",!0,!0,window,0,c+s,r+n,a+s,l+n,!1,!1,!1,!1,0,o.target);const u=document.createEvent("MouseEvents");u.initMouseEvent("mouseup",!0,!0,window,0,c+s,r+n,a+s,l+n,!1,!1,!1,!1,0,o.target),o.target.dispatchEvent(p),await this.waitTimeout(10),o.target.dispatchEvent(d),await this.waitTimeout(10),o.target.dispatchEvent(u)}}(e);case"close":return async function(){window.close()}();case"wait":return async function(e){await s(e.waitTime)}(e);case"eval":return async function(e){return console.log("eval!",e.code),new Promise((t=>{try{e.async?(window.eval(e.code),setTimeout((()=>{t(window.eval("window.__consentCheckResult"))}),e.timeout||250)):t(window.eval(e.code))}catch(o){console.warn("eval error",o,e.code),t(!1)}}))}(e);default:throw new Error("Unknown action type: "+e.type)}}var n=0;function s(e){return new Promise((t=>{setTimeout((()=>{t()}),e)}))}function c(){return crypto&&void 0!==crypto.randomUUID?crypto.randomUUID():Math.random().toString()}var r=class{constructor(e,t=1e3){this.id=e,this.promise=new Promise(((e,t)=>{this.resolve=e,this.reject=t})),this.timer=window.setTimeout((()=>{this.reject(new Error("timeout"))}),t)}},a={pending:new Map,sendContentMessage:null};var l={EVAL_0:()=>console.log(1),EVAL_CONSENTMANAGER_1:()=>window.__cmp&&"object"==typeof __cmp("getCMPData"),EVAL_CONSENTMANAGER_2:()=>!__cmp("consentStatus").userChoiceExists,EVAL_CONSENTMANAGER_3:()=>__cmp("setConsent",0),EVAL_CONSENTMANAGER_4:()=>__cmp("setConsent",1),EVAL_CONSENTMANAGER_5:()=>__cmp("consentStatus").userChoiceExists,EVAL_COOKIEBOT_1:()=>!!window.Cookiebot,EVAL_COOKIEBOT_2:()=>!window.Cookiebot.hasResponse&&!0===window.Cookiebot.dialog?.visible,EVAL_COOKIEBOT_3:()=>window.Cookiebot.withdraw()||!0,EVAL_COOKIEBOT_4:()=>window.Cookiebot.hide()||!0,EVAL_COOKIEBOT_5:()=>!0===window.Cookiebot.declined,EVAL_KLARO_1:()=>{const e=globalThis.klaroConfig||globalThis.klaro?.getManager&&globalThis.klaro.getManager().config;if(!e)return!0;const t=(e.services||e.apps).filter((e=>!e.required)).map((e=>e.name));if(klaro&&klaro.getManager){const e=klaro.getManager();return t.every((t=>!e.consents[t]))}if(klaroConfig&&"cookie"===klaroConfig.storageMethod){const e=klaroConfig.cookieName||klaroConfig.storageName,o=JSON.parse(decodeURIComponent(document.cookie.split(";").find((t=>t.trim().startsWith(e))).split("=")[1]));return Object.keys(o).filter((e=>t.includes(e))).every((e=>!1===o[e]))}},EVAL_KLARO_OPEN_POPUP:()=>{klaro.show(void 0,!0)},EVAL_KLARO_TRY_API_OPT_OUT:()=>{if(window.klaro&&"function"==typeof klaro.show&&"function"==typeof klaro.getManager)try{return klaro.getManager().changeAll(!1),klaro.getManager().saveAndApplyConsents(),!0}catch(e){return console.warn(e),!1}return!1},EVAL_ONETRUST_1:()=>window.OnetrustActiveGroups.split(",").filter((e=>e.length>0)).length<=1,EVAL_TRUSTARC_TOP:()=>window&&window.truste&&"0"===window.truste.eu.bindMap.prefCookie,EVAL_TRUSTARC_FRAME_TEST:()=>window&&window.QueryString&&"0"===window.QueryString.preferences,EVAL_TRUSTARC_FRAME_GTM:()=>window&&window.QueryString&&"1"===window.QueryString.gtm,EVAL_ABC_TEST:()=>document.cookie.includes("trackingconsent"),EVAL_ADROLL_0:()=>!document.cookie.includes("__adroll_fpc"),EVAL_ALMACMP_0:()=>document.cookie.includes('"name":"Google","consent":false'),EVAL_AFFINITY_SERIF_COM_0:()=>document.cookie.includes("serif_manage_cookies_viewed")&&!document.cookie.includes("serif_allow_analytics"),EVAL_ARBEITSAGENTUR_TEST:()=>document.cookie.includes("cookie_consent=denied"),EVAL_AXEPTIO_0:()=>document.cookie.includes("axeptio_authorized_vendors=%2C%2C"),EVAL_BAHN_TEST:()=>1===utag.gdpr.getSelectedCategories().length,EVAL_BING_0:()=>document.cookie.includes("AD=0"),EVAL_BLOCKSY_0:()=>document.cookie.includes("blocksy_cookies_consent_accepted=no"),EVAL_BORLABS_0:()=>!JSON.parse(decodeURIComponent(document.cookie.split(";").find((e=>-1!==e.indexOf("borlabs-cookie"))).split("=",2)[1])).consents.statistics,EVAL_BUNDESREGIERUNG_DE_0:()=>document.cookie.match("cookie-allow-tracking=0"),EVAL_CANVA_0:()=>!document.cookie.includes("gtm_fpc_engagement_event"),EVAL_CC_BANNER2_0:()=>!!document.cookie.match(/sncc=[^;]+D%3Dtrue/),EVAL_CLICKIO_0:()=>document.cookie.includes("__lxG__consent__v2_daisybit="),EVAL_CLINCH_0:()=>document.cookie.includes("ctc_rejected=1"),EVAL_COOKIECONSENT2_TEST:()=>document.cookie.includes("cc_cookie="),EVAL_COOKIECONSENT3_TEST:()=>document.cookie.includes("cc_cookie="),EVAL_COINBASE_0:()=>JSON.parse(decodeURIComponent(document.cookie.match(/cm_(eu|default)_preferences=([0-9a-zA-Z\\{\\}\\[\\]%:]*);?/)[2])).consent.length<=1,EVAL_COMPLIANZ_BANNER_0:()=>document.cookie.includes("cmplz_banner-status=dismissed"),EVAL_COOKIE_LAW_INFO_0:()=>CLI.disableAllCookies()||CLI.reject_close()||!0,EVAL_COOKIE_LAW_INFO_1:()=>-1===document.cookie.indexOf("cookielawinfo-checkbox-non-necessary=yes"),EVAL_COOKIE_LAW_INFO_DETECT:()=>!!window.CLI,EVAL_COOKIE_MANAGER_POPUP_0:()=>!1===JSON.parse(document.cookie.split(";").find((e=>e.trim().startsWith("CookieLevel"))).split("=")[1]).social,EVAL_COOKIEALERT_0:()=>document.querySelector("body").removeAttribute("style")||!0,EVAL_COOKIEALERT_1:()=>document.querySelector("body").removeAttribute("style")||!0,EVAL_COOKIEALERT_2:()=>!0===window.CookieConsent.declined,EVAL_COOKIEFIRST_0:()=>{return!1===(e=JSON.parse(decodeURIComponent(document.cookie.split(";").find((e=>-1!==e.indexOf("cookiefirst"))).trim()).split("=")[1])).performance&&!1===e.functional&&!1===e.advertising;var e},EVAL_COOKIEFIRST_1:()=>document.querySelectorAll("button[data-cookiefirst-accent-color=true][role=checkbox]:not([disabled])").forEach((e=>"true"===e.getAttribute("aria-checked")&&e.click()))||!0,EVAL_COOKIEINFORMATION_0:()=>CookieInformation.declineAllCategories()||!0,EVAL_COOKIEINFORMATION_1:()=>CookieInformation.submitAllCategories()||!0,EVAL_COOKIEINFORMATION_2:()=>document.cookie.includes("CookieInformationConsent="),EVAL_COOKIEYES_0:()=>document.cookie.includes("advertisement:no"),EVAL_DAILYMOTION_0:()=>!!document.cookie.match("dm-euconsent-v2"),EVAL_DNDBEYOND_TEST:()=>document.cookie.includes("cookie-consent=denied"),EVAL_DSGVO_0:()=>!document.cookie.includes("sp_dsgvo_cookie_settings"),EVAL_DUNELM_0:()=>document.cookie.includes("cc_functional=0")&&document.cookie.includes("cc_targeting=0"),EVAL_ETSY_0:()=>document.querySelectorAll(".gdpr-overlay-body input").forEach((e=>{e.checked=!1}))||!0,EVAL_ETSY_1:()=>document.querySelector(".gdpr-overlay-view button[data-wt-overlay-close]").click()||!0,EVAL_EU_COOKIE_COMPLIANCE_0:()=>-1===document.cookie.indexOf("cookie-agreed=2"),EVAL_EU_COOKIE_LAW_0:()=>!document.cookie.includes("euCookie"),EVAL_EZOIC_0:()=>ezCMP.handleAcceptAllClick(),EVAL_EZOIC_1:()=>!!document.cookie.match(/ez-consent-tcf/),EVAL_FIDES_DETECT_POPUP:()=>window.Fides?.initialized,EVAL_GOOGLE_0:()=>!!document.cookie.match(/SOCS=CAE/),EVAL_HEMA_TEST_0:()=>document.cookie.includes("cookies_rejected=1"),EVAL_IUBENDA_0:()=>document.querySelectorAll(".purposes-item input[type=checkbox]:not([disabled])").forEach((e=>{e.checked&&e.click()}))||!0,EVAL_IUBENDA_1:()=>!!document.cookie.match(/_iub_cs-\d+=/),EVAL_IWINK_TEST:()=>document.cookie.includes("cookie_permission_granted=no"),EVAL_JQUERY_COOKIEBAR_0:()=>!document.cookie.includes("cookies-state=accepted"),EVAL_KETCH_TEST:()=>document.cookie.includes("_ketch_consent_v1_"),EVAL_MEDIAVINE_0:()=>document.querySelectorAll('[data-name="mediavine-gdpr-cmp"] input[type=checkbox]').forEach((e=>e.checked&&e.click()))||!0,EVAL_MICROSOFT_0:()=>Array.from(document.querySelectorAll("div > button")).filter((e=>e.innerText.match("Reject|Ablehnen")))[0].click()||!0,EVAL_MICROSOFT_1:()=>Array.from(document.querySelectorAll("div > button")).filter((e=>e.innerText.match("Accept|Annehmen")))[0].click()||!0,EVAL_MICROSOFT_2:()=>!!document.cookie.match("MSCC|GHCC"),EVAL_MOOVE_0:()=>document.querySelectorAll("#moove_gdpr_cookie_modal input").forEach((e=>{e.disabled||(e.checked="moove_gdpr_strict_cookies"===e.name||"moove_gdpr_strict_cookies"===e.id)}))||!0,EVAL_ONENINETWO_0:()=>document.cookie.includes("CC_ADVERTISING=NO")&&document.cookie.includes("CC_ANALYTICS=NO"),EVAL_OPENAI_TEST:()=>document.cookie.includes("oai-allow-ne=false"),EVAL_OPERA_0:()=>document.cookie.includes("cookie_consent_essential=true")&&!document.cookie.includes("cookie_consent_marketing=true"),EVAL_PAYPAL_0:()=>!0===document.cookie.includes("cookie_prefs"),EVAL_PRIMEBOX_0:()=>!document.cookie.includes("cb-enabled=accepted"),EVAL_PUBTECH_0:()=>document.cookie.includes("euconsent-v2")&&(document.cookie.match(/.YAAAAAAAAAAA/)||document.cookie.match(/.aAAAAAAAAAAA/)||document.cookie.match(/.YAAACFgAAAAA/)),EVAL_REDDIT_0:()=>document.cookie.includes("eu_cookie={%22opted%22:true%2C%22nonessential%22:false}"),EVAL_ROBLOX_TEST:()=>document.cookie.includes("RBXcb"),EVAL_SKYSCANNER_TEST:()=>document.cookie.match(/gdpr=[^;]*adverts:::false/)&&!document.cookie.match(/gdpr=[^;]*init:::true/),EVAL_SIRDATA_UNBLOCK_SCROLL:()=>(document.documentElement.classList.forEach((e=>{e.startsWith("sd-cmp-")&&document.documentElement.classList.remove(e)})),!0),EVAL_SNIGEL_0:()=>!!document.cookie.match("snconsent"),EVAL_STEAMPOWERED_0:()=>2===JSON.parse(decodeURIComponent(document.cookie.split(";").find((e=>e.trim().startsWith("cookieSettings"))).split("=")[1])).preference_state,EVAL_SVT_TEST:()=>document.cookie.includes('cookie-consent-1={"optedIn":true,"functionality":false,"statistics":false}'),EVAL_TAKEALOT_0:()=>document.body.classList.remove("freeze")||(document.body.style="")||!0,EVAL_TARTEAUCITRON_0:()=>tarteaucitron.userInterface.respondAll(!1)||!0,EVAL_TARTEAUCITRON_1:()=>tarteaucitron.userInterface.respondAll(!0)||!0,EVAL_TARTEAUCITRON_2:()=>document.cookie.match(/tarteaucitron=[^;]*/)?.[0].includes("false"),EVAL_TAUNTON_TEST:()=>document.cookie.includes("taunton_user_consent_submitted=true"),EVAL_TEALIUM_0:()=>void 0!==window.utag&&"object"==typeof utag.gdpr,EVAL_TEALIUM_1:()=>utag.gdpr.setConsentValue(!1)||!0,EVAL_TEALIUM_DONOTSELL:()=>utag.gdpr.dns?.setDnsState(!1)||!0,EVAL_TEALIUM_2:()=>utag.gdpr.setConsentValue(!0)||!0,EVAL_TEALIUM_3:()=>1!==utag.gdpr.getConsentState(),EVAL_TEALIUM_DONOTSELL_CHECK:()=>1!==utag.gdpr.dns?.getDnsState(),EVAL_TESLA_TEST:()=>document.cookie.includes("tsla-cookie-consent=rejected"),EVAL_TESTCMP_STEP:()=>!!document.querySelector("#reject-all"),EVAL_TESTCMP_0:()=>"button_clicked"===window.results.results[0],EVAL_TESTCMP_COSMETIC_0:()=>"banner_hidden"===window.results.results[0],EVAL_THEFREEDICTIONARY_0:()=>cmpUi.showPurposes()||cmpUi.rejectAll()||!0,EVAL_THEFREEDICTIONARY_1:()=>cmpUi.allowAll()||!0,EVAL_THEVERGE_0:()=>document.cookie.includes("_duet_gdpr_acknowledged=1"),EVAL_TWCC_TEST:()=>document.cookie.includes("twCookieConsent="),EVAL_UBUNTU_COM_0:()=>document.cookie.includes("_cookies_accepted=essential"),EVAL_UK_COOKIE_CONSENT_0:()=>!document.cookie.includes("catAccCookies"),EVAL_USERCENTRICS_API_0:()=>"object"==typeof UC_UI,EVAL_USERCENTRICS_API_1:()=>!!UC_UI.closeCMP(),EVAL_USERCENTRICS_API_2:()=>!!UC_UI.denyAllConsents(),EVAL_USERCENTRICS_API_3:()=>!!UC_UI.acceptAllConsents(),EVAL_USERCENTRICS_API_4:()=>!!UC_UI.closeCMP(),EVAL_USERCENTRICS_API_5:()=>!0===UC_UI.areAllConsentsAccepted(),EVAL_USERCENTRICS_API_6:()=>!1===UC_UI.areAllConsentsAccepted(),EVAL_USERCENTRICS_BUTTON_0:()=>JSON.parse(localStorage.getItem("usercentrics")).consents.every((e=>e.isEssential||!e.consentStatus)),EVAL_WAITROSE_0:()=>Array.from(document.querySelectorAll("label[id$=cookies-deny-label]")).forEach((e=>e.click()))||!0,EVAL_WAITROSE_1:()=>document.cookie.includes("wtr_cookies_advertising=0")&&document.cookie.includes("wtr_cookies_analytics=0"),EVAL_WP_COOKIE_NOTICE_0:()=>document.cookie.includes("wpl_viewed_cookie=no"),EVAL_XE_TEST:()=>document.cookie.includes("xeConsentState={%22performance%22:false%2C%22marketing%22:false%2C%22compliance%22:false}"),EVAL_XING_0:()=>document.cookie.includes("userConsent=%7B%22marketing%22%3Afalse"),EVAL_YOUTUBE_DESKTOP_0:()=>!!document.cookie.match(/SOCS=CAE/),EVAL_YOUTUBE_MOBILE_0:()=>!!document.cookie.match(/SOCS=CAE/)};var p={main:!0,frame:!1,urlPattern:""},d=class{constructor(e){this.runContext=p,this.autoconsent=e}get hasSelfTest(){throw new Error("Not Implemented")}get isIntermediate(){throw new Error("Not Implemented")}get isCosmetic(){throw new Error("Not Implemented")}mainWorldEval(e){const t=l[e];if(!t)return console.warn("Snippet not found",e),Promise.resolve(!1);const o=this.autoconsent.config.logs;if(this.autoconsent.config.isMainWorld){o.evals&&console.log("inline eval:",e,t);let i=!1;try{i=!!t.call(globalThis)}catch(t){o.evals&&console.error("error evaluating rule",e,t)}return Promise.resolve(i)}const i=`(${t.toString()})()`;return o.evals&&console.log("async eval:",e,i),function(e,t){const o=c();a.sendContentMessage({type:"eval",id:o,code:e,snippetId:t});const i=new r(o);return a.pending.set(i.id,i),i.promise}(i,e).catch((t=>(o.evals&&console.error("error evaluating rule",e,t),!1)))}checkRunContext(){const e={...p,...this.runContext},t=window.top===window;return!(t&&!e.main)&&(!(!t&&!e.frame)&&!(e.urlPattern&&!window.location.href.match(e.urlPattern)))}detectCmp(){throw new Error("Not Implemented")}async detectPopup(){return!1}optOut(){throw new Error("Not Implemented")}optIn(){throw new Error("Not Implemented")}openCmp(){throw new Error("Not Implemented")}async test(){return Promise.resolve(!0)}click(e,t=!1){return this.autoconsent.domActions.click(e,t)}elementExists(e){return this.autoconsent.domActions.elementExists(e)}elementVisible(e,t){return this.autoconsent.domActions.elementVisible(e,t)}waitForElement(e,t){return this.autoconsent.domActions.waitForElement(e,t)}waitForVisible(e,t,o){return this.autoconsent.domActions.waitForVisible(e,t,o)}waitForThenClick(e,t,o){return this.autoconsent.domActions.waitForThenClick(e,t,o)}wait(e){return this.autoconsent.domActions.wait(e)}hide(e,t){return this.autoconsent.domActions.hide(e,t)}prehide(e){return this.autoconsent.domActions.prehide(e)}undoPrehide(){return this.autoconsent.domActions.undoPrehide()}querySingleReplySelector(e,t){return this.autoconsent.domActions.querySingleReplySelector(e,t)}querySelectorChain(e){return this.autoconsent.domActions.querySelectorChain(e)}elementSelector(e){return this.autoconsent.domActions.elementSelector(e)}},u=class extends d{constructor(e,t){super(t),this.rule=e,this.name=e.name,this.runContext=e.runContext||p}get hasSelfTest(){return!!this.rule.test}get isIntermediate(){return!!this.rule.intermediate}get isCosmetic(){return!!this.rule.cosmetic}get prehideSelectors(){return this.rule.prehideSelectors}async detectCmp(){return!!this.rule.detectCmp&&this._runRulesParallel(this.rule.detectCmp)}async detectPopup(){return!!this.rule.detectPopup&&this._runRulesSequentially(this.rule.detectPopup)}async optOut(){const e=this.autoconsent.config.logs;return!!this.rule.optOut&&(e.lifecycle&&console.log("Initiated optOut()",this.rule.optOut),this._runRulesSequentially(this.rule.optOut))}async optIn(){const e=this.autoconsent.config.logs;return!!this.rule.optIn&&(e.lifecycle&&console.log("Initiated optIn()",this.rule.optIn),this._runRulesSequentially(this.rule.optIn))}async openCmp(){return!!this.rule.openCmp&&this._runRulesSequentially(this.rule.openCmp)}async test(){return this.hasSelfTest?this._runRulesSequentially(this.rule.test):super.test()}async evaluateRuleStep(e){const t=[],o=this.autoconsent.config.logs;if(e.exists&&t.push(this.elementExists(e.exists)),e.visible&&t.push(this.elementVisible(e.visible,e.check)),e.eval){const o=this.mainWorldEval(e.eval);t.push(o)}if(e.waitFor&&t.push(this.waitForElement(e.waitFor,e.timeout)),e.waitForVisible&&t.push(this.waitForVisible(e.waitForVisible,e.timeout,e.check)),e.click&&t.push(this.click(e.click,e.all)),e.waitForThenClick&&t.push(this.waitForThenClick(e.waitForThenClick,e.timeout,e.all)),e.wait&&t.push(this.wait(e.wait)),e.hide&&t.push(this.hide(e.hide,e.method)),e.if){if(!e.if.exists&&!e.if.visible)return console.error("invalid conditional rule",e.if),!1;const i=await this.evaluateRuleStep(e.if);o.rulesteps&&console.log("Condition is",i),i?t.push(this._runRulesSequentially(e.then)):e.else?t.push(this._runRulesSequentially(e.else)):t.push(!0)}if(e.any){for(const t of e.any)if(await this.evaluateRuleStep(t))return!0;return!1}if(0===t.length)return o.errors&&console.warn("Unrecognized rule",e),!1;return(await Promise.all(t)).reduce(((e,t)=>e&&t),!0)}async _runRulesParallel(e){const t=e.map((e=>this.evaluateRuleStep(e)));return(await Promise.all(t)).every((e=>!!e))}async _runRulesSequentially(e){const t=this.autoconsent.config.logs;for(const o of e){t.rulesteps&&console.log("Running rule...",o);const e=await this.evaluateRuleStep(o);if(t.rulesteps&&console.log("...rule result",e),!e&&!o.optional)return!1}return!0}},h=class{constructor(e,t){this.name=e,this.config=t,this.methods=new Map,this.runContext=p,this.isCosmetic=!1,t.methods.forEach((e=>{e.action&&this.methods.set(e.name,e.action)})),this.hasSelfTest=!1}get isIntermediate(){return!1}checkRunContext(){return!0}async detectCmp(){return this.config.detectors.map((e=>o(e.presentMatcher))).some((e=>!!e))}async detectPopup(){return this.config.detectors.map((e=>o(e.showingMatcher))).some((e=>!!e))}async executeAction(e,t){return!this.methods.has(e)||i(this.methods.get(e),t)}async optOut(){return await this.executeAction("HIDE_CMP"),await this.executeAction("OPEN_OPTIONS"),await this.executeAction("HIDE_CMP"),await this.executeAction("DO_CONSENT",[]),await this.executeAction("SAVE_CONSENT"),!0}async optIn(){return await this.executeAction("HIDE_CMP"),await this.executeAction("OPEN_OPTIONS"),await this.executeAction("HIDE_CMP"),await this.executeAction("DO_CONSENT",["D","A","B","E","F","X"]),await this.executeAction("SAVE_CONSENT"),!0}async openCmp(){return await this.executeAction("HIDE_CMP"),await this.executeAction("OPEN_OPTIONS"),!0}async test(){return!0}};function m(e="autoconsent-css-rules"){const t=`style#${e}`,o=document.querySelector(t);if(o&&o instanceof HTMLStyleElement)return o;{const t=document.head||document.getElementsByTagName("head")[0]||document.documentElement,o=document.createElement("style");return o.id=e,t.appendChild(o),o}}function A(e){return`${"opacity"===e?"opacity: 0":"display: none"} !important; z-index: -1 !important; pointer-events: none !important;`}function g(e,t,o="display"){const i=`${t} { ${A(o)} } `;return e instanceof HTMLStyleElement&&(e.innerText+=i,t.length>0)}async function f(e,t,o){const i=await e();return!i&&t>0?new Promise((i=>{setTimeout((async()=>{i(f(e,t-1,o))}),o)})):Promise.resolve(i)}function k(e){if(!e)return!1;if(null!==e.offsetParent)return!0;{const t=window.getComputedStyle(e);if("fixed"===t.position&&"none"!==t.display)return!0}return!1}function b(e){const t={enabled:!0,autoAction:"optOut",disabledCmps:[],enablePrehide:!0,enableCosmeticRules:!0,detectRetries:20,isMainWorld:!1,prehideTimeout:2e3,enableFilterList:!1,logs:{lifecycle:!1,rulesteps:!1,evals:!1,errors:!0,messages:!1}},o=(i=t,globalThis.structuredClone?structuredClone(i):JSON.parse(JSON.stringify(i)));var i;for(const i of Object.keys(t))void 0!==e[i]&&(o[i]=e[i]);return o}var y="#truste-show-consent",w="#truste-consent-track",v=[class extends d{constructor(e){super(e),this.name="TrustArc-top",this.prehideSelectors=[".trustarc-banner-container",`.truste_popframe,.truste_overlay,.truste_box_overlay,${w}`],this.runContext={main:!0,frame:!1},this._shortcutButton=null,this._optInDone=!1}get hasSelfTest(){return!0}get isIntermediate(){return!this._optInDone&&!this._shortcutButton}get isCosmetic(){return!1}async detectCmp(){const e=this.elementExists(`${y},${w}`);return e&&(this._shortcutButton=document.querySelector("#truste-consent-required")),e}async detectPopup(){return this.elementVisible(`#truste-consent-content,#trustarc-banner-overlay,${w}`,"any")}openFrame(){this.click(y)}async optOut(){return this._shortcutButton?(this._shortcutButton.click(),!0):(g(m(),`.truste_popframe, .truste_overlay, .truste_box_overlay, ${w}`),this.click(y),setTimeout((()=>{m().remove()}),1e4),!0)}async optIn(){return this._optInDone=!0,this.click("#truste-consent-button")}async openCmp(){return!0}async test(){return await this.wait(500),await this.mainWorldEval("EVAL_TRUSTARC_TOP")}},class extends d{constructor(){super(...arguments),this.name="TrustArc-frame",this.runContext={main:!1,frame:!0,urlPattern:"^https://consent-pref\\.trustarc\\.com/\\?"}}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return!0}async detectPopup(){return this.elementVisible("#defaultpreferencemanager","any")&&this.elementVisible(".mainContent","any")}async navigateToSettings(){return await f((async()=>this.elementExists(".shp")||this.elementVisible(".advance","any")||this.elementExists(".switch span:first-child")),10,500),this.elementExists(".shp")&&this.click(".shp"),await this.waitForElement(".prefPanel",5e3),this.elementVisible(".advance","any")&&this.click(".advance"),await f((()=>this.elementVisible(".switch span:first-child","any")),5,1e3)}async optOut(){if(await this.mainWorldEval("EVAL_TRUSTARC_FRAME_TEST"))return!0;let e=3e3;return await this.mainWorldEval("EVAL_TRUSTARC_FRAME_GTM")&&(e=1500),await f((()=>"complete"===document.readyState),20,100),await this.waitForElement(".mainContent[aria-hidden=false]",e),!!this.click(".rejectAll")||(this.elementExists(".prefPanel")&&await this.waitForElement('.prefPanel[style="visibility: visible;"]',e),this.click("#catDetails0")?(this.click(".submit"),this.waitForThenClick("#gwt-debug-close_id",e),!0):this.click(".required")?(this.waitForThenClick("#gwt-debug-close_id",e),!0):(await this.navigateToSettings(),this.click(".switch span:nth-child(1):not(.active)",!0),this.click(".submit"),this.waitForThenClick("#gwt-debug-close_id",10*e),!0))}async optIn(){return this.click(".call")||(await this.navigateToSettings(),this.click(".switch span:nth-child(2)",!0),this.click(".submit"),this.waitForElement("#gwt-debug-close_id",3e5).then((()=>{this.click("#gwt-debug-close_id")}))),!0}async test(){return await this.wait(500),await this.mainWorldEval("EVAL_TRUSTARC_FRAME_TEST")}},class extends d{constructor(){super(...arguments),this.name="Cybotcookiebot",this.prehideSelectors=["#CybotCookiebotDialog,#CybotCookiebotDialogBodyUnderlay,#dtcookie-container,#cookiebanner,#cb-cookieoverlay,.modal--cookie-banner,#cookiebanner_outer,#CookieBanner"]}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return await this.mainWorldEval("EVAL_COOKIEBOT_1")}async detectPopup(){return this.mainWorldEval("EVAL_COOKIEBOT_2")}async optOut(){await this.wait(500);let e=await this.mainWorldEval("EVAL_COOKIEBOT_3");return await this.wait(500),e=e&&await this.mainWorldEval("EVAL_COOKIEBOT_4"),e}async optIn(){return this.elementExists("#dtcookie-container")?this.click(".h-dtcookie-accept"):(this.click(".CybotCookiebotDialogBodyLevelButton:not(:checked):enabled",!0),this.click("#CybotCookiebotDialogBodyLevelButtonAccept"),this.click("#CybotCookiebotDialogBodyButtonAccept"),!0)}async test(){return await this.wait(500),await this.mainWorldEval("EVAL_COOKIEBOT_5")}},class extends d{constructor(){super(...arguments),this.name="Sourcepoint-frame",this.prehideSelectors=["div[id^='sp_message_container_'],.message-overlay","#sp_privacy_manager_container"],this.ccpaNotice=!1,this.ccpaPopup=!1,this.runContext={main:!0,frame:!0}}get hasSelfTest(){return!1}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){const e=new URL(location.href);return e.searchParams.has("message_id")&&"ccpa-notice.sp-prod.net"===e.hostname?(this.ccpaNotice=!0,!0):"ccpa-pm.sp-prod.net"===e.hostname?(this.ccpaPopup=!0,!0):("/index.html"===e.pathname||"/privacy-manager/index.html"===e.pathname||"/ccpa_pm/index.html"===e.pathname)&&(e.searchParams.has("message_id")||e.searchParams.has("requestUUID")||e.searchParams.has("consentUUID"))}async detectPopup(){return!!this.ccpaNotice||(this.ccpaPopup?await this.waitForElement(".priv-save-btn",2e3):(await this.waitForElement(".sp_choice_type_11,.sp_choice_type_12,.sp_choice_type_13,.sp_choice_type_ACCEPT_ALL,.sp_choice_type_SAVE_AND_EXIT",2e3),!this.elementExists(".sp_choice_type_9")))}async optIn(){return await this.waitForElement(".sp_choice_type_11,.sp_choice_type_ACCEPT_ALL",2e3),!!this.click(".sp_choice_type_11")||!!this.click(".sp_choice_type_ACCEPT_ALL")}isManagerOpen(){return"/privacy-manager/index.html"===location.pathname||"/ccpa_pm/index.html"===location.pathname}async optOut(){const e=this.autoconsent.config.logs;if(this.ccpaPopup){const e=document.querySelectorAll(".priv-purpose-container .sp-switch-arrow-block a.neutral.on .right");for(const t of e)t.click();const t=document.querySelectorAll(".priv-purpose-container .sp-switch-arrow-block a.switch-bg.on");for(const e of t)e.click();return this.click(".priv-save-btn")}if(!this.isManagerOpen()){if(!await this.waitForElement(".sp_choice_type_12,.sp_choice_type_13"))return!1;if(!this.elementExists(".sp_choice_type_12"))return this.click(".sp_choice_type_13");this.click(".sp_choice_type_12"),await f((()=>this.isManagerOpen()),200,100)}await this.waitForElement(".type-modal",2e4),this.waitForThenClick(".ccpa-stack .pm-switch[aria-checked=true] .slider",500,!0);try{const e=".sp_choice_type_REJECT_ALL",t=".reject-toggle",o=await Promise.race([this.waitForElement(e,2e3).then((e=>e?0:-1)),this.waitForElement(t,2e3).then((e=>e?1:-1)),this.waitForElement(".pm-features",2e3).then((e=>e?2:-1))]);if(0===o)return await this.waitForVisible(e),this.click(e);1===o?this.click(t):2===o&&(await this.waitForElement(".pm-features",1e4),this.click(".checked > span",!0),this.click(".chevron"))}catch(t){e.errors&&console.warn(t)}return this.click(".sp_choice_type_SAVE_AND_EXIT")}},class extends d{constructor(){super(...arguments),this.name="consentmanager.net",this.prehideSelectors=["#cmpbox,#cmpbox2"],this.apiAvailable=!1}get hasSelfTest(){return this.apiAvailable}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.apiAvailable=await this.mainWorldEval("EVAL_CONSENTMANAGER_1"),!!this.apiAvailable||this.elementExists("#cmpbox")}async detectPopup(){return this.apiAvailable?(await this.wait(500),await this.mainWorldEval("EVAL_CONSENTMANAGER_2")):this.elementVisible("#cmpbox .cmpmore","any")}async optOut(){return await this.wait(500),this.apiAvailable?await this.mainWorldEval("EVAL_CONSENTMANAGER_3"):!!this.click(".cmpboxbtnno")||(this.elementExists(".cmpwelcomeprpsbtn")?(this.click(".cmpwelcomeprpsbtn > a[aria-checked=true]",!0),this.click(".cmpboxbtnsave"),!0):(this.click(".cmpboxbtncustom"),await this.waitForElement(".cmptblbox",2e3),this.click(".cmptdchoice > a[aria-checked=true]",!0),this.click(".cmpboxbtnyescustomchoices"),this.hide("#cmpwrapper,#cmpbox","display"),!0))}async optIn(){return this.apiAvailable?await this.mainWorldEval("EVAL_CONSENTMANAGER_4"):this.click(".cmpboxbtnyes")}async test(){if(this.apiAvailable)return await this.mainWorldEval("EVAL_CONSENTMANAGER_5")}},class extends d{constructor(){super(...arguments),this.name="Evidon"}get hasSelfTest(){return!1}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists("#_evidon_banner")}async detectPopup(){return this.elementVisible("#_evidon_banner","any")}async optOut(){return this.click("#_evidon-decline-button")||(g(m(),"#evidon-prefdiag-overlay,#evidon-prefdiag-background,#_evidon-background"),await this.waitForThenClick("#_evidon-option-button"),await this.waitForElement("#evidon-prefdiag-overlay",5e3),await this.wait(500),await this.waitForThenClick("#evidon-prefdiag-decline")),!0}async optIn(){return this.click("#_evidon-accept-button")}},class extends d{constructor(){super(...arguments),this.name="Onetrust",this.prehideSelectors=["#onetrust-banner-sdk,#onetrust-consent-sdk,.onetrust-pc-dark-filter,.js-consent-banner"],this.runContext={urlPattern:"^(?!.*https://www\\.nba\\.com/)"}}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists("#onetrust-banner-sdk,#onetrust-pc-sdk")}async detectPopup(){return this.elementVisible("#onetrust-banner-sdk,#onetrust-pc-sdk","any")}async optOut(){return this.elementVisible("#onetrust-reject-all-handler,.ot-pc-refuse-all-handler,.js-reject-cookies","any")?this.click("#onetrust-reject-all-handler,.ot-pc-refuse-all-handler,.js-reject-cookies"):(this.elementExists("#onetrust-pc-btn-handler")?this.click("#onetrust-pc-btn-handler"):this.click(".ot-sdk-show-settings,button.js-cookie-settings"),await this.waitForElement("#onetrust-consent-sdk",2e3),await this.wait(1e3),this.click("#onetrust-consent-sdk input.category-switch-handler:checked,.js-editor-toggle-state:checked",!0),await this.wait(1e3),await this.waitForElement(".save-preference-btn-handler,.js-consent-save",2e3),this.click(".save-preference-btn-handler,.js-consent-save"),await this.waitForVisible("#onetrust-banner-sdk",5e3,"none"),!0)}async optIn(){return this.click("#onetrust-accept-btn-handler,#accept-recommended-btn-handler,.js-accept-cookies")}async test(){return await f((()=>this.mainWorldEval("EVAL_ONETRUST_1")),10,500)}},class extends d{constructor(){super(...arguments),this.name="Klaro",this.prehideSelectors=[".klaro"],this.settingsOpen=!1}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists(".klaro > .cookie-modal")?(this.settingsOpen=!0,!0):this.elementExists(".klaro > .cookie-notice")}async detectPopup(){return this.elementVisible(".klaro > .cookie-notice,.klaro > .cookie-modal","any")}async optOut(){return!!await this.mainWorldEval("EVAL_KLARO_TRY_API_OPT_OUT")||(!!this.click(".klaro .cn-decline")||(await this.mainWorldEval("EVAL_KLARO_OPEN_POPUP"),!!this.click(".klaro .cn-decline")||(this.click(".cm-purpose:not(.cm-toggle-all) > input:not(.half-checked,.required,.only-required),.cm-purpose:not(.cm-toggle-all) > div > input:not(.half-checked,.required,.only-required)",!0),this.click(".cm-btn-accept,.cm-button"))))}async optIn(){return!!this.click(".klaro .cm-btn-accept-all")||(this.settingsOpen?(this.click(".cm-purpose:not(.cm-toggle-all) > input.half-checked",!0),this.click(".cm-btn-accept")):this.click(".klaro .cookie-notice .cm-btn-success"))}async test(){return await this.mainWorldEval("EVAL_KLARO_1")}},class extends d{constructor(){super(...arguments),this.name="Uniconsent"}get prehideSelectors(){return[".unic",".modal:has(.unic)"]}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists(".unic .unic-box,.unic .unic-bar,.unic .unic-modal")}async detectPopup(){return this.elementVisible(".unic .unic-box,.unic .unic-bar,.unic .unic-modal","any")}async optOut(){if(await this.waitForElement(".unic button",1e3),document.querySelectorAll(".unic button").forEach((e=>{const t=e.textContent;(t.includes("Manage Options")||t.includes("Optionen verwalten"))&&e.click()})),await this.waitForElement(".unic input[type=checkbox]",1e3)){await this.waitForElement(".unic button",1e3),document.querySelectorAll(".unic input[type=checkbox]").forEach((e=>{e.checked&&e.click()}));for(const e of document.querySelectorAll(".unic button")){const t=e.textContent;for(const o of["Confirm Choices","Save Choices","Auswahl speichern"])if(t.includes(o))return e.click(),await this.wait(500),!0}}return!1}async optIn(){return this.waitForThenClick(".unic #unic-agree")}async test(){await this.wait(1e3);return!this.elementExists(".unic .unic-box,.unic .unic-bar")}},class extends d{constructor(){super(...arguments),this.prehideSelectors=[".cmp-root"],this.name="Conversant"}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists(".cmp-root .cmp-receptacle")}async detectPopup(){return this.elementVisible(".cmp-root .cmp-receptacle","any")}async optOut(){if(!await this.waitForThenClick(".cmp-main-button:not(.cmp-main-button--primary)"))return!1;if(!await this.waitForElement(".cmp-view-tab-tabs"))return!1;await this.waitForThenClick(".cmp-view-tab-tabs > :first-child"),await this.waitForThenClick(".cmp-view-tab-tabs > .cmp-view-tab--active:first-child");for(const e of Array.from(document.querySelectorAll(".cmp-accordion-item"))){e.querySelector(".cmp-accordion-item-title").click(),await f((()=>!!e.querySelector(".cmp-accordion-item-content.cmp-active")),10,50);const t=e.querySelector(".cmp-accordion-item-content.cmp-active");t.querySelectorAll(".cmp-toggle-actions .cmp-toggle-deny:not(.cmp-toggle-deny--active)").forEach((e=>e.click())),t.querySelectorAll(".cmp-toggle-actions .cmp-toggle-checkbox:not(.cmp-toggle-checkbox--active)").forEach((e=>e.click()))}return await this.click(".cmp-main-button:not(.cmp-main-button--primary)"),!0}async optIn(){return this.waitForThenClick(".cmp-main-button.cmp-main-button--primary")}async test(){return document.cookie.includes("cmp-data=0")}},class extends d{constructor(){super(...arguments),this.name="tiktok.com",this.runContext={urlPattern:"tiktok"}}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}getShadowRoot(){const e=document.querySelector("tiktok-cookie-banner");return e?e.shadowRoot:null}async detectCmp(){return this.elementExists("tiktok-cookie-banner")}async detectPopup(){return k(this.getShadowRoot().querySelector(".tiktok-cookie-banner"))}async optOut(){const e=this.autoconsent.config.logs,t=this.getShadowRoot().querySelector(".button-wrapper button:first-child");return t?(e.rulesteps&&console.log("[clicking]",t),t.click(),!0):(e.errors&&console.log("no decline button found"),!1)}async optIn(){const e=this.autoconsent.config.logs,t=this.getShadowRoot().querySelector(".button-wrapper button:last-child");return t?(e.rulesteps&&console.log("[clicking]",t),t.click(),!0):(e.errors&&console.log("no accept button found"),!1)}async test(){const e=document.cookie.match(/cookie-consent=([^;]+)/);if(!e)return!1;const t=JSON.parse(decodeURIComponent(e[1]));return Object.values(t).every((e=>"boolean"!=typeof e||!1===e))}},class extends d{constructor(){super(...arguments),this.name="airbnb",this.runContext={urlPattern:"^https://(www\\.)?airbnb\\.[^/]+/"},this.prehideSelectors=["div[data-testid=main-cookies-banner-container]",'div:has(> div:first-child):has(> div:last-child):has(> section [data-testid="strictly-necessary-cookies"])']}get hasSelfTest(){return!0}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists("div[data-testid=main-cookies-banner-container]")}async detectPopup(){return this.elementVisible("div[data-testid=main-cookies-banner-container","any")}async optOut(){let e;for(await this.waitForThenClick("div[data-testid=main-cookies-banner-container] button._snbhip0");e=document.querySelector("[data-testid=modal-container] button[aria-checked=true]:not([disabled])");)e.click();return this.waitForThenClick("button[data-testid=save-btn]")}async optIn(){return this.waitForThenClick("div[data-testid=main-cookies-banner-container] button._148dgdpk")}async test(){return await f((()=>!!document.cookie.match("OptanonAlertBoxClosed")),20,200)}},class extends d{constructor(){super(...arguments),this.name="tumblr-com",this.runContext={urlPattern:"^https://(www\\.)?tumblr\\.com/"}}get hasSelfTest(){return!1}get isIntermediate(){return!1}get isCosmetic(){return!1}get prehideSelectors(){return["#cmp-app-container"]}async detectCmp(){return this.elementExists("#cmp-app-container")}async detectPopup(){return this.elementVisible("#cmp-app-container","any")}async optOut(){let e=document.querySelector("#cmp-app-container iframe"),t=e.contentDocument?.querySelector(".cmp-components-button.is-secondary");return!!t&&(t.click(),await f((()=>{const e=document.querySelector("#cmp-app-container iframe");return!!e.contentDocument?.querySelector(".cmp__dialog input")}),5,500),e=document.querySelector("#cmp-app-container iframe"),t=e.contentDocument?.querySelector(".cmp-components-button.is-secondary"),!!t&&(t.click(),!0))}async optIn(){const e=document.querySelector("#cmp-app-container iframe").contentDocument.querySelector(".cmp-components-button.is-primary");return!!e&&(e.click(),!0)}},class extends d{constructor(){super(...arguments),this.name="Admiral"}get hasSelfTest(){return!1}get isIntermediate(){return!1}get isCosmetic(){return!1}async detectCmp(){return this.elementExists("div > div[class*=Card] > div[class*=Frame] > div[class*=Pills] > button[class*=Pills__StyledPill]")}async detectPopup(){return this.elementVisible("div > div[class*=Card] > div[class*=Frame] > div[class*=Pills] > button[class*=Pills__StyledPill]","any")}async optOut(){const e="xpath///button[contains(., 'Afvis alle') or contains(., 'Reject all') or contains(., 'Odbaci sve') or contains(., 'Rechazar todo') or contains(., 'Atmesti visus') or contains(., 'Odmítnout vše') or contains(., 'Απόρριψη όλων') or contains(., 'Rejeitar tudo') or contains(., 'Tümünü reddet') or contains(., 'Отклонить все') or contains(., 'Noraidīt visu') or contains(., 'Avvisa alla') or contains(., 'Odrzuć wszystkie') or contains(., 'Alles afwijzen') or contains(., 'Отхвърляне на всички') or contains(., 'Rifiuta tutto') or contains(., 'Zavrni vse') or contains(., 'Az összes elutasítása') or contains(., 'Respingeți tot') or contains(., 'Alles ablehnen') or contains(., 'Tout rejeter') or contains(., 'Odmietnuť všetko') or contains(., 'Lükka kõik tagasi') or contains(., 'Hylkää kaikki')]";if(await this.waitForElement(e,500))return this.click(e);const t="xpath///button[contains(., 'Spara & avsluta') or contains(., 'Save & exit') or contains(., 'Uložit a ukončit') or contains(., 'Enregistrer et quitter') or contains(., 'Speichern & Verlassen') or contains(., 'Tallenna ja poistu') or contains(., 'Išsaugoti ir išeiti') or contains(., 'Opslaan & afsluiten') or contains(., 'Guardar y salir') or contains(., 'Shrani in zapri') or contains(., 'Uložiť a ukončiť') or contains(., 'Kaydet ve çıkış yap') or contains(., 'Сохранить и выйти') or contains(., 'Salvesta ja välju') or contains(., 'Salva ed esci') or contains(., 'Gem & afslut') or contains(., 'Αποθήκευση και έξοδος') or contains(., 'Saglabāt un iziet') or contains(., 'Mentés és kilépés') or contains(., 'Guardar e sair') or contains(., 'Zapisz & zakończ') or contains(., 'Salvare și ieșire') or contains(., 'Spremi i izađi') or contains(., 'Запазване и изход')]";if(await this.waitForThenClick("xpath///button[contains(., 'Zwecke') or contains(., 'Σκοποί') or contains(., 'Purposes') or contains(., 'Цели') or contains(., 'Eesmärgid') or contains(., 'Tikslai') or contains(., 'Svrhe') or contains(., 'Cele') or contains(., 'Účely') or contains(., 'Finalidades') or contains(., 'Mērķi') or contains(., 'Scopuri') or contains(., 'Fines') or contains(., 'Ändamål') or contains(., 'Finalités') or contains(., 'Doeleinden') or contains(., 'Tarkoitukset') or contains(., 'Scopi') or contains(., 'Amaçlar') or contains(., 'Nameni') or contains(., 'Célok') or contains(., 'Formål')]")&&await this.waitForVisible(t)){return this.elementSelector(t)[0].parentElement.parentElement.querySelectorAll("input[type=checkbox]:checked").forEach((e=>e.click())),this.click(t)}return!1}async optIn(){return this.click("xpath///button[contains(., 'Sprejmi vse') or contains(., 'Prihvati sve') or contains(., 'Godkänn alla') or contains(., 'Prijať všetko') or contains(., 'Принять все') or contains(., 'Aceptar todo') or contains(., 'Αποδοχή όλων') or contains(., 'Zaakceptuj wszystkie') or contains(., 'Accetta tutto') or contains(., 'Priimti visus') or contains(., 'Pieņemt visu') or contains(., 'Tümünü kabul et') or contains(., 'Az összes elfogadása') or contains(., 'Accept all') or contains(., 'Приемане на всички') or contains(., 'Accepter alle') or contains(., 'Hyväksy kaikki') or contains(., 'Tout accepter') or contains(., 'Alles accepteren') or contains(., 'Aktsepteeri kõik') or contains(., 'Přijmout vše') or contains(., 'Alles akzeptieren') or contains(., 'Aceitar tudo') or contains(., 'Acceptați tot')]")}}],_=class{constructor(e){this.autoconsentInstance=e}click(e,t=!1){const o=this.elementSelector(e);return this.autoconsentInstance.config.logs.rulesteps&&console.log("[click]",e,t,o),o.length>0&&(t?o.forEach((e=>e.click())):o[0].click()),o.length>0}elementExists(e){return this.elementSelector(e).length>0}elementVisible(e,t){const o=this.elementSelector(e),i=new Array(o.length);return o.forEach(((e,t)=>{i[t]=k(e)})),"none"===t?i.every((e=>!e)):0!==i.length&&("any"===t?i.some((e=>e)):i.every((e=>e)))}waitForElement(e,t=1e4){const o=Math.ceil(t/200);return this.autoconsentInstance.config.logs.rulesteps&&console.log("[waitForElement]",e),f((()=>this.elementSelector(e).length>0),o,200)}waitForVisible(e,t=1e4,o="any"){const i=Math.ceil(t/200);return this.autoconsentInstance.config.logs.rulesteps&&console.log("[waitForVisible]",e),f((()=>this.elementVisible(e,o)),i,200)}async waitForThenClick(e,t=1e4,o=!1){return await this.waitForElement(e,t),this.click(e,o)}wait(e){return this.autoconsentInstance.config.logs.rulesteps&&this.autoconsentInstance.config.logs.waits&&console.log("[wait]",e),new Promise((t=>{setTimeout((()=>{t(!0)}),e)}))}hide(e,t){this.autoconsentInstance.config.logs.rulesteps&&console.log("[hide]",e);return g(m(),e,t)}prehide(e){const t=m("autoconsent-prehide");return this.autoconsentInstance.config.logs.lifecycle&&console.log("[prehide]",t,location.href),g(t,e,"opacity")}undoPrehide(){const e=m("autoconsent-prehide");return this.autoconsentInstance.config.logs.lifecycle&&console.log("[undoprehide]",e,location.href),e&&e.remove(),!!e}async createOrUpdateStyleSheet(e,t){return t||(t=new CSSStyleSheet),t=await t.replace(e)}removeStyleSheet(e){return!!e&&(e.replace(""),!0)}querySingleReplySelector(e,t=document){if(e.startsWith("aria/"))return[];if(e.startsWith("xpath/")){const o=e.slice(6),i=document.evaluate(o,t,null,XPathResult.ANY_TYPE,null);let n=null;const s=[];for(;n=i.iterateNext();)s.push(n);return s}return e.startsWith("text/")||e.startsWith("pierce/")?[]:t.shadowRoot?Array.from(t.shadowRoot.querySelectorAll(e)):Array.from(t.querySelectorAll(e))}querySelectorChain(e){let t,o=document;for(const i of e){if(t=this.querySingleReplySelector(i,o),0===t.length)return[];o=t[0]}return t}elementSelector(e){return"string"==typeof e?this.querySingleReplySelector(e):this.querySelectorChain(e)}};function C(){return{chars:new Map,code:void 0}}var x=new Uint8Array(0),S=class{constructor(e,t=3e4){this.trie=function(e){const t=C();for(let o=0;o figure.wp-block-image:has(> img[class^="wp-image-"][src^="https://www.sinhasannews.com/"][width="','"]:not([style^="width: 1px; height: 1px; position: absolute; left: -10000px; top: -"])',"acs, document.createElement, %2Fl%5C.parentNode%5C.insertBefore%5C(s%2F","%2Fvisit%2F%22%5D%5Btitle%5E%3D%22https%3A%2F%2F%22%5D, %5Btitle%5D",", OptanonConsent, groups%3DC0001%253A1%252CC0002%253A0%252CC000","rmnt, script, %2Fh%3DdecodeURIComponent%7CpopundersPerIP%2F",'.project-description [href^="/linkout?remoteUrl="][href*="',':not([style^="position: absolute; left: -5000px"])',"href-sanitizer, a%5Bhref%5E%3D%22https%3A%2F%2F","ra, oncontextmenu%7Condragstart%7Conselectstart",", OptanonAlertBoxClosed, %24currentDate%24","acs, document.querySelectorAll, popMagic","acs, addEventListener, google_ad_client","aost, String.prototype.charCodeAt, ai_","aopr, app_vars.force_disable_adblock","acs, document.addEventListener, ","taboola-below-article-thumbnails","acs, document.getElementById, ","no-fetch-if, googlesyndication","aopr, document.dispatchEvent","no-xhr-if, googlesyndication",", document.createElement, ","acs, String.fromCharCode, ","%2522%253Afalse%252C%2522",", document.oncontextmenu","%2522%253Atrue%252C%2522","aeld, DOMContentLoaded, ","nosiif, visibility, 1000","set-local-storage-item, ","%2522%3Afalse%252C%2522","trusted-click-element, ","set, blurred, false","acs, eval, replace","decodeURIComponent",'[target="_blank"]',"%22%3Afalse%2C%22","^script:has-text(",'[href^="https://','[href^="http://','[href="https://','[src^="https://','[data-testid="',"modal-backdrop","rmnt, script, ","BlockDetected","trusted-set-",".prototype.","contextmenu","no-fetch-if","otification",":has-text(","background",'[class*="','[class^="',"body,html","container","Container","decodeURI","div[class",'div[id^="',"div[style","document.","no-xhr-if","placehold",'[href*="',"#wpsafe-","AAAAAAAA","Detector","disclaim","nano-sib","nextFunc","noopFunc","nostif, ","nowebrtc",'.com/"]',"300x250","article","consent","Consent","content","display","keydown","message","Message","overlay","privacy","sidebar","sponsor","wrapper","-child","[data-","accept","Accept","aopr, ","banner","bottom","cookie","Cookie","google","nosiif","notice","nowoif","policy","Policy","script","widget",":has(",":not(","block","Block","click","deskt","disab","fixed","frame","modal","popup","video",".com","2%3A","aeld","body","butt","foot","gdpr","html","icky","ight","show","tion","true"," > ","%3D","%7C","age","box","div","ent","out","rap","set","__",", ",'"]',"%2","%5",'="',"00","ac","ad","Ad","al","an","ar","at","e-","ed","en","er","he","id","in","la","le","lo","od","ol","om","on","op","or","re","s_","s-","se","st","t-","te","ti","un","_","-",";",":",".","(",")","[","]","*","/","#","^","0","1","2","3","4","5","6","7","8","9","b","B","c","C","d","D","e","E","f","F","g","G","h","H","I","j","J","k","l","L","m","M","n","N","O","p","P","q","Q","R","s","S","t","T","u","U","v","V","w","W","x","y","Y","z"],T=["sandbox allow-forms allow-same-origin allow-scripts allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-top-navigation","script-src 'self' 'unsafe-inline' 'unsafe-eval' "," *.google.com *.gstatic.com *.googleapis.com",".com *.google.com *.googletagmanager.com *.","script-src 'self' '*' 'unsafe-inline'","default-src 'unsafe-inline' 'self'","script-src 'self' 'unsafe-eval' "," *.google.com *.gstatic.com *.","t-src 'self' 'unsafe-inline' ","script-src * 'unsafe-inline'",".com *.googleapis.com *."," *.googletagmanager.com",".com *.bootstrapcdn.com","default-src 'self' *.","frame-src 'self' *"," *.cloudflare.com","child-src 'none';","worker-src 'none'","'unsafe-inline'"," data: blob:","*.googleapis","connect-src ","unsafe-eval'","child-src *"," *.gstatic","script-src","style-src ","frame-src","facebook","https://"," 'self'"," allow-",".com *.",".net *.","addthis","captcha","gstatic","youtube","defaul","disqus","google","https:","jquery","data:","http:","media","scrip","-src",".com",".net","n.cc"," *.","age","box","str","vic","yti"," '"," *","*.","al","am","an","as","cd","el","es","il","im","in","or","pi","st","ur","wi","wp"," ","-",";",":",".","'","*","/","3","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y"],O=["/homad-global-configs.schneevonmorgen.com/global_config","/videojs-vast-vpaid@2.0.2/bin/videojs_5.vast.vpaid.min","/etc.clientlibs/logitech-common/clientlibs/onetrust.","/pagead/managed/js/adsense/*/show_ads_impl","/pagead/managed/js/gpt/*/pubads_impl","/wrappermessagingwithoutdetection","/pagead/js/adsbygoogle.js","a-z]{8,15}\\.(?:com|net)\\/","/js/sdkloader/ima3.js","/js/sdkloader/ima3_d","/videojs-contrib-ads","/wp-content/plugins/","/wp-content/uploads/","/wp-content/themes/","/detroitchicago/","*/satellitelib-","/appmeasurement","/413gkwmt/init","/cdn-cgi/trace","/^https?:\\/\\/","[a-zA-Z0-9]{","/^https:\\/\\/","notification","\\/[a-z0-9]{","fingerprint","impression","[0-9a-z]{","/plugins/","affiliate","analytics","telemetry","(.+?\\.)?","/assets/","/images/","/pagead/","pageview","template","tracking","/public","300x250","ampaign","captcha","collect","consent","content","counter","default","metrics","privacy","[a-z]{","/embed","728x90","banner","bundle","client","cookie","detect","dn-cgi","google","iframe","module","prebid","script","source","widget",".aspx",".cgi?",".com/",".html","/api/","/beac","/img/","/java","/stat","0x600","block","click","count","event","manag","media","pixel","popup","tegra","theme","track","type=","video","visit",".css",".gif",".jpg",".min",".php",".png","/jqu","/js/","/lib","/log","/web","/wp-","468x","data","gdpr","gi-b","http","ight","mail","play","plug","publ","show","stat","uild","view",".js","/ad","=*&","age","com","ext","jax","key","log","new","sdk","tag","web","ync",":/","*/","*^","/_","/?","/*","/d","/f","/g","/h","/l","/n","/r","/u","/w","ac","ad","al","am","an","ap","ar","as","at","bo","ce","ch","co","de","e/","ec","ed","el","en","er","et","fi","g/","ic","id","im","in","is","it","js","la","le","li","lo","ma","mo","mp","ol","om","on","op","or","ot","re","ro","s_","s-","s?","s/","sp","ss","st","t/","ti","tm","tr","ub","un","ur","us","ut","ve","_","-",",","?",".","}","*","/","\\","&","^","=","0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"],P=["securepubads.g.doubleclick",".actonservice.com","googlesyndication","imasdk.googleapis",".cloudfront.net","googletagmanag","-1.xx.fbcdn","analytics.","marketing.","tracking.","metrics.","images.",".co.uk","a8clk.","stats.","a8cv.","click","media","track",".com",".net",".xyz","www.",".io",".jp","a8.","app","cdn","new","web",".b",".c",".d",".f",".h",".k",".m",".n",".p",".s",".t","10","24","a-","a1","a2","a4","ab","ac","ad","af","ag","ah","ai","ak","al","am","an","ap","ar","as","at","au","av","aw","ax","ay","az","be","bi","bl","bo","br","bu","ca","ce","ch","ci","ck","cl","cr","ct","cu","de","di","dn","do","dr","ds","du","dy","e-","eb","ec","ed","ef","eg","el","em","en","ep","er","es","et","eu","ev","ew","ex","ey","fe","ff","fi","fo","fr","ft","ge","gh","gi","gn","go","gr","gu","he","ho","ib","ic","id","ie","if","ig","ik","il","im","in","ip","ir","is","it","iv","ix","iz","jo","ks","la","le","li","ll","lo","lu","ly","ma","me","mo","mp","my","no","ok","ol","om","on","oo","op","or","ot","ou","ph","pl","po","pr","pu","qu","re","ri","ro","ru","s-","sc","se","sh","si","sk","sn","so","sp","ss","st","su","sw","sy","t-","ta","te","th","ti","tn","to","tr","ts","tu","ty","ub","ud","ul","um","un","up","ur","us","ut","ve","vi","vo","wa","we","wh","wn","-",".","0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"],R=["google-analytics.com/analytics.js","googlesyndication_adsbygoogle.js","googletagmanager.com/gtm.js","googletagservices_gpt.js","googletagmanager_gtm.js","fuckadblock.js-3.2.0","amazon_apstag.js","google-analytics","fingerprint2.js","noop-1s.mp4:10","google-ima.js","noop-0.1s.mp3","prebid-ads.js","nobab2.js:10","noopmp3-0.1s","noop-1s.mp4","hd-main.js","noopmp4-1s","32x32.png","noop.html","noopframe","noop.txt","nooptext","1x1.gif","2x2.png","noop.js","noopjs",".com/",".js:5","noop",":10",".js","ads","bea","_a",":5",".0","ar","ch","ic","in","le","ma","on","re","st","_","-",":",".","/","0","1","2","3","4","5","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","r","s","t","u","v","w","x","y","z"],z=[",redirect=google-ima","/js/sdkloader/ima3.j","/wp-content/plugins/",",redirect-rule=noop",".actonservice.com^",".com^$third-party","googlesyndication","imasdk.googleapis",".cloudfront.net^",",redirect-rule=","$script,domain=",",3p,denyallow=",",redirect=noop","xmlhttprequest","^$third-party","||smetrics.","third-party","marketing.","$document","analytics",",domain=","/assets/","metrics.","subdocum","tracking","$script",".co.uk","$ghide","a8clk.","cookie","google","script",".com^",".xyz^","$doma","a8cv.","click","image","media","track",".com",".fr^",".gif",".jp^",".net","/js/","$doc","$xhr","stat","www.",",1p",",3p",".io",".jp",".js","app","cdn","ent","new","web",".b",".c",".d",".f",".h",".m",".n",".p",".s",".t","@@","/*","/p","||","ab","ac","ad","af","ag","ai","ak","al","am","an","ap","ar","as","at","au","av","aw","ax","ay","az","be","bi","bo","br","ca","ce","ch","ck","cl","ct","cu","de","di","do","e-","e^","ec","ed","el","em","en","ep","er","es","et","ev","ew","ex","fe","ff","fi","fo","fr","g^","ge","gi","go","gr","he","hi","ho","hp","ht","ic","id","ig","il","im","in","ip","ir","is","it","ix","js","ke","le","li","lo","lu","ly","me","mo","mp","ne","no","od","ok","ol","om","on","op","or","ot","ow","pl","po","pr","qu","re","ri","ro","ru","s-","s/","sc","se","sh","si","so","sp","ss","st","su","te","th","ti","to","tr","ts","ty","ub","ud","ul","um","un","up","ur","us","ut","ve","vi","_","-",",","?",".","*","/","^","=","|","~","$","0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"],L=["-webkit-touch-callo",", 1year, , domain, ",", googlesyndication",", SOCS, CAISNQgQEit",":style(overflow: au","##^script:has-text(","9udGVuZHVpc2VydmVyX","GgJmaSADGgYIgOu0sgY","ib3FfaWRlbnRpdHlmcm","position: initial !","set-local-storage-i","set, blurred, false","user-select: text !","zIwMjQwNTE0LjA2X3Aw",'[href^="https://',"rmnt, script, ","ut: default !"," !important)","trusted-set-",", document.",", noopFunc)","##body,html","contextmenu","no-fetch-if","otification",".com##+js(",'="https://',"background","important;"," -webkit-",".*,xhamst","container","AAAAAAAA","nostif, ",",google",":style(","consent","message","nowoif)","privacy","-wrapp",",kayak",".co.uk","[class","##+js(","accept","aopr, ","banner","bottom","cookie","Cookie","google","notice","policy","widget",":has(","##div","block","cript","true)",".co.",".com",".de,",".fr,",".net",".nl,",".pl,",".xyz","#@#.","2%3A","gdpr","html","ight","news","text","to !","wrap","www."," > ",",xh","##.","###","%3D","%7C","ent","lay","web","__","-s",", ",",b",",c",",f",",g",",h",",m",",p",",s",",t",": ",".*",".b",".c",".m",".p",".s",'"]',"##","%2","%5",'="',"00","a-","ab","ac","ad","Ad","af","ag","ak","al","am","an","ap","ar","as","at","au","av","ay","az","bo","ch","ck","cl","ct","de","di","do","e-","ed","el","em","en","er","es","et","ex","fi","fo","he","ic","id","if","ig","il","im","in","is","it","iv","le","lo","mo","ol","om","on","op","or","ot","ov","pl","po","re","ro","s_","s-","se","sh","si","sp","st","t-","th","ti","tr","tv","ub","ul","um","un","up","ur","us","ut","vi"," ","_","-",",",":",".","(",")","[","*","/","^","0","1","2","3","4","5","6","7","8","9","a","b","B","c","C","d","D","e","E","f","F","g","h","i","j","k","l","L","m","M","n","o","p","P","q","r","s","S","t","T","u","v","w","x","y","z"],B=class{constructor(){this.cosmeticSelector=new I(F),this.networkCSP=new I(T),this.networkRedirect=new I(R),this.networkHostname=new I(P),this.networkFilter=new I(O),this.networkRaw=new I(z),this.cosmeticRaw=new I(L)}},U=(()=>{let e=0;const t=new Int32Array(256);for(let o=0;256!==o;o+=1)e=o,e=1&e?-306674912^e>>>1:e>>>1,e=1&e?-306674912^e>>>1:e>>>1,e=1&e?-306674912^e>>>1:e>>>1,e=1&e?-306674912^e>>>1:e>>>1,e=1&e?-306674912^e>>>1:e>>>1,e=1&e?-306674912^e>>>1:e>>>1,e=1&e?-306674912^e>>>1:e>>>1,e=1&e?-306674912^e>>>1:e>>>1,t[o]=e;return t})();var N=2147483647,V=36,j=/[^\0-\x7E]/,M=/[\x2E\u3002\uFF0E\uFF61]/g,D={"invalid-input":"Invalid input","not-basic":"Illegal input >= 0x80 (not a basic code point)",overflow:"Overflow: input needs wider integers to process"};function H(e){throw new RangeError(D[e])}function W(e,t){return e+22+75*(e<26?1:0)-((0!==t?1:0)<<5)}function q(e,t,o){let i=0;for(e=o?Math.floor(e/700):e>>1,e+=Math.floor(e/t);e>455;i+=V)e=Math.floor(e/35);return Math.floor(i+36*e/(e+38))}function G(e){const t=[],o=e.length;let i=0,n=128,s=72,c=e.lastIndexOf("-");c<0&&(c=0);for(let o=0;o=128&&H("not-basic"),t.push(e.charCodeAt(o));for(let a=c>0?c+1:0;a=o&&H("invalid-input");const c=(r=e.charCodeAt(a++))-48<10?r-22:r-65<26?r-65:r-97<26?r-97:V;(c>=V||c>Math.floor((N-i)/t))&&H("overflow"),i+=c*t;const l=n<=s?1:n>=s+26?26:n-s;if(cMath.floor(N/p)&&H("overflow"),t*=p}const l=t.length+1;s=q(i-c,l,0===c),Math.floor(i/l)>N-n&&H("overflow"),n+=Math.floor(i/l),i%=l,t.splice(i++,0,n)}var r;return String.fromCodePoint.apply(null,t)}function $(e){const t=[],o=function(e){const t=[];let o=0;const i=e.length;for(;o=55296&&n<=56319&&o=n&&iMath.floor((N-s)/i)&&H("overflow"),s+=(e-n)*i,n=e;for(let e=0;eN&&H("overflow"),l===n){let e=s;for(let o=V;;o+=V){const i=o<=c?1:o>=c+26?26:o-c;if(e{const e=new B;return Z=()=>e,e};function J(e){return e<=127?1:5}function ee(e,t){return te(e.length,t)}function te(e,t){return(t?3:0)+e+J(e)}function oe(e){return e.length+J(e.length)}function ie(e){const t=$(e).length;return t+J(t)}function ne(e){return e.byteLength+J(e.length)}var se,ce=class e{static empty(t){return e.fromUint8Array(Q,t)}static fromUint8Array(t,o){return new e(t,o)}static allocate(t,o){return new e(new Uint8Array(t),o)}constructor(e,{enableCompression:t}){if(!1===X)throw new Error("Adblocker currently does not support Big-endian systems");!0===t&&this.enableCompression(),this.buffer=e,this.pos=0}enableCompression(){this.compression=Z()}checksum(){return function(e,t,o){let i=-1;const n=o-7;let s=t;for(;s>>8^U[255&(i^e[s++])],i=i>>>8^U[255&(i^e[s++])],i=i>>>8^U[255&(i^e[s++])],i=i>>>8^U[255&(i^e[s++])],i=i>>>8^U[255&(i^e[s++])],i=i>>>8^U[255&(i^e[s++])],i=i>>>8^U[255&(i^e[s++])],i=i>>>8^U[255&(i^e[s++])];for(;s>>8^U[255&(i^e[s++])];return~i>>>0}(this.buffer,0,this.pos)}dataAvailable(){return this.pos>>8,this.buffer[this.pos++]=e}getUint16(){return(this.buffer[this.pos++]<<8|this.buffer[this.pos++])>>>0}pushUint32(e){this.buffer[this.pos++]=e>>>24,this.buffer[this.pos++]=e>>>16,this.buffer[this.pos++]=e>>>8,this.buffer[this.pos++]=e}getUint32(){return(this.buffer[this.pos++]<<24>>>0)+(this.buffer[this.pos++]<<16|this.buffer[this.pos++]<<8|this.buffer[this.pos++])>>>0}pushUint32Array(e){this.pushLength(e.length);for(const t of e)this.pushUint32(t)}getUint32Array(){const e=this.getLength(),t=new Uint32Array(e);for(let o=0;othis.buffer.byteLength)throw new Error(`StaticDataView too small: ${this.buffer.byteLength}, but required ${this.pos} bytes`)}pushLength(e){e<=127?this.pushUint8(e):(this.pushUint8(128),this.pushUint32(e))}getLength(){const e=this.getUint8();return 128===e?this.getUint32():e}},re=class e{static deserialize(t){return new e({debug:t.getBool(),enableCompression:t.getBool(),enableHtmlFiltering:t.getBool(),enableInMemoryCache:t.getBool(),enableMutationObserver:t.getBool(),enableOptimizations:t.getBool(),enablePushInjectionsOnNavigationEvents:t.getBool(),guessRequestTypeFromUrl:t.getBool(),integrityCheck:t.getBool(),loadCSPFilters:t.getBool(),loadCosmeticFilters:t.getBool(),loadExceptionFilters:t.getBool(),loadExtendedSelectors:t.getBool(),loadGenericCosmeticsFilters:t.getBool(),loadNetworkFilters:t.getBool(),loadPreprocessors:t.getBool()})}constructor({debug:e=!1,enableCompression:t=!1,enableHtmlFiltering:o=!1,enableInMemoryCache:i=!0,enableMutationObserver:n=!0,enableOptimizations:s=!0,enablePushInjectionsOnNavigationEvents:c=!0,guessRequestTypeFromUrl:r=!1,integrityCheck:a=!0,loadCSPFilters:l=!0,loadCosmeticFilters:p=!0,loadExceptionFilters:d=!0,loadExtendedSelectors:u=!1,loadGenericCosmeticsFilters:h=!0,loadNetworkFilters:m=!0,loadPreprocessors:A=!1}={}){this.debug=e,this.enableCompression=t,this.enableHtmlFiltering=o,this.enableInMemoryCache=i,this.enableMutationObserver=n,this.enableOptimizations=s,this.enablePushInjectionsOnNavigationEvents=c,this.guessRequestTypeFromUrl=r,this.integrityCheck=a,this.loadCSPFilters=l,this.loadCosmeticFilters=p,this.loadExceptionFilters=d,this.loadExtendedSelectors=u,this.loadGenericCosmeticsFilters=h,this.loadNetworkFilters=m,this.loadPreprocessors=A}getSerializedSize(){return 16}serialize(e){e.pushBool(this.debug),e.pushBool(this.enableCompression),e.pushBool(this.enableHtmlFiltering),e.pushBool(this.enableInMemoryCache),e.pushBool(this.enableMutationObserver),e.pushBool(this.enableOptimizations),e.pushBool(this.enablePushInjectionsOnNavigationEvents),e.pushBool(this.guessRequestTypeFromUrl),e.pushBool(this.integrityCheck),e.pushBool(this.loadCSPFilters),e.pushBool(this.loadCosmeticFilters),e.pushBool(this.loadExceptionFilters),e.pushBool(this.loadExtendedSelectors),e.pushBool(this.loadGenericCosmeticsFilters),e.pushBool(this.loadNetworkFilters),e.pushBool(this.loadPreprocessors)}},ae="undefined"!=typeof window&&"function"==typeof window.queueMicrotask?e=>window.queueMicrotask(e):e=>(se||(se=Promise.resolve())).then(e).catch((e=>setTimeout((()=>{throw e}),0)));function le(e,t,o){let i=o.get(e);void 0===i&&(i=[],o.set(e,i)),i.push(t)}function pe(e,t,o){const i=o.get(e);if(void 0!==i){const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}function de(e,t,o){if(0===o.size)return!1;const i=o.get(e);return void 0!==i&&(ae((()=>{for(const e of i)e(...t)})),!0)}var ue=class{constructor(){this.onceListeners=new Map,this.onListeners=new Map}on(e,t){le(e,t,this.onListeners)}once(e,t){le(e,t,this.onceListeners)}unsubscribe(e,t){pe(e,t,this.onListeners),pe(e,t,this.onceListeners)}emit(e,...t){de(e,t,this.onListeners),!0===de(e,t,this.onceListeners)&&this.onceListeners.delete(e)}};function he(e,t){return function(e,t){let o=3;const i=()=>e(t).catch((e=>{if(o>0)return o-=1,new Promise(((e,t)=>{setTimeout((()=>{i().then(e).catch(t)}),500)}));throw e}));return i()}(e,t).then((e=>e.text()))}var me="https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets",Ae=[`${me}/easylist/easylist.txt`,`${me}/peter-lowe/serverlist.txt`,`${me}/ublock-origin/badware.txt`,`${me}/ublock-origin/filters-2020.txt`,`${me}/ublock-origin/filters-2021.txt`,`${me}/ublock-origin/filters-2022.txt`,`${me}/ublock-origin/filters-2023.txt`,`${me}/ublock-origin/filters-2024.txt`,`${me}/ublock-origin/filters.txt`,`${me}/ublock-origin/quick-fixes.txt`,`${me}/ublock-origin/resource-abuse.txt`,`${me}/ublock-origin/unbreak.txt`],ge=[...Ae,`${me}/easylist/easyprivacy.txt`,`${me}/ublock-origin/privacy.txt`],fe=[...ge,`${me}/easylist/easylist-cookie.txt`,`${me}/ublock-origin/annoyances-others.txt`,`${me}/ublock-origin/annoyances-cookies.txt`];var ke=new Set(["any","dir","has","host-context","if","if-not","is","matches","not","where"]),be={attribute:/\[\s*(?:(?\*|[-\w]*)\|)?(?[-\w\u{0080}-\u{FFFF}]+)\s*(?:(?\W?=)\s*(?.+?)\s*(?[iIsS])?\s*)?\]/gu,id:/#(?(?:[-\w\u{0080}-\u{FFFF}]|\\.)+)/gu,class:/\.(?(?:[-\w\u{0080}-\u{FFFF}]|\\.)+)/gu,comma:/\s*,\s*/g,combinator:/\s*[\s>+~]\s*/g,"pseudo-element":/::(?[-\w\u{0080}-\u{FFFF}]+)(?:\((?:¶*)\))?/gu,"pseudo-class":/:(?[-\w\u{0080}-\u{FFFF}]+)(?:\((?¶*)\))?/gu,type:/(?:(?\*|[-\w]*)\|)?(?[-\w\u{0080}-\u{FFFF}]+)|\*/gu},ye=new Set(["pseudo-class","pseudo-element"]),we=new Set([...ye,"attribute"]),ve=new Set(["combinator","comma"]),_e=Object.assign({},be);function Ce(e,t){e.lastIndex=0;const o=e.exec(t);if(null===o)return;const i=o.index-1,n=o[0],s=t.slice(0,i+1),c=t.slice(i+n.length+1);return[s,[n,o.groups||{}],c]}_e["pseudo-element"]=RegExp(be["pseudo-element"].source.replace("(?¶*)","(?.*?)"),"gu"),_e["pseudo-class"]=RegExp(be["pseudo-class"].source.replace("(?¶*)","(?.*)"),"gu");var xe=[e=>{const t=Ce(be.attribute,e);if(void 0===t)return;const[o,[i,{name:n,operator:s,value:c,namespace:r,caseSensitive:a}],l]=t;return void 0!==n?[o,{type:"attribute",content:i,length:i.length,namespace:r,caseSensitive:a,pos:[],name:n,operator:s,value:c},l]:void 0},e=>{const t=Ce(be.id,e);if(void 0===t)return;const[o,[i,{name:n}],s]=t;return void 0!==n?[o,{type:"id",content:i,length:i.length,pos:[],name:n},s]:void 0},e=>{const t=Ce(be.class,e);if(void 0===t)return;const[o,[i,{name:n}],s]=t;return void 0!==n?[o,{type:"class",content:i,length:i.length,pos:[],name:n},s]:void 0},e=>{const t=Ce(be.comma,e);if(void 0===t)return;const[o,[i],n]=t;return[o,{type:"comma",content:i,length:i.length,pos:[]},n]},e=>{const t=Ce(be.combinator,e);if(void 0===t)return;const[o,[i],n]=t;return[o,{type:"combinator",content:i,length:i.length,pos:[]},n]},e=>{const t=Ce(be["pseudo-element"],e);if(void 0===t)return;const[o,[i,{name:n}],s]=t;return void 0!==n?[o,{type:"pseudo-element",content:i,length:i.length,pos:[],name:n},s]:void 0},e=>{const t=Ce(be["pseudo-class"],e);if(void 0===t)return;const[o,[i,{name:n,argument:s}],c]=t;return void 0!==n?[o,{type:"pseudo-class",content:i,length:i.length,pos:[],name:n,argument:s,subtree:void 0},c]:void 0},e=>{const t=Ce(be.type,e);if(void 0===t)return;const[o,[i,{name:n,namespace:s}],c]=t;return[o,{type:"type",content:i,length:i.length,namespace:s,pos:[],name:n},c]}];function Se(e,t,o,i){for(const n of t)for(const t of e)if(i.has(t.type)&&t.pos[0]=0&&"\\"===e[t];)o+=1,t-=1;return o%2!=0}function Ie(e,t,o){let i=o+1;for(;-1!==(i=e.indexOf(t,i))&&!0===Ee(e,i);)i+=1;if(-1!==i)return e.slice(o,i+1)}function Fe(e,t){let o=0;for(let i=t;i0))return;o-=1}if(0===o)return e.slice(t,i+1)}}function Te(e,t,o,i){const n=[];let s=0;for(;-1!==(s=e.indexOf(o,s));){const o=i(e,s);if(void 0===o)break;n.push({str:o,start:s}),e=`${e.slice(0,s+1)}${t.repeat(o.length-2)}${e.slice(s+o.length-1)}`,s+=o.length}return[n,e]}function Oe(e){if("string"!=typeof e)return[];if(0===(e=e.trim()).length)return[];const[t,o]=Te(e,"§",'"',((e,t)=>Ie(e,'"',t))),[i,n]=Te(o,"§","'",((e,t)=>Ie(e,"'",t))),[s,c]=Te(n,"¶","(",Fe),r=function(e){if(!e)return[];const t=[e];for(const e of xe)for(let o=0;o0!==e.length)))}}let o=0;for(const e of t)"string"!=typeof e&&(e.pos=[o,o+e.length],ve.has(e.type)&&(e.content=e.content.trim()||" ")),o+=e.length;return t.every((e=>"string"!=typeof e))?t:[]}(c);return Se(r,s,/\(¶*\)/,ye),Se(r,t,/"§*"/,we),Se(r,i,/'§*'/,we),r}function Pe(e,{list:t=!0}={}){if(!0===t&&e.some((e=>"comma"===e.type))){const t=[],o=[];for(let i=0;i=0;t--){const o=e[t];if("combinator"===o.type){const i=Pe(e.slice(0,t)),n=Pe(e.slice(t+1));if(void 0===n)return;if(" "!==o.content&&"~"!==o.content&&"+"!==o.content&&">"!==o.content)return;return{type:"complex",combinator:o.content,left:i,right:n}}}if(0!==e.length)return function(e){return e.every((e=>"comma"!==e.type&&"combinator"!==e.type))}(e)?1===e.length?e[0]:{type:"compound",compound:[...e]}:void 0}function Re(e,t,o,i){if(void 0!==e){if("complex"===e.type)Re(e.left,t,o,e),Re(e.right,t,o,e);else if("compound"===e.type)for(const i of e.compound)Re(i,t,o,e);else"pseudo-class"===e.type&&void 0!==e.subtree&&void 0!==o&&"pseudo-class"===o.type&&void 0!==o.subtree&&Re(e.subtree,t,o,e);t(e,i)}}function ze(e,{recursive:t=!0,list:o=!0}={}){const i=Oe(e);if(0===i.length)return;const n=Pe(i,{list:o});return!0===t&&Re(n,(e=>{"pseudo-class"===e.type&&e.argument&&void 0!==e.name&&ke.has(e.name)&&(e.subtree=ze(e.argument,{recursive:!0,list:!0}))})),n}var Le,Be,Ue=new Set(["has","has-text","if"]),Ne=new Set(["active","any","any-link","blank","checked","default","defined","dir","disabled","empty","enabled","first","first-child","first-of-type","focus","focus-visible","focus-within","fullscreen","host","host-context","hover","in-range","indeterminate","invalid","is","lang","last-child","last-of-type","left","link","matches","not","nth-child","nth-last-child","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","placeholder-shown","read-only","read-write","required","right","root","scope","target","valid","visited","where"]),Ve=new Set(["after","before","first-letter","first-line"]);function je(e){if(-1===e.indexOf(":"))return Le.Normal;const t=Oe(e);let o=!1;for(const e of t)if("pseudo-class"===e.type){const{name:t}=e;if(!0===Ue.has(t))o=!0;else if(!1===Ne.has(t)&&!1===Ve.has(t))return Le.Invalid;if(!1===o&&void 0!==e.argument&&!0===ke.has(t)){const t=je(e.argument);if(t===Le.Invalid)return t;t===Le.Extended&&(o=!0)}}return!0===o?Le.Extended:Le.Normal}(Be=Le||(Le={}))[Be.Normal=0]="Normal",Be[Be.Extended=1]="Extended",Be[Be.Invalid=2]="Invalid";var Me=new Set(["htm","html","xhtml"]),De=new Set(["eot","otf","sfnt","ttf","woff","woff2"]),He=new Set(["apng","bmp","cur","dib","eps","gif","heic","heif","ico","j2k","jfi","jfif","jif","jp2","jpe","jpeg","jpf","jpg","jpm","jpx","mj2","pjp","pjpeg","png","svg","svgz","tif","tiff","webp"]),We=new Set(["avi","flv","mp3","mp4","ogg","wav","weba","webm","wmv"]),qe=new Set(["js","ts","jsx","esm"]),Ge=new Set(["css","scss"]);function $e(e,t){let o=0,i=e.length,n=!1;if(!t){if(e.startsWith("data:"))return null;for(;oo+1&&e.charCodeAt(i-1)<=32;)i-=1;if(47===e.charCodeAt(o)&&47===e.charCodeAt(o+1))o+=2;else{const t=e.indexOf(":/",o);if(-1!==t){const i=t-o,n=e.charCodeAt(o),s=e.charCodeAt(o+1),c=e.charCodeAt(o+2),r=e.charCodeAt(o+3),a=e.charCodeAt(o+4);if(5===i&&104===n&&116===s&&116===c&&112===r&&115===a);else if(4===i&&104===n&&116===s&&116===c&&112===r);else if(3===i&&119===n&&115===s&&115===c);else if(2===i&&119===n&&115===s);else for(let i=o;i=97&&t<=122||t>=48&&t<=57||46===t||45===t||43===t))return null}for(o=t+2;47===e.charCodeAt(o);)o+=1}}let t=-1,s=-1,c=-1;for(let r=o;r=65&&o<=90&&(n=!0)}if(-1!==t&&t>o&&to&&co+1&&46===e.charCodeAt(i-1);)i-=1;const s=0!==o||i!==e.length?e.slice(o,i):e;return n?s.toLowerCase():s}function Ke(e){return e>=97&&e<=122||e>=48&&e<=57||e>127}function Qe(e){if(e.length>255)return!1;if(0===e.length)return!1;if(!Ke(e.charCodeAt(0))&&46!==e.charCodeAt(0)&&95!==e.charCodeAt(0))return!1;let t=-1,o=-1;const i=e.length;for(let n=0;n64||46===o||45===o||95===o)return!1;t=n}else if(!Ke(i)&&45!==i&&95!==i)return!1;o=i}return i-t-1<=63&&45!==o}var Ye=function({allowIcannDomains:e=!0,allowPrivateDomains:t=!1,detectIp:o=!0,extractHostname:i=!0,mixedInputs:n=!0,validHosts:s=null,validateHostname:c=!0}){return{allowIcannDomains:e,allowPrivateDomains:t,detectIp:o,extractHostname:i,mixedInputs:n,validHosts:s,validateHostname:c}}({});function Xe(e,t,o,i,n){const s=function(e){return void 0===e?Ye:function({allowIcannDomains:e=!0,allowPrivateDomains:t=!1,detectIp:o=!0,extractHostname:i=!0,mixedInputs:n=!0,validHosts:s=null,validateHostname:c=!0}){return{allowIcannDomains:e,allowPrivateDomains:t,detectIp:o,extractHostname:i,mixedInputs:n,validHosts:s,validateHostname:c}}(e)}(i);return"string"!=typeof e?n:(s.extractHostname?s.mixedInputs?n.hostname=$e(e,Qe(e)):n.hostname=$e(e,!1):n.hostname=e,0===t||null===n.hostname||s.detectIp&&(n.isIp=function(e){if(e.length<3)return!1;let t=e.startsWith("[")?1:0,o=e.length;if("]"===e[o-1]&&(o-=1),o-t>39)return!1;let i=!1;for(;t=48&&o<=57||o>=97&&o<=102||o>=65&&o<=90))return!1}return i}(c=n.hostname)||function(e){if(e.length<7)return!1;if(e.length>15)return!1;let t=0;for(let o=0;o57)return!1}return 3===t&&46!==e.charCodeAt(0)&&46!==e.charCodeAt(e.length-1)}(c),n.isIp)?n:s.validateHostname&&s.extractHostname&&!Qe(n.hostname)?(n.hostname=null,n):(o(n.hostname,s,n),2===t||null===n.publicSuffix?n:(n.domain=function(e,t,o){if(null!==o.validHosts){const e=o.validHosts;for(const o of e)if(function(e,t){return!!e.endsWith(t)&&(e.length===t.length||"."===e[e.length-t.length-1])}(t,o))return o}let i=0;if(t.startsWith("."))for(;i=i)return!1;let n=o,s=i-1;for(;n<=s;){const o=n+s>>>1,i=e[o];if(it))return!0;s=o-1}}return!1}var et=new Uint32Array(20);function tt(e,t,o){if(function(e,t,o){if(!t.allowPrivateDomains&&e.length>3){const t=e.length-1,i=e.charCodeAt(t),n=e.charCodeAt(t-1),s=e.charCodeAt(t-2),c=e.charCodeAt(t-3);if(109===i&&111===n&&99===s&&46===c)return o.isIcann=!0,o.isPrivate=!1,o.publicSuffix="com",!0;if(103===i&&114===n&&111===s&&46===c)return o.isIcann=!0,o.isPrivate=!1,o.publicSuffix="org",!0;if(117===i&&100===n&&101===s&&46===c)return o.isIcann=!0,o.isPrivate=!1,o.publicSuffix="edu",!0;if(118===i&&111===n&&103===s&&46===c)return o.isIcann=!0,o.isPrivate=!1,o.publicSuffix="gov",!0;if(116===i&&101===n&&110===s&&46===c)return o.isIcann=!0,o.isPrivate=!1,o.publicSuffix="net",!0;if(101===i&&100===n&&46===s)return o.isIcann=!0,o.isPrivate=!1,o.publicSuffix="de",!0}return!1}(e,t,o))return;const{allowIcannDomains:i,allowPrivateDomains:n}=t;let s=-1,c=0,r=0,a=1;const l=function(e,t){let o=5381,i=0;for(let n=e.length-1;n>=0;n-=1){const s=e.charCodeAt(n);if(46===s&&(et[i<<1]=o>>>0,et[1+(i<<1)]=n+1,i+=1,i===t))return i;o=33*o^s}return et[i<<1]=o>>>0,et[1+(i<<1)]=0,i+=1,i}(e,Ze[0]);for(let e=0;er;)t.shift();o.publicSuffix=t.join(".")}else o.publicSuffix=e.slice(s);else o.publicSuffix=1===l?e:e.slice(et[1])}function ot(e,t={}){return Xe(e,5,tt,t,{domain:null,domainWithoutSuffix:null,hostname:null,isIcann:null,isIp:null,isPrivate:null,publicSuffix:null,subdomain:null})}var it=new class{constructor(e){this.pos=0,this.buffer=new Uint32Array(e)}reset(){this.pos=0}slice(){return this.buffer.slice(0,this.pos)}push(e){this.buffer[this.pos++]=e}empty(){return 0===this.pos}full(){return this.pos===this.buffer.length}remaining(){return this.buffer.length-this.pos}}(1024),nt=37,st=5011;function ct(e){return 16843009*((e=(858993459&(e-=e>>1&1431655765))+(e>>2&858993459))+(e>>4)&252645135)>>24}function rt(e,t){return!!(e&t)}function at(e,t){return e|t}function lt(e,t){return e&~t}function pt(e,t,o){let i=st;for(let n=t;n>>0}function dt(e){return"string"!=typeof e||0===e.length?st:pt(e,0,e.length)}function ut(e){const t=new Uint32Array(e.length);let o=0;for(const i of e)t[o++]=dt(i);return t}function ht(e,t){if(e.length=48&&e<=57}function gt(e){return e>=97&&e<=122||e>=65&&e<=90}function ft(e){return gt(e)||At(e)||37===e||function(e){return e>=192&&e<=450}(e)||function(e){return e>=1024&&e<=1279}(e)}function kt(e,t,o,i){const n=Math.min(e.length,2*i.remaining());let s=!1,c=0,r=st;for(let o=0;o1&&(!1===t||0!==c)&&i.push(r>>>0))}!0===s&&!1===o&&e.length-c>1&&!1===i.full()&&i.push(r>>>0)}function bt(e,t){const o=Math.min(e.length,2*t.remaining());let i=!1,n=0,s=st;for(let c=0;c1&&t.push(s>>>0))}!0===i&&e.length-n>1&&!1===t.full()&&t.push(s>>>0)}function yt(e,t){return-1!==function(e,t){if(0===e.length)return-1;let o=0,i=e.length-1;for(;o<=i;){const n=o+i>>>1,s=e[n];if(st))return n;i=n-1}}return-1}(e,t)}var wt=/[^\u0000-\u00ff]/;function vt(e){return wt.test(e)}var _t={extractHostname:!0,mixedInputs:!1,validateHostname:!1},Ct={beacon:dt("type:beacon"),cspReport:dt("type:csp"),csp_report:dt("type:csp"),cspviolationreport:dt("type:cspviolationreport"),document:dt("type:document"),eventsource:dt("type:other"),fetch:dt("type:xhr"),font:dt("type:font"),image:dt("type:image"),imageset:dt("type:image"),mainFrame:dt("type:document"),main_frame:dt("type:document"),manifest:dt("type:other"),media:dt("type:media"),object:dt("type:object"),object_subrequest:dt("type:object"),other:dt("type:other"),ping:dt("type:ping"),prefetch:dt("type:other"),preflight:dt("type:preflight"),script:dt("type:script"),signedexchange:dt("type:signedexchange"),speculative:dt("type:other"),stylesheet:dt("type:stylesheet"),subFrame:dt("type:subdocument"),sub_frame:dt("type:subdocument"),texttrack:dt("type:other"),webSocket:dt("type:websocket"),web_manifest:dt("type:other"),websocket:dt("type:websocket"),xhr:dt("type:xhr"),xml_dtd:dt("type:other"),xmlhttprequest:dt("type:xhr"),xslt:dt("type:other")};function xt(e){let t=st;for(let o=e.length-1;o>=0;o-=1)t=t*nt^e.charCodeAt(o);return t>>>0}function St(e,t,o){it.reset();let i=st;for(let n=t-1;n>=0;n-=1){const t=e.charCodeAt(n);46===t&&n>>0),i=i*nt^t}return it.push(i>>>0),it.slice()}function Et(e,t){const o=function(e,t){let o=null;const i=t.indexOf(".");if(-1!==i){const n=t.slice(i+1);o=e.slice(0,-n.length-1)}return o}(e,t);return null!==o?St(o,o.length,o.length):Y}function It(e,t){return St(e,e.length,e.length-t.length)}var Ft=class e{static fromRawDetails({requestId:t="0",tabId:o=0,url:i="",hostname:n,domain:s,sourceUrl:c="",sourceHostname:r,sourceDomain:a,type:l="main_frame",_originalRequestDetails:p}){if(i=i.toLowerCase(),void 0===n||void 0===s){const e=ot(i,_t);n=n||e.hostname||"",s=s||e.domain||""}if(void 0===r||void 0===a){const e=ot(r||a||c,_t);r=r||e.hostname||"",a=a||e.domain||r||""}return new e({requestId:t,tabId:o,domain:s,hostname:n,url:i,sourceDomain:a,sourceHostname:r,sourceUrl:c,type:l,_originalRequestDetails:p})}constructor({requestId:e,tabId:t,type:o,domain:i,hostname:n,url:s,sourceDomain:c,sourceHostname:r,_originalRequestDetails:a}){if(this.tokens=void 0,this.hostnameHashes=void 0,this.entityHashes=void 0,this._originalRequestDetails=a,this.id=e,this.tabId=t,this.type=o,this.url=s,this.hostname=n,this.domain=i,this.sourceHostnameHashes=0===r.length?Y:It(r,c),this.sourceEntityHashes=0===r.length?Y:Et(r,c),this.isThirdParty=function(e,t,o,i,n){return"main_frame"!==n&&"mainFrame"!==n&&(0!==t.length&&0!==i.length?t!==i:0!==t.length&&0!==o.length?t!==o:0!==i.length&&0!==e.length&&e!==i)}(n,i,r,c,o),this.isFirstParty=!this.isThirdParty,this.isSupported=!0,"websocket"===this.type||this.url.startsWith("ws:")||this.url.startsWith("wss:"))this.isHttp=!1,this.isHttps=!1,this.type="websocket",this.isSupported=!0;else if(this.url.startsWith("http:"))this.isHttp=!0,this.isHttps=!1;else if(this.url.startsWith("https:"))this.isHttps=!0,this.isHttp=!1;else if(this.url.startsWith("data:")){this.isHttp=!1,this.isHttps=!1;const e=this.url.indexOf(",");-1!==e&&(this.url=this.url.slice(0,e))}else this.isHttp=!1,this.isHttps=!1,this.isSupported=!1}getHostnameHashes(){return void 0===this.hostnameHashes&&(this.hostnameHashes=0===this.hostname.length?Y:It(this.hostname,this.domain)),this.hostnameHashes}getEntityHashes(){return void 0===this.entityHashes&&(this.entityHashes=0===this.hostname.length?Y:Et(this.hostname,this.domain)),this.entityHashes}getTokens(){if(void 0===this.tokens){it.reset();for(const e of this.sourceHostnameHashes)it.push(e);it.push(Ct[this.type]),bt(this.url,it),this.tokens=it.slice()}return this.tokens}isMainFrame(){return"main_frame"===this.type||"mainFrame"===this.type}isSubFrame(){return"sub_frame"===this.type||"subFrame"===this.type}guessTypeOfRequest(){const e=this.type;return this.type=function(e){const t=function(e){let t=e.length;const o=e.indexOf("#");-1!==o&&(t=o);const i=e.indexOf("?");-1!==i&&i=0&&(s=e.charCodeAt(n),0!=(s>=65&&s<=90||s>=97&&s<=122||s>=48&&s<=57));n-=1);return 46!==s||n<0||t-n>=10?"":e.slice(n+1,t)}(e);return He.has(t)||e.startsWith("data:image/")||e.startsWith("https://frog.wix.com/bt")?"image":We.has(t)||e.startsWith("data:audio/")||e.startsWith("data:video/")?"media":Ge.has(t)||e.startsWith("data:text/css")?"stylesheet":qe.has(t)||e.startsWith("data:")&&(e.startsWith("data:application/ecmascript")||e.startsWith("data:application/javascript")||e.startsWith("data:application/x-ecmascript")||e.startsWith("data:application/x-javascript")||e.startsWith("data:text/ecmascript")||e.startsWith("data:text/javascript")||e.startsWith("data:text/javascript1.0")||e.startsWith("data:text/javascript1.1")||e.startsWith("data:text/javascript1.2")||e.startsWith("data:text/javascript1.3")||e.startsWith("data:text/javascript1.4")||e.startsWith("data:text/javascript1.5")||e.startsWith("data:text/jscript")||e.startsWith("data:text/livescript")||e.startsWith("data:text/x-ecmascript")||e.startsWith("data:text/x-javascript"))||e.startsWith("https://maps.googleapis.com/maps/api/js")||e.startsWith("https://www.googletagmanager.com/gtag/js")?"script":Me.has(t)||e.startsWith("data:text/html")||e.startsWith("data:application/xhtml")||e.startsWith("https://www.youtube.com/embed/")||e.startsWith("https://www.google.com/gen_204")?"document":De.has(t)||e.startsWith("data:font/")?"font":"other"}(this.url),e!==this.type&&(this.tokens=void 0),this.type}},Tt=class e{static parse(t,o=!1){if(0===t.length)return;const i=[],n=[],s=[],c=[];for(let e of t){vt(e)&&(e=K(e));const t=126===e.charCodeAt(0),o=42===e.charCodeAt(e.length-1)&&46===e.charCodeAt(e.length-2),r=t?1:0,a=o?e.length-2:e.length,l=xt(!0===t||!0===o?e.slice(r,a):e);t?o?n.push(l):c.push(l):o?i.push(l):s.push(l)}return new e({entities:0!==i.length?new Uint32Array(i).sort():void 0,hostnames:0!==s.length?new Uint32Array(s).sort():void 0,notEntities:0!==n.length?new Uint32Array(n).sort():void 0,notHostnames:0!==c.length?new Uint32Array(c).sort():void 0,parts:!0===o?t.join(","):void 0})}static deserialize(t){const o=t.getUint8();return new e({entities:1&~o?void 0:t.getUint32Array(),hostnames:2&~o?void 0:t.getUint32Array(),notEntities:4&~o?void 0:t.getUint32Array(),notHostnames:8&~o?void 0:t.getUint32Array(),parts:16&~o?void 0:t.getUTF8()})}constructor({entities:e,hostnames:t,notEntities:o,notHostnames:i,parts:n}){this.entities=e,this.hostnames=t,this.notEntities=o,this.notHostnames=i,this.parts=n}updateId(e){const{hostnames:t,entities:o,notHostnames:i,notEntities:n}=this;if(void 0!==t)for(const o of t)e=e*nt^o;if(void 0!==o)for(const t of o)e=e*nt^t;if(void 0!==i)for(const t of i)e=e*nt^t;if(void 0!==n)for(const t of n)e=e*nt^t;return e}serialize(e){const t=e.getPos();e.pushUint8(0);let o=0;void 0!==this.entities&&(o|=1,e.pushUint32Array(this.entities)),void 0!==this.hostnames&&(o|=2,e.pushUint32Array(this.hostnames)),void 0!==this.notEntities&&(o|=4,e.pushUint32Array(this.notEntities)),void 0!==this.notHostnames&&(o|=8,e.pushUint32Array(this.notHostnames)),void 0!==this.parts&&(o|=16,e.pushUTF8(this.parts)),e.setByte(t,o)}getSerializedSize(){let e=1;return void 0!==this.entities&&(e+=ne(this.entities)),void 0!==this.hostnames&&(e+=ne(this.hostnames)),void 0!==this.notHostnames&&(e+=ne(this.notHostnames)),void 0!==this.notEntities&&(e+=ne(this.notEntities)),void 0!==this.parts&&(e+=ie(this.parts)),e}match(e,t){if(void 0!==this.notHostnames)for(const t of e)if(yt(this.notHostnames,t))return!1;if(void 0!==this.notEntities)for(const e of t)if(yt(this.notEntities,e))return!1;if(void 0!==this.hostnames||void 0!==this.entities){if(void 0!==this.hostnames)for(const t of e)if(yt(this.hostnames,t))return!0;if(void 0!==this.entities)for(const e of t)if(yt(this.entities,e))return!0;return!1}return!0}};function Ot(e){if(!1===e.startsWith("^script"))return;const t=":has-text(",o=[];let i=7;for(;e.startsWith(t,i);){i+=10;let t=1;const n=i;let s=-1;for(;i=48&&o<=57||o>=65&&o<=90||o>=97&&o<=122)){if(t{}},t=/^[#.]?[\w-.]+$/;return function(o){if(t.test(o))return!0;try{(t=>{e.matches(t)})(o)}catch(e){return!1}return!0}})();function Dt(e,t){const o=e.getSelector();if(!1===e.isScriptInject())return o;const i=e.parseScript();if(void 0===i)return o;const n=t(i.name);return void 0===n?o:o.replace(i.name,n)}(jt=Vt||(Vt={}))[jt.unhide=1]="unhide",jt[jt.scriptInject=2]="scriptInject",jt[jt.isUnicode=4]="isUnicode",jt[jt.isClassSelector=8]="isClassSelector",jt[jt.isIdSelector=16]="isIdSelector",jt[jt.isHrefSelector=32]="isHrefSelector",jt[jt.remove=64]="remove",jt[jt.extended=128]="extended";var Ht=class e{static parse(t,o=!1){const i=t;let n,s,c,r=0;const a=t.indexOf("#"),l=a+1;let p=l+1;if(t.length>l&&("@"===t[l]?(r=at(r,Vt.unhide),p+=1):"?"===t[l]&&(p+=1)),p>=t.length)return null;if(a>0&&(s=Tt.parse(t.slice(0,a).split(","),o)),t.endsWith(":remove()"))r=at(r,Vt.remove),r=at(r,Vt.extended),t=t.slice(0,-9);else if(t.length-p>=8&&t.endsWith(")")&&-1!==t.indexOf(":style(",p)){const e=t.indexOf(":style(",p);c=t.slice(e+7,-1),t=t.slice(0,e)}if(94===t.charCodeAt(p)){if(!1===mt(t,"script:has-text(",p+1)||41!==t.charCodeAt(t.length-1))return null;if(n=t.slice(p,t.length),void 0===Ot(n))return null}else if(t.length-p>4&&43===t.charCodeAt(p)&&mt(t,"+js(",p)){if((void 0===s||void 0===s.hostnames&&void 0===s.entities)&&!1===rt(r,Vt.unhide))return null;if(r=at(r,Vt.scriptInject),n=t.slice(p+4,t.length-1),!1===rt(r,Vt.unhide)&&0===n.length)return null}else{n=t.slice(p);const e=je(n);if(e===Le.Extended)r=at(r,Vt.extended);else if(e===Le.Invalid||!Mt(n))return null}if(void 0===s&&!0===rt(r,Vt.extended))return null;if(void 0!==n&&(vt(n)&&(r=at(r,Vt.isUnicode)),!1===rt(r,Vt.scriptInject)&&!1===rt(r,Vt.remove)&&!1===rt(r,Vt.extended)&&!1===n.startsWith("^"))){const e=n.charCodeAt(0),t=n.charCodeAt(1),o=n.charCodeAt(2);!1===rt(r,Vt.scriptInject)&&(46===e&&Ut(n)?r=at(r,Vt.isClassSelector):35===e&&Ut(n)?r=at(r,Vt.isIdSelector):(97===e&&91===t&&104===o&&Nt(n,2)||91===e&&104===t&&Nt(n,1))&&(r=at(r,Vt.isHrefSelector)))}return new e({mask:r,rawLine:!0===o?i:void 0,selector:n,style:c,domains:s})}static deserialize(t){const o=t.getUint8(),i=rt(o,Vt.isUnicode),n=t.getUint8(),s=i?t.getUTF8():t.getCosmeticSelector();return new e({mask:o,selector:s,domains:1&~n?void 0:Tt.deserialize(t),rawLine:2&~n?void 0:t.getRawCosmetic(),style:4&~n?void 0:t.getASCII()})}constructor({mask:e,selector:t,domains:o,rawLine:i,style:n}){this.mask=e,this.selector=t,this.domains=o,this.style=n,this.id=void 0,this.rawLine=i,this.scriptletDetails=void 0}isCosmeticFilter(){return!0}isNetworkFilter(){return!1}serialize(e){e.pushUint8(this.mask);const t=e.getPos();e.pushUint8(0),this.isUnicode()?e.pushUTF8(this.selector):e.pushCosmeticSelector(this.selector);let o=0;void 0!==this.domains&&(o|=1,this.domains.serialize(e)),void 0!==this.rawLine&&(o|=2,e.pushRawCosmetic(this.rawLine)),void 0!==this.style&&(o|=4,e.pushASCII(this.style)),e.setByte(t,o)}getSerializedSize(e){let t=2;return this.isUnicode()?t+=ie(this.selector):t+=function(e,t){return!0===t?te(Z().cosmeticSelector.getCompressedSize(e),!1):oe(e)}(this.selector,e),void 0!==this.domains&&(t+=this.domains.getSerializedSize()),void 0!==this.rawLine&&(t+=function(e,t){return!0===t?te(Z().cosmeticRaw.getCompressedSize($(e)),!1):ie(e)}(this.rawLine,e)),void 0!==this.style&&(t+=oe(this.style)),t}toString(){if(void 0!==this.rawLine)return this.rawLine;let e="";return void 0!==this.domains&&(void 0!==this.domains.parts?e+=this.domains.parts:e+=""),this.isUnhide()?e+="#@#":e+="##",this.isScriptInject()?(e+="+js(",e+=this.selector,e+=")"):e+=this.selector,e}match(e,t){return!1===this.hasHostnameConstraint()||!(!e&&this.hasHostnameConstraint())&&(void 0===this.domains||this.domains.match(0===e.length?Y:It(e,t),0===e.length?Y:Et(e,t)))}getTokens(){const e=[];if(void 0!==this.domains){const{hostnames:t,entities:o}=this.domains;if(void 0!==t)for(const o of t)e.push(new Uint32Array([o]));if(void 0!==o)for(const t of o)e.push(new Uint32Array([t]))}if(0===e.length&&!1===this.isUnhide())if(this.isIdSelector()||this.isClassSelector()){let t=1;const o=this.selector;for(;t0?n=!0:"'"===p&&e.indexOf("'",o+1)>0?s=!0:"{"===p&&e.indexOf("}",o+1)>0?r+=1:"/"===p&&e.indexOf("/",o+1)>0?c=!0:l=!0)),","===p&&(t.push(e.slice(i+1,o).trim()),i=o,l=!1))),a="\\"===p}if(t.push(e.slice(i+1).trim()),0===t.length)return;const p=t.slice(1).map((e=>e.startsWith("'")&&e.endsWith("'")||e.startsWith('"')&&e.endsWith('"')?e.substring(1,e.length-1):e)).map((e=>e.replace(zt,",").replace(Lt,"\\").replace(Bt,",")));return this.scriptletDetails={name:t[0],args:p},this.scriptletDetails}getScript(e){const t=this.parseScript();if(void 0===t)return;const{name:o,args:i}=t;let n=e(o);if(void 0!==n){for(let e=0;e>>0}(this.mask,this.selector,this.domains,this.style)),this.id}hasCustomStyle(){return void 0!==this.style}getStyle(e=Rt){return this.style||e}getStyleAttributeHash(){return`s${dt(this.getStyle())}`}getSelector(){return this.selector}getSelectorAST(){return ze(this.getSelector())}getExtendedSelector(){return Ot(this.selector)}isExtended(){return rt(this.mask,Vt.extended)}isRemove(){return rt(this.mask,Vt.remove)}isUnhide(){return rt(this.mask,Vt.unhide)}isScriptInject(){return rt(this.mask,Vt.scriptInject)}isCSS(){return!1===this.isScriptInject()}isIdSelector(){return rt(this.mask,Vt.isIdSelector)}isClassSelector(){return rt(this.mask,Vt.isClassSelector)}isHrefSelector(){return rt(this.mask,Vt.isHrefSelector)}isUnicode(){return rt(this.mask,Vt.isUnicode)}isHtmlFiltering(){return this.getSelector().startsWith("^")}isGenericHide(){var e,t;return void 0===(null===(e=null==this?void 0:this.domains)||void 0===e?void 0:e.hostnames)&&void 0===(null===(t=null==this?void 0:this.domains)||void 0===t?void 0:t.entities)}},Wt=class{constructor(){this.options=new Set,this.prefix=void 0,this.infix=void 0,this.suffix=void 0,this.redirect=void 0}blockRequestsWithType(e){if(this.options.has(e))throw new Error(`Already blocking type ${e}`);return this.options.add(e),this}images(){return this.blockRequestsWithType("image")}scripts(){return this.blockRequestsWithType("script")}frames(){return this.blockRequestsWithType("frame")}fonts(){return this.blockRequestsWithType("font")}medias(){return this.blockRequestsWithType("media")}styles(){return this.blockRequestsWithType("css")}redirectTo(e){if(void 0!==this.redirect)throw new Error(`Already redirecting: ${this.redirect}`);return this.redirect=`redirect=${e}`,this}urlContains(e){if(void 0!==this.infix)throw new Error(`Already matching pattern: ${this.infix}`);return this.infix=e,this}urlStartsWith(e){if(void 0!==this.prefix)throw new Error(`Already matching prefix: ${this.prefix}`);return this.prefix=`|${e}`,this}urlEndsWith(e){if(void 0!==this.suffix)throw new Error(`Already matching suffix: ${this.suffix}`);return this.suffix=`${e}|`,this}withHostname(e){if(void 0!==this.prefix)throw new Error(`Cannot match hostname if filter already has prefix: ${this.prefix}`);return this.prefix=`||${e}^`,this}toString(){const e=[];void 0!==this.prefix&&e.push(this.prefix),void 0!==this.infix&&e.push(this.infix),void 0!==this.suffix&&e.push(this.suffix);const t=["important"];if(0!==this.options.size)for(const e of this.options)t.push(e);return void 0!==this.redirect&&t.push(this.redirect),`${0===e.length?"*":e.join("*")}$${t.join(",")}`}};function qt(){return new Wt}var Gt,$t,Kt=dt("http"),Qt=dt("https");($t=Gt||(Gt={}))[$t.fromDocument=1]="fromDocument",$t[$t.fromFont=2]="fromFont",$t[$t.fromHttp=4]="fromHttp",$t[$t.fromHttps=8]="fromHttps",$t[$t.fromImage=16]="fromImage",$t[$t.fromMedia=32]="fromMedia",$t[$t.fromObject=64]="fromObject",$t[$t.fromOther=128]="fromOther",$t[$t.fromPing=256]="fromPing",$t[$t.fromScript=512]="fromScript",$t[$t.fromStylesheet=1024]="fromStylesheet",$t[$t.fromSubdocument=2048]="fromSubdocument",$t[$t.fromWebsocket=4096]="fromWebsocket",$t[$t.fromXmlHttpRequest=8192]="fromXmlHttpRequest",$t[$t.firstParty=16384]="firstParty",$t[$t.thirdParty=32768]="thirdParty",$t[$t.isReplace=65536]="isReplace",$t[$t.isBadFilter=131072]="isBadFilter",$t[$t.isCSP=262144]="isCSP",$t[$t.isGenericHide=524288]="isGenericHide",$t[$t.isImportant=1048576]="isImportant",$t[$t.isSpecificHide=2097152]="isSpecificHide",$t[$t.isFullRegex=4194304]="isFullRegex",$t[$t.isRegex=8388608]="isRegex",$t[$t.isUnicode=16777216]="isUnicode",$t[$t.isLeftAnchor=33554432]="isLeftAnchor",$t[$t.isRightAnchor=67108864]="isRightAnchor",$t[$t.isException=134217728]="isException",$t[$t.isHostnameAnchor=268435456]="isHostnameAnchor",$t[$t.isRedirectRule=536870912]="isRedirectRule",$t[$t.isRedirect=1073741824]="isRedirect";var Yt=Gt.fromDocument|Gt.fromFont|Gt.fromImage|Gt.fromMedia|Gt.fromObject|Gt.fromOther|Gt.fromPing|Gt.fromScript|Gt.fromStylesheet|Gt.fromSubdocument|Gt.fromWebsocket|Gt.fromXmlHttpRequest,Xt={beacon:Gt.fromPing,document:Gt.fromDocument,cspviolationreport:Gt.fromOther,fetch:Gt.fromXmlHttpRequest,font:Gt.fromFont,image:Gt.fromImage,imageset:Gt.fromImage,mainFrame:Gt.fromDocument,main_frame:Gt.fromDocument,media:Gt.fromMedia,object:Gt.fromObject,object_subrequest:Gt.fromObject,ping:Gt.fromPing,script:Gt.fromScript,stylesheet:Gt.fromStylesheet,subFrame:Gt.fromSubdocument,sub_frame:Gt.fromSubdocument,webSocket:Gt.fromWebsocket,websocket:Gt.fromWebsocket,xhr:Gt.fromXmlHttpRequest,xmlhttprequest:Gt.fromXmlHttpRequest,cspReport:Gt.fromOther,csp_report:Gt.fromOther,eventsource:Gt.fromOther,manifest:Gt.fromOther,other:Gt.fromOther,prefetch:Gt.fromOther,preflight:Gt.fromOther,signedexchange:Gt.fromOther,speculative:Gt.fromOther,texttrack:Gt.fromOther,web_manifest:Gt.fromOther,xml_dtd:Gt.fromOther,xslt:Gt.fromOther};function Zt(e){const t=[];return e.fromDocument()&&t.push("document"),e.fromImage()&&t.push("image"),e.fromMedia()&&t.push("media"),e.fromObject()&&t.push("object"),e.fromOther()&&t.push("other"),e.fromPing()&&t.push("ping"),e.fromScript()&&t.push("script"),e.fromStylesheet()&&t.push("stylesheet"),e.fromSubdocument()&&t.push("sub_frame"),e.fromWebsocket()&&t.push("websocket"),e.fromXmlHttpRequest()&&t.push("xhr"),e.fromFont()&&t.push("font"),t}function Jt(e,t,o,i,n,s){let c=185407^e;if(void 0!==i&&(c=i.updateId(c)),void 0!==n&&(c=n.updateId(c)),void 0!==t)for(let e=0;e>>0}function eo(e,t,o,i){return!0===i?new RegExp(e.slice(1,e.length-1),"i"):(e=(e=(e=e.replace(/([|.$+?{}()[\]\\])/g,"\\$1")).replace(/\*/g,".*")).replace(/\^/g,"(?:[^\\w\\d_.%-]|$)"),o&&(e=`${e}$`),t&&(e=`^${e}`),new RegExp(e))}function to(e,t,o){const i=t;for(;t=48&&e<=57||e<=65&&e<=70||e>=97&&e<=102}function so(e,t,o){const i=e.charCodeAt(t+1);return 44===i||47===i?[t+1,!1]:function(e,t){const o=e.charCodeAt(t+1);if(44===o||io.has(o))return[t+1,!0];if(99===o){const o=e.charCodeAt(t+2);if(o>=65&&o<=90||o>=97&&o<=122)return[t+2,!0]}if(120===o&&no(e.charCodeAt(t+2))&&no(e.charCodeAt(t+3)))return[t+3,!0];if(117===o)if(123===e.charCodeAt(t+2)){const o=e.indexOf("}",t+3),i=o-t+3;if(i>=1&&i<=6)return[o,!0]}else if(no(e.charCodeAt(t+2))&&no(e.charCodeAt(t+3))&&no(e.charCodeAt(t+4))&&no(e.charCodeAt(t+5)))return[t+5,!0];return[t+1,!1]}(e,t)}function co(e,t,o){if(47!==e.charCodeAt(t++))return[o,void 0];const i=["","",""];let n=t,s=0;for(;t0&&92===e.charCodeAt(o-1);)o=e.lastIndexOf(t,o-1);return o}(t,"$");if(-1!==u&&47!==t.charCodeAt(u+1)){d=u;for(const e of function(e,t,o){const i=[];let n,s;for(;t0&&(c=p);break;case"ehide":case"elemhide":if(t)return null;r=at(r,Gt.isGenericHide),r=at(r,Gt.isSpecificHide);break;case"shide":case"specifichide":if(t)return null;r=at(r,Gt.isSpecificHide);break;case"ghide":case"generichide":if(t)return null;r=at(r,Gt.isGenericHide);break;case"inline-script":if(t)return null;r=at(r,Gt.isCSP),c="script-src 'self' 'unsafe-eval' http: https: data: blob: mediastream: filesystem:";break;case"inline-font":if(t)return null;r=at(r,Gt.isCSP),c="font-src 'self' 'unsafe-eval' http: https: data: blob: mediastream: filesystem:";break;case"replace":case"content":if(t||(0===p.length?!1===rt(r,Gt.isException):null===ro(p)))return null;r=at(r,Gt.isReplace),c=p;break;default:{let e=0;switch(i){case"all":if(t)return null;break;case"image":e=Gt.fromImage;break;case"media":e=Gt.fromMedia;break;case"object":case"object-subrequest":e=Gt.fromObject;break;case"other":e=Gt.fromOther;break;case"ping":case"beacon":e=Gt.fromPing;break;case"script":e=Gt.fromScript;break;case"css":case"stylesheet":e=Gt.fromStylesheet;break;case"frame":case"subdocument":e=Gt.fromSubdocument;break;case"xhr":case"xmlhttprequest":e=Gt.fromXmlHttpRequest;break;case"websocket":e=Gt.fromWebsocket;break;case"font":e=Gt.fromFont;break;case"doc":case"document":e=Gt.fromDocument;break;default:return null}t?l=lt(l,e):a=at(a,e);break}}}}let h;if(r|=0===a?l:l===Yt?a:a&l,d-p>=2&&47===t.charCodeAt(p)&&47===t.charCodeAt(d-1)){h=t.slice(p,d);try{eo(h,!1,!1,!0)}catch(e){return null}r=at(r,Gt.isFullRegex)}else{if(d>0&&124===t.charCodeAt(d-1)&&(r=at(r,Gt.isRightAnchor),d-=1),p0&&42===t.charCodeAt(d-1)&&(d-=1),!1===rt(r,Gt.isHostnameAnchor)&&d-p>0&&42===t.charCodeAt(p)&&(r=lt(r,Gt.isLeftAnchor),p+=1),rt(r,Gt.isLeftAnchor)&&(d-p==5&&mt(t,"ws://",p)?(r=at(r,Gt.fromWebsocket),r=lt(r,Gt.isLeftAnchor),r=lt(r,Gt.fromHttp),r=lt(r,Gt.fromHttps),p=d):d-p==7&&mt(t,"http://",p)?(r=at(r,Gt.fromHttp),r=lt(r,Gt.fromHttps),r=lt(r,Gt.isLeftAnchor),p=d):d-p==8&&mt(t,"https://",p)?(r=at(r,Gt.fromHttps),r=lt(r,Gt.fromHttp),r=lt(r,Gt.isLeftAnchor),p=d):d-p==8&&mt(t,"http*://",p)&&(r=at(r,Gt.fromHttps),r=at(r,Gt.fromHttp),r=lt(r,Gt.isLeftAnchor),p=d)),d-p>0&&(h=t.slice(p,d).toLowerCase(),r=po(r,Gt.isUnicode,vt(h)),!1===rt(r,Gt.isRegex)&&(r=po(r,Gt.isRegex,function(e,t,o){const i=e.indexOf("^",t);if(-1!==i&&it.length)return!1;if(e.length===t.length)return e===t;const i=t.indexOf(e);if(-1===i)return!1;if(0===i)return!0===o||46===t.charCodeAt(e.length)||46===e.charCodeAt(e.length-1);if(t.length===i+e.length)return 46===t.charCodeAt(i-1)||46===e.charCodeAt(0);return!(!0!==o&&46!==t.charCodeAt(e.length)&&46!==e.charCodeAt(e.length-1)||46!==t.charCodeAt(i-1)&&46!==e.charCodeAt(0))}(i,t.hostname,void 0!==e.filter&&42===e.filter.charCodeAt(0)))return!1;if(e.isRegex())return e.getRegex().test(t.url.slice(t.url.indexOf(i)+i.length));if(e.isRightAnchor()&&e.isLeftAnchor()){return o===t.url.slice(t.url.indexOf(i)+i.length)}if(e.isRightAnchor()){const n=t.hostname;return!1===e.hasFilter()?i.length===n.length||n.endsWith(i):t.url.endsWith(o)}return e.isLeftAnchor()?mt(t.url,o,t.url.indexOf(i)+i.length):!1===e.hasFilter()||-1!==t.url.indexOf(o,t.url.indexOf(i)+i.length)}if(e.isRegex())return e.getRegex().test(t.url);if(e.isLeftAnchor()&&e.isRightAnchor())return t.url===o;if(e.isLeftAnchor())return ht(t.url,o);if(e.isRightAnchor())return t.url.endsWith(o);if(!1===e.hasFilter())return!0;return-1!==t.url.indexOf(o)}(this,e)}serialize(e){e.pushUint32(this.mask);const t=e.getPos();e.pushUint8(0);let o=0;void 0!==this.filter&&(o|=1,this.isUnicode()?e.pushUTF8(this.filter):e.pushNetworkFilter(this.filter)),void 0!==this.hostname&&(o|=2,e.pushNetworkHostname(this.hostname)),void 0!==this.domains&&(o|=4,this.domains.serialize(e)),void 0!==this.rawLine&&(o|=8,e.pushRawNetwork(this.rawLine)),void 0!==this.denyallow&&(o|=16,this.denyallow.serialize(e)),void 0!==this.optionValue&&(o|=32,this.isCSP()?e.pushNetworkCSP(this.optionValue):this.isRedirect()?e.pushNetworkRedirect(this.optionValue):e.pushUTF8(this.optionValue)),e.setByte(t,o)}getSerializedSize(e){let t=5;return void 0!==this.filter&&(!0===this.isUnicode()?t+=ie(this.filter):t+=function(e,t){return!0===t?te(Z().networkFilter.getCompressedSize(e),!1):oe(e)}(this.filter,e)),void 0!==this.hostname&&(t+=function(e,t){return!0===t?te(Z().networkHostname.getCompressedSize(e),!1):oe(e)}(this.hostname,e)),void 0!==this.domains&&(t+=this.domains.getSerializedSize()),void 0!==this.rawLine&&(t+=function(e,t){return!0===t?te(Z().networkRaw.getCompressedSize($(e)),!1):ie(e)}(this.rawLine,e)),void 0!==this.denyallow&&(t+=this.denyallow.getSerializedSize()),void 0!==this.optionValue&&(this.isCSP()?t+=function(e,t){return!0===t?te(Z().networkCSP.getCompressedSize(e),!1):oe(e)}(this.optionValue,e):this.isRedirect()?t+=function(e,t){return!0===t?te(Z().networkRedirect.getCompressedSize(e),!1):oe(e)}(this.optionValue,e):t+=ie(this.optionValue)),t}toString(e){if(void 0!==this.rawLine)return this.rawLine;let t="";this.isException()&&(t+="@@"),this.isHostnameAnchor()?t+="||":this.fromHttp()!==this.fromHttps()?this.fromHttp()?t+="|http://":t+="|https://":this.isLeftAnchor()&&(t+="|"),this.hasHostname()&&(t+=this.getHostname(),t+="^"),this.isFullRegex()?t+=`/${this.getRegex().source}/`:this.isRegex()?t+=this.getRegex().source:t+=this.getFilter(),this.isRightAnchor()&&"^"!==t[t.length-1]&&(t+="|");const o=[];if(!1===this.fromAny()){const e=ct(this.getCptMask());if(ct(Yt)-e")),void 0!==this.denyallow&&(void 0!==this.denyallow.parts?o.push(`denyallow=${this.denyallow.parts}`):o.push("denyallow=")),this.isBadFilter()&&o.push("badfilter"),o.length>0&&(t+="function"==typeof e?`$${o.map(e).join(",")}`:`$${o.join(",")}`),t}getIdWithoutBadFilter(){return Jt(this.mask&~Gt.isBadFilter,this.filter,this.hostname,this.domains,this.denyallow,this.optionValue)}getId(){return void 0===this.id&&(this.id=Jt(this.mask,this.filter,this.hostname,this.domains,this.denyallow,this.optionValue)),this.id}hasFilter(){return void 0!==this.filter}hasDomains(){return void 0!==this.domains}getMask(){return this.mask}getCptMask(){return this.getMask()&Yt}isRedirect(){return rt(this.getMask(),Gt.isRedirect)}isRedirectRule(){return rt(this.mask,Gt.isRedirectRule)}getRedirect(){var e;return null!==(e=this.optionValue)&&void 0!==e?e:""}isReplace(){return rt(this.getMask(),Gt.isReplace)}getHtmlModifier(){var e;return 0===(null===(e=this.optionValue)||void 0===e?void 0:e.length)?null:ro(this.optionValue)}isHtmlFilteringRule(){return this.isReplace()}getRedirectResource(){const e=this.getRedirect(),t=e.lastIndexOf(":");return-1===t?e:e.slice(0,t)}getRedirectPriority(){const e=this.getRedirect(),t=e.lastIndexOf(":");return-1===t?0:Number(e.slice(t+1))}hasHostname(){return void 0!==this.hostname}getHostname(){return this.hostname||""}getFilter(){return this.filter||""}getRegex(){return void 0===this.regex&&(this.regex=void 0!==this.filter&&this.isRegex()?eo(this.filter,this.isLeftAnchor(),this.isRightAnchor(),this.isFullRegex()):ao),this.regex}getTokens(){if(it.reset(),void 0!==this.domains&&void 0!==this.domains.hostnames&&void 0===this.domains.entities&&void 0===this.domains.notHostnames&&void 0===this.domains.notEntities&&1===this.domains.hostnames.length&&it.push(this.domains.hostnames[0]),!1===this.isFullRegex()){if(void 0!==this.filter){const e=!this.isRightAnchor(),t=!this.isLeftAnchor();!function(e,t,o,i){const n=Math.min(e.length,2*i.remaining());let s=!1,c=0,r=0,a=st;for(let o=0;o1&&42!==n&&42!==c&&(!1===t||0!==r)&&i.push(a>>>0)),c=n)}!1===o&&!0===s&&42!==c&&e.length-r>1&&!1===i.full()&&i.push(a>>>0)}(this.filter,t,e,it)}void 0!==this.hostname&&kt(this.hostname,!1,void 0!==this.filter&&42===this.filter.charCodeAt(0),it)}else void 0!==this.filter&&function(e,t){let o=e.length-1,i=1,n=0;for(;i=i;o-=1){const t=e.charCodeAt(o);if(124===t)return;if(41===t||42===t||43===t||63===t||93===t||125===t||46===t&&92!==e.charCodeAt(o-1)||92===t&>(n))break;n=t}if(o1&&kt(e.slice(1,i),94!==e.charCodeAt(1),!0,t),oObject.prototype.hasOwnProperty.call(yo,e),vo=(e,t)=>"true"===e&&!t.has("true")||!("false"===e&&!t.has("false"))&&!!t.get(e),_o=(e,t)=>{if(0===e.length)return!1;if((e=>bo.test(e))(e))return"!"===e[0]?!vo(e.slice(1),t):vo(e,t);const o=(e=>e.match(ko))(e);if(!o||0===o.length)return!1;if(e.length!==o.reduce(((e,t)=>e+t.length),0))return!1;const i=[],n=[];for(const e of o)if("("===e)n.push(e);else if(")"===e){for(;0!==n.length&&"("!==n[n.length-1];)i.push(n.pop());if(0===n.length)return!1;n.pop()}else if(wo(e)){for(;n.length&&wo(n[n.length-1])&&yo[e]<=yo[n[n.length-1]];)i.push(n.pop());n.push(e)}else i.push(vo(e,t));if("("===n[0]||")"===n[0])return!1;for(;0!==n.length;)i.push(n.pop());for(const e of i)if(!0===e||!1===e)n.push(e);else if("!"===e)n.push(!n.pop());else if(wo(e)){const t=n.pop(),o=n.pop();"&&"===e?n.push(o&&t):n.push(o||t)}return!0===n[0]},Co=class e{static getCondition(e){return e.slice(5).replace(/\s/g,"")}static parse(t,o){return new this({condition:e.getCondition(t),filterIDs:o})}static deserialize(e){const t=e.getUTF8(),o=new Set;for(let t=0,i=e.getUint32();t2)for(;e4&&32===t.charCodeAt(0)&&32===t.charCodeAt(1)&&32===t.charCodeAt(2)&&32===t.charCodeAt(3)&&32!==t.charCodeAt(4)))break;a+=t.slice(4),e+=1}0!==a.length&&a.charCodeAt(a.length-1)<=32&&(a=a.trim());const l=xo(a,{extendedNonSupportedTypes:!0});if(l===go.NETWORK&&!0===t.loadNetworkFilters){const i=lo.parse(a,t.debug);null!==i?(o.push(i),r.length>0&&r[r.length-1].filterIDs.add(i.getId())):n.push({lineNumber:e,filter:a,filterType:l})}else if(l===go.COSMETIC&&!0===t.loadCosmeticFilters){const o=Ht.parse(a,t.debug);null!==o?!0!==t.loadGenericCosmeticsFilters&&!1!==o.isGenericHide()||(i.push(o),r.length>0&&r[r.length-1].filterIDs.add(o.getId())):n.push({lineNumber:e,filter:a,filterType:go.COSMETIC})}else if(t.loadPreprocessors){const t=Ao(a);if(t===uo.BEGIF)r.length>0?r.push(new Co({condition:`(${r[r.length-1].condition})&&(${Co.getCondition(a)})`})):r.push(Co.parse(a));else if((t===uo.ENDIF||t===uo.ELSE)&&r.length>0){const e=r.pop();c.push(e),t===uo.ELSE&&r.push(new Co({condition:`!(${e.condition})`}))}else l===go.NOT_SUPPORTED_ADGUARD&&n.push({lineNumber:e,filter:a,filterType:l})}else l===go.NOT_SUPPORTED_ADGUARD&&n.push({lineNumber:e,filter:a,filterType:l})}return{networkFilters:o,cosmeticFilters:i,preprocessors:c.filter((e=>e.filterIDs.size>0)),notSupportedFilters:n}}(fo=go||(go={}))[fo.NOT_SUPPORTED=0]="NOT_SUPPORTED",fo[fo.NETWORK=1]="NETWORK",fo[fo.COSMETIC=2]="COSMETIC",fo[fo.NOT_SUPPORTED_EMPTY=100]="NOT_SUPPORTED_EMPTY",fo[fo.NOT_SUPPORTED_COMMENT=101]="NOT_SUPPORTED_COMMENT",fo[fo.NOT_SUPPORTED_ADGUARD=102]="NOT_SUPPORTED_ADGUARD";var Eo="video/flv",Io={contentType:`${Eo};base64`,aliases:[Eo,".flv","flv"],body:"RkxWAQEAAAAJAAAAABIAALgAAAAAAAAAAgAKb25NZXRhRGF0YQgAAAAIAAhkdXJhdGlvbgAAAAAAAAAAAAAFd2lkdGgAP/AAAAAAAAAABmhlaWdodAA/8AAAAAAAAAANdmlkZW9kYXRhcmF0ZQBAaGoAAAAAAAAJZnJhbWVyYXRlAEBZAAAAAAAAAAx2aWRlb2NvZGVjaWQAQAAAAAAAAAAAB2VuY29kZXICAA1MYXZmNTcuNDEuMTAwAAhmaWxlc2l6ZQBAaoAAAAAAAAAACQAAAMM="},Fo="image/gif",To={contentType:`${Fo};base64`,aliases:[Fo,".gif","gif"],body:"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"},Oo="text/html",Po={contentType:Oo,aliases:[Oo,".html","html",".htm","htm","noopframe","noop.html"],body:""},Ro="image/vnd.microsoft.icon",zo={contentType:`${Ro};base64`,aliases:[Ro,".ico","ico"],body:"AAABAAEAAQEAAAEAGAAwAAAAFgAAACgAAAABAAAAAgAAAAEAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAA=="},Lo="image/jpeg",Bo={contentType:`${Lo};base64`,aliases:[Lo,".jpg","jpg",".jpeg","jpeg"],body:"/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k="},Uo="application/javascript",No={contentType:Uo,aliases:[Uo,".js","js","javascript",".jsx","jsx","typescript",".ts","ts","noop.js","noopjs"],body:""},Vo="application/json",jo={contentType:Vo,aliases:[Vo,".json","json"],body:"0"},Mo="audio/mpeg",Do={contentType:`${Mo};base64`,aliases:[Mo,".mp3","mp3","noop-0.1s.mp3","noopmp3-0.1s"],body:"/+MYxAAAAANIAAAAAExBTUUzLjk4LjIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},Ho="video/mp4",Wo={contentType:`${Ho};base64`,aliases:[Ho,".mp4","mp4",".m4a","m4a",".m4p","m4p",".m4b","m4b",".m4r","m4r",".m4v","m4v","noop-1s.mp4","noopmp4-1s"],body:"AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAC721kYXQhEAUgpBv/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3pwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcCEQBSCkG//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADengAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAsJtb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAALwABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAB7HRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAIAAAAAAAAALwAAAAAAAAAAAAAAAQEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAC8AAAAAAAEAAAAAAWRtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAAKxEAAAIAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAAEPbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAADTc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAAKxEAAAAAAAzZXNkcwAAAAADgICAIgACAASAgIAUQBUAAAAAAfQAAAHz+QWAgIACEhAGgICAAQIAAAAYc3R0cwAAAAAAAAABAAAAAgAABAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAIAAAABAAAAHHN0c3oAAAAAAAAAAAAAAAIAAAFzAAABdAAAABRzdGNvAAAAAAAAAAEAAAAsAAAAYnVkdGEAAABabWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAbWRpcmFwcGwAAAAAAAAAAAAAAAAtaWxzdAAAACWpdG9vAAAAHWRhdGEAAAABAAAAAExhdmY1Ni40MC4xMDE="},qo="application/pdf",Go={contentType:`${qo};base64`,aliases:[qo,".pdf","pdf"],body:"JVBERi0xLgoxIDAgb2JqPDwvUGFnZXMgMiAwIFI+PmVuZG9iagoyIDAgb2JqPDwvS2lkc1szIDAgUl0vQ291bnQgMT4+ZW5kb2JqCjMgMCBvYmo8PC9QYXJlbnQgMiAwIFI+PmVuZG9iagp0cmFpbGVyIDw8L1Jvb3QgMSAwIFI+Pg=="},$o="image/png",Ko={contentType:`${$o};base64`,aliases:[$o,".png","png"],body:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="},Qo="image/svg+xml",Yo={contentType:Qo,aliases:[Qo,".svg","svg"],body:"https://raw.githubusercontent.com/mathiasbynens/small/master/svg.svg"},Xo="text/plain",Zo={contentType:Xo,aliases:[Xo,".txt","txt","text","nooptext","noop.txt"],body:""},Jo="audio/wav",ei={contentType:`${Jo};base64`,aliases:[Jo,".wav","wav"],body:"UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA="},ti="video/webm",oi={contentType:`${ti};base64`,aliases:[ti,".webm","webm"],body:"GkXfo0AgQoaBAUL3gQFC8oEEQvOBCEKCQAR3ZWJtQoeBAkKFgQIYU4BnQI0VSalmQCgq17FAAw9CQE2AQAZ3aGFtbXlXQUAGd2hhbW15RIlACECPQAAAAAAAFlSua0AxrkAu14EBY8WBAZyBACK1nEADdW5khkAFVl9WUDglhohAA1ZQOIOBAeBABrCBCLqBCB9DtnVAIueBAKNAHIEAAIAwAQCdASoIAAgAAUAmJaQAA3AA/vz0AAA="},ii="image/webp",ni={contentType:`${ii};base64`,aliases:[ii,".webp","webp"],body:"UklGRhIAAABXRUJQVlA4TAYAAAAvQWxvAGs="},si="video/wmv",ci={contentType:`${si};base64`,aliases:[si,".wmv","wmv"],body:"MCaydY5mzxGm2QCqAGLObOUBAAAAAAAABQAAAAECodyrjEepzxGO5ADADCBTZWgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABcCAAAAAAAAAIA+1d6xnQEAAAAAAAAAAMAF2QEAAAAAAAAAAAAAAAAcDAAAAAAAAAIAAACADAAAgAwAAEANAwC1A79fLqnPEY7jAMAMIFNlLgAAAAAAAAAR0tOruqnPEY7mAMAMIFNlBgAAAAAAQKTQ0gfj0hGX8ACgyV6oUGQAAAAAAAAAAQAoAFcATQAvAEUAbgBjAG8AZABpAG4AZwBTAGUAdAB0AGkAbgBnAHMAAAAAABwATABhAHYAZgA1ADcALgA0ADEALgAxADAAMAAAAJEH3Le3qc8RjuYAwAwgU2WBAAAAAAAAAMDvGbxNW88RqP0AgF9cRCsAV/sgVVvPEaj9AIBfXEQrAAAAAAAAAAAzAAAAAAAAAAEAAAAAAAEAAAABAAAAAigAKAAAAAEAAAABAAAAAQAYAE1QNDMDAAAAAAAAAAAAAAAAAAAAAAAAAEBS0YYdMdARo6QAoMkDSPZMAAAAAAAAAEFS0YYdMdARo6QAoMkDSPYBAAAAAQAKAG0AcwBtAHAAZQBnADQAdgAzAAAAAAAEAE1QNDM2JrJ1jmbPEabZAKoAYs5sMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQ=="},ri=(()=>{const e={};for(const t of[Io,To,Po,zo,Bo,No,jo,Do,Wo,Go,Ko,Yo,Zo,ei,oi,ni,ci])for(const o of t.aliases)e[o]=t;return e})();function ai(e){return ri[e]||Zo}function li(e){if(null===e)return!1;if("object"!=typeof e)return!1;const{name:t,aliases:o,body:i,contentType:n}=e;return"string"==typeof t&&(!(!Array.isArray(o)||!o.every((e=>"string"==typeof e)))&&("string"==typeof i&&"string"==typeof n))}function pi(e){if(null===e)return!1;if("object"!=typeof e)return!1;const{name:t,aliases:o,body:i,dependencies:n,executionWorld:s,requiresTrust:c}=e;return"string"==typeof t&&(!(!Array.isArray(o)||!o.every((e=>"string"==typeof e)))&&("string"==typeof i&&(!(!Array.isArray(n)||!n.every((e=>"string"==typeof e)))&&((void 0===s||"MAIN"===s||"ISOLATED"===s)&&(void 0===c||"boolean"==typeof c)))))}var di=class e{static deserialize(t){const o=t.getASCII(),i=[],n=[];for(let e=0,o=t.getUint16();e["if (typeof scriptletGlobals === 'undefined') { var scriptletGlobals = {}; }",...t,`(${e})(...['{{1}}','{{2}}','{{3}}','{{4}}','{{5}}','{{6}}','{{7}}','{{8}}','{{9}}','{{10}}'].filter((a,i) => a !== '{{'+(i+1)+'}}').map((a) => decodeURIComponent(a)))`].join(";"))(t.body,i),this.scriptletsCache.set(t.name,o),o}getSurrogate(e){const t=this.resourcesByName.get(e.endsWith(".js")?e:`${e}.js`);if(void 0!==t&&"application/javascript"===t.contentType)return t.body}getScriptletCanonicalName(e){var t;return null===(t=this.getRawScriptlet(e))||void 0===t?void 0:t.name}getRawScriptlet(e){if(!e.endsWith(".fn"))return this.scriptletsByName.get(e.endsWith(".js")?e:`${e}.js`)}getScriptletDependencies(e){const t=new Map,o=[...e.dependencies];for(;o.length>0;){const e=o.pop();if(t.has(e))continue;const i=this.scriptletsByName.get(e);t.set(e,i.body),o.push(...i.dependencies)}return Array.from(t.values())}getSerializedSize(){let e=oe(this.checksum);e+=2;for(const{name:t,aliases:o,body:i,contentType:n}of this.resources)e+=oe(t),e+=o.reduce(((e,t)=>e+oe(t)),2),e+=ie(i),e+=oe(n);e+=2;for(const{name:t,aliases:o,body:i,dependencies:n}of this.scriptlets)e+=oe(t),e+=o.reduce(((e,t)=>e+oe(t)),2),e+=ie(i),e+=1,e+=1,e+=1,e+=1,e+=n.reduce(((e,t)=>e+oe(t)),2);return e}serialize(e){e.pushASCII(this.checksum),e.pushUint16(this.resources.length);for(const{name:t,aliases:o,body:i,contentType:n}of this.resources){e.pushASCII(t),e.pushUint16(o.length);for(const t of o)e.pushASCII(t);e.pushUTF8(i),e.pushASCII(n)}e.pushUint16(this.scriptlets.length);for(const{name:t,aliases:o,body:i,dependencies:n,executionWorld:s,requiresTrust:c}of this.scriptlets){e.pushASCII(t),e.pushUint16(o.length);for(const t of o)e.pushASCII(t);e.pushUTF8(i),e.pushBool(void 0!==s),e.pushBool("ISOLATED"===s),e.pushBool(void 0!==c),e.pushBool(!0===c),e.pushUint16(n.length),n.forEach((t=>e.pushASCII(t)))}}};var ui=new Uint32Array(0);function hi(e){return`(?:${e.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&")})`}function mi(e,t,o){let i=e.get(t);void 0===i&&(i=[],e.set(t,i)),i.push(o)}function Ai(e,t){const o=new Map;for(const i of e)mi(o,t(i),i);return Array.from(o.values())}function gi(e,t){const o=[],i=[];for(const n of e)t(n)?o.push(n):i.push(n);return{negative:i,positive:o}}var fi=[{description:"Remove duplicated filters by ID",fusion:e=>e[0],groupByCriteria:e=>""+e.getId(),select:()=>!0},{description:"Group idential filter with same mask but different domains in single filters",fusion:e=>{const t=[],o=new Set,i=new Set,n=new Set,s=new Set;for(const{domains:c}of e)if(void 0!==c){if(void 0!==c.parts&&t.push(c.parts),void 0!==c.hostnames)for(const e of c.hostnames)o.add(e);if(void 0!==c.entities)for(const e of c.entities)n.add(e);if(void 0!==c.notHostnames)for(const e of c.notHostnames)i.add(e);if(void 0!==c.notEntities)for(const e of c.notEntities)s.add(e)}return new lo(Object.assign({},e[0],{domains:new Tt({hostnames:0!==o.size?new Uint32Array(o).sort():void 0,entities:0!==n.size?new Uint32Array(n).sort():void 0,notHostnames:0!==i.size?new Uint32Array(i).sort():void 0,notEntities:0!==s.size?new Uint32Array(s).sort():void 0,parts:0!==t.length?t.join(","):void 0}),rawLine:void 0!==e[0].rawLine?e.map((({rawLine:e})=>e)).join(" <+> "):void 0}))},groupByCriteria:e=>{var t;return e.getHostname()+e.getFilter()+e.getMask()+(null!==(t=e.optionValue)&&void 0!==t?t:"")},select:e=>!e.isCSP()&&void 0===e.denyallow&&void 0!==e.domains},{description:"Group simple patterns, into a single filter",fusion:e=>{const t=[];for(const o of e)o.isRegex()?t.push(`(?:${o.getRegex().source})`):o.isRightAnchor()?t.push(`${hi(o.getFilter())}$`):o.isLeftAnchor()?t.push(`^${hi(o.getFilter())}`):t.push(hi(o.getFilter()));return new lo(Object.assign({},e[0],{mask:at(e[0].mask,Gt.isRegex),rawLine:void 0!==e[0].rawLine?e.map((({rawLine:e})=>e)).join(" <+> "):void 0,regex:new RegExp(t.join("|"))}))},groupByCriteria:e=>""+(e.getMask()&~Gt.isRegex&~Gt.isFullRegex),select:e=>void 0===e.domains&&void 0===e.denyallow&&!e.isHostnameAnchor()&&!e.isRedirect()&&!e.isCSP()}];function ki(e){return e}function bi(e){return e}function yi(e){const t=[];let o=e;for(const{select:e,fusion:i,groupByCriteria:n}of fi){const{positive:s,negative:c}=gi(o,e);o=c;const r=Ai(s,n);for(const e of r)e.length>1?t.push(i(e)):o.push(e[0])}for(const e of o)t.push(e);return t}function wi(e){return e--,e|=e>>1,e|=e>>2,e|=e>>4,e|=e>>8,e|=e>>16,++e}var vi=1;var _i=Number.MAX_SAFE_INTEGER>>>0,Ci=class e{static deserialize(t,o,i,n){const s=t.getUint32(),c=t.getUint32(),r=t.getUint32(),a=ce.fromUint8Array(t.getBytes(!0),n),l=a.getUint32ArrayView(s),p=a.getUint32ArrayView(c),d=a.pos;return a.seekZero(),new e({config:n,deserialize:o,filters:[],optimize:i}).updateInternals({bucketsIndex:p,filtersIndexStart:d,numberOfFilters:r,tokensLookupIndex:l,view:a})}constructor({deserialize:e,filters:t,optimize:o,config:i}){this.bucketsIndex=Y,this.filtersIndexStart=0,this.numberOfFilters=0,this.tokensLookupIndex=Y,this.cache=new Map,this.view=ce.empty(i),this.deserializeFilter=e,this.optimize=o,this.config=i,0!==t.length&&this.update(t,void 0)}getFilters(){const e=[];if(0===this.numberOfFilters)return e;this.view.setPos(this.filtersIndexStart);for(let t=0;t!t.has(e.getId())||(r-=e.getSerializedSize(o),!1))));for(const t of e)r+=t.getSerializedSize(o),a.push(t)}else{a=e;for(const t of e)r+=t.getSerializedSize(o)}if(0===a.length)return void this.updateInternals({bucketsIndex:Y,filtersIndexStart:0,numberOfFilters:0,tokensLookupIndex:Y,view:ce.empty(this.config)});!0===this.config.debug&&a.sort(((e,t)=>e.getId()-t.getId()));const l=new Uint32Array(Math.max(wi(2*a.length),256));for(const e of a){const t=e.getTokens();s.push(t),c+=2*t.length,n+=t.length;for(const e of t){i+=e.length;for(const t of e)l[t%l.length]+=1}}r+=4*c;const p=Math.max(2,wi(n)),d=p-1,u=[];for(let e=0;e1?this.optimize(c):c,lastRequestSeen:-1},!0===this.config.enableInMemoryCache&&this.cache.set(e,i)}if(i.lastRequestSeen!==t){i.lastRequestSeen=t;const e=i.filters;for(let t=0;t0){const o=e[t];e[t]=e[t-1],e[t-1]=o}return!1}}return!0}},xi=new Uint8Array(4),Si=class e{static deserialize(t,o,i){const n=new e({deserialize:o,config:i,filters:[]});return n.filters=t.getBytes(),n}constructor({config:e,deserialize:t,filters:o}){this.deserialize=t,this.filters=xi,this.config=e,0!==o.length&&this.update(o,void 0)}update(e,t){let o=this.filters.byteLength,i=[];const n=this.config.enableCompression,s=this.getFilters();if(0!==s.length)if(void 0===t||0===t.size)i=s;else for(const e of s)!1===t.has(e.getId())?i.push(e):o-=e.getSerializedSize(n);const c=i.length!==s.length,r=i.length;for(const t of e)o+=t.getSerializedSize(n),i.push(t);const a=i.length>r;if(0===i.length)this.filters=xi;else if(!0===a||!0===c){const e=ce.allocate(o,this.config);e.pushUint32(i.length),!0===this.config.debug&&i.sort(((e,t)=>e.getId()-t.getId()));for(const t of i)t.serialize(e);this.filters=e.buffer}}getSerializedSize(){return ee(this.filters,!1)}serialize(e){e.pushBytes(this.filters)}getFilters(){if(this.filters.byteLength<=4)return[];const e=[],t=ce.fromUint8Array(this.filters,this.config),o=t.getUint32();for(let i=0;i(!0!==c&&!0!==o.isScriptInject()||!o.match(t,e)||(null==p?void 0:p(o))||u.push(o),!0))),!0===s&&!0===a){const o=this.getGenericRules(l);for(const i of o)!0!==i.match(t,e)||(null==p?void 0:p(i))||u.push(i)}!0===s&&!0===r&&0!==o.length&&this.classesIndex.iterMatchingFilters(ut(o),(o=>(o.match(t,e)&&!(null==p?void 0:p(o))&&u.push(o),!0))),!0===s&&!0===r&&0!==n.length&&this.idsIndex.iterMatchingFilters(ut(n),(o=>(o.match(t,e)&&!(null==p?void 0:p(o))&&u.push(o),!0))),!0===s&&!0===r&&0!==i.length&&this.hrefsIndex.iterMatchingFilters(function(e){const t=e.sort();let o=1;for(let e=1;e{return t=e,it.reset(),bt(t,it),it.slice();var t})))),(o=>(o.match(t,e)&&!(null==p?void 0:p(o))&&u.push(o),!0)));const h=[];return 0!==u.length&&this.unhideIndex.iterMatchingFilters(d,(o=>(o.match(t,e)&&!(null==p?void 0:p(o))&&h.push(o),!0))),{filters:u,unhides:h}}getStylesheetsFromFilters({filters:e,extendedFilters:t},{getBaseRules:o,allowGenericHides:i,hidingStyle:n=Rt}){let s=!1===o||!1===i?"":this.getBaseStylesheet(n);0!==e.length&&(0!==s.length&&(s+="\n\n"),s+=Fi(e,n));const c=[];if(0!==t.length){const e=new Map;for(const o of t){const t=o.getSelectorAST();if(void 0!==t){const i=o.isRemove()?void 0:o.getStyleAttributeHash();void 0!==i&&e.set(o.getStyle(n),i),c.push({ast:t,remove:o.isRemove(),attribute:i})}}0!==e.size&&(0!==s.length&&(s+="\n\n"),s+=[...e.entries()].map((([e,t])=>`[${t}] { ${e} }`)).join("\n\n"))}return{stylesheet:s,extended:c}}getGenericRules(e){return null===this.extraGenericRules?this.lazyPopulateGenericRulesCache(e).genericRules:this.extraGenericRules}getBaseStylesheet(e){return null===this.baseStylesheet?this.lazyPopulateGenericRulesCache(e).baseStylesheet:this.baseStylesheet}lazyPopulateGenericRulesCache(e){if(null===this.baseStylesheet||null===this.extraGenericRules){const t=this.unhideIndex.getFilters(),o=new Set;for(const e of t)o.add(e.getSelector());const i=this.genericRules.getFilters(),n=[],s=[];for(const e of i)e.hasCustomStyle()||e.isScriptInject()||e.hasHostnameConstraint()||o.has(e.getSelector())?s.push(e):n.push(e);this.baseStylesheet=Fi(n,e),this.extraGenericRules=s}return{baseStylesheet:this.baseStylesheet,genericRules:this.extraGenericRules}}},Pi=class e{static deserialize(t,o){const i=new e({config:o});return i.index=Ci.deserialize(t,lo.deserialize,o.enableOptimizations?yi:ki,o),i.badFilters=Si.deserialize(t,lo.deserialize,o),i}constructor({filters:e=[],config:t}){this.index=new Ci({config:t,deserialize:lo.deserialize,filters:[],optimize:t.enableOptimizations?yi:ki}),this.badFiltersIds=null,this.badFilters=new Si({config:t,deserialize:lo.deserialize,filters:[]}),0!==e.length&&this.update(e,void 0)}getFilters(){return[].concat(this.badFilters.getFilters(),this.index.getFilters())}update(e,t){const o=[],i=[];for(const t of e)t.isBadFilter()?o.push(t):i.push(t);this.badFilters.update(o,t),this.index.update(i,t),this.badFiltersIds=null}getSerializedSize(){return this.badFilters.getSerializedSize()+this.index.getSerializedSize()}serialize(e){this.index.serialize(e),this.badFilters.serialize(e)}matchAll(e,t){const o=[];return this.index.iterMatchingFilters(e.getTokens(),(i=>(i.match(e)&&!1===this.isFilterDisabled(i)&&!(null==t?void 0:t(i))&&o.push(i),!0))),o}match(e,t){let o;return this.index.iterMatchingFilters(e.getTokens(),(i=>!(i.match(e)&&!1===this.isFilterDisabled(i)&&!(null==t?void 0:t(i)))||(o=i,!1))),o}isFilterDisabled(e){if(null===this.badFiltersIds){const e=this.badFilters.getFilters();if(0===e.length)return!1;const t=new Set;for(const o of e)t.add(o.getIdWithoutBadFilter());this.badFiltersIds=t}return this.badFiltersIds.has(e.getId())}},Ri=class e{static deserialize(t,o){const i=new e({config:o});return i.networkIndex=Ci.deserialize(t,lo.deserialize,o.enableOptimizations?yi:ki,o),i.exceptionsIndex=Ci.deserialize(t,lo.deserialize,o.enableOptimizations?yi:ki,o),i.cosmeticIndex=Ci.deserialize(t,Ht.deserialize,bi,o),i.unhideIndex=Ci.deserialize(t,Ht.deserialize,bi,o),i}constructor({filters:e=[],config:t}){this.config=t,this.networkIndex=new Ci({config:t,deserialize:lo.deserialize,filters:[],optimize:t.enableOptimizations?yi:ki}),this.exceptionsIndex=new Ci({config:t,deserialize:lo.deserialize,filters:[],optimize:t.enableOptimizations?yi:ki}),this.cosmeticIndex=new Ci({config:t,deserialize:Ht.deserialize,filters:[],optimize:bi}),this.unhideIndex=new Ci({config:t,deserialize:Ht.deserialize,filters:[],optimize:bi}),0!==e.length&&this.update(e,void 0)}update(e,t){const o=[],i=[],n=[],s=[];for(const t of e)t.isNetworkFilter()?t.isException()?i.push(t):o.push(t):t.isCosmeticFilter()&&(t.isUnhide()?s.push(t):n.push(t));this.networkIndex.update(o,t),this.exceptionsIndex.update(i,t),this.cosmeticIndex.update(n,t),this.unhideIndex.update(s,t)}serialize(e){this.networkIndex.serialize(e),this.exceptionsIndex.serialize(e),this.cosmeticIndex.serialize(e),this.unhideIndex.serialize(e)}getSerializedSize(){return this.networkIndex.getSerializedSize()+this.exceptionsIndex.getSerializedSize()+this.cosmeticIndex.getSerializedSize()+this.unhideIndex.getSerializedSize()}getHTMLFilters(e,t){const o=[],i=[],n=[],s=[];if(!0===this.config.loadNetworkFilters&&this.networkIndex.iterMatchingFilters(e.getTokens(),(i=>(i.match(e)&&!(null==t?void 0:t(i))&&o.push(i),!0))),0!==o.length&&this.exceptionsIndex.iterMatchingFilters(e.getTokens(),(o=>(o.match(e)&&!(null==t?void 0:t(o))&&n.push(o),!0))),!0===this.config.loadCosmeticFilters&&e.isMainFrame()){const{hostname:o,domain:n=""}=e,c=Ti(o,n);this.cosmeticIndex.iterMatchingFilters(c,(e=>(e.match(o,n)&&!(null==t?void 0:t(e))&&i.push(e),!0))),0!==i.length&&this.unhideIndex.iterMatchingFilters(c,(e=>(e.match(o,n)&&!(null==t?void 0:t(e))&&s.push(e),!0)))}return{networkFilters:o,cosmeticFilters:i,unhides:s,exceptions:n}}getFilters(){return[].concat(this.networkIndex.getFilters(),this.exceptionsIndex.getFilters(),this.cosmeticIndex.getFilters(),this.unhideIndex.getFilters())}},zi=Number.MAX_SAFE_INTEGER>>>0,Li=class e{static deserialize(t,o){const i=t.getUint32(),n=t.getUint32(),s=t.getUint32(),c=ce.fromUint8Array(t.getBytes(!0),{enableCompression:!1}),r=c.getUint32ArrayView(i),a=c.getUint32ArrayView(n),l=c.pos;return c.seekZero(),new e({deserialize:o,values:[],getKeys:()=>[],getSerializedSize:()=>0,serialize:()=>{}}).updateInternals({bucketsIndex:a,valuesIndexStart:l,numberOfValues:s,tokensLookupIndex:r,view:c})}constructor({serialize:e,deserialize:t,getKeys:o,getSerializedSize:i,values:n}){if(this.cache=new Map,this.bucketsIndex=Y,this.tokensLookupIndex=Y,this.valuesIndexStart=0,this.numberOfValues=0,this.view=ce.empty({enableCompression:!1}),this.deserializeValue=t,0!==n.length){const t=[];let s=0,c=0;for(const e of n)c+=i(e);if(0===n.length)return void this.updateInternals({bucketsIndex:Y,valuesIndexStart:0,numberOfValues:0,tokensLookupIndex:Y,view:ce.empty({enableCompression:!1})});for(const e of n){const i=o(e);t.push(i),s+=2*i.length}c+=4*s;const r=Math.max(2,wi(n.length)),a=r-1,l=[];for(let e=0;e[Ui(e)],serialize:Vi,deserialize:ji,values:e})}function Di(e){if(null===e)return!1;if("object"!=typeof e)return!1;const{key:t,name:o,description:i,country:n,website_url:s,privacy_policy_url:c,privacy_contact:r,ghostery_id:a}=e;return"string"==typeof t&&("string"==typeof o&&((null===i||"string"==typeof i)&&((null===n||"string"==typeof n)&&((null===s||"string"==typeof s)&&((null===c||"string"==typeof c)&&((null===r||"string"==typeof r)&&(null===a||"string"==typeof a)))))))}function Hi(e){return dt(e.key)}function Wi(e){return ie(e.key)+ie(e.name)+ie(e.description||"")+ie(e.website_url||"")+ie(e.country||"")+ie(e.privacy_policy_url||"")+ie(e.privacy_contact||"")+ie(e.ghostery_id||"")}function qi(e,t){t.pushUTF8(e.key),t.pushUTF8(e.name),t.pushUTF8(e.description||""),t.pushUTF8(e.website_url||""),t.pushUTF8(e.country||""),t.pushUTF8(e.privacy_policy_url||""),t.pushUTF8(e.privacy_contact||""),t.pushUTF8(e.ghostery_id||"")}function Gi(e){return{key:e.getUTF8(),name:e.getUTF8(),description:e.getUTF8()||null,website_url:e.getUTF8()||null,country:e.getUTF8()||null,privacy_policy_url:e.getUTF8()||null,privacy_contact:e.getUTF8()||null,ghostery_id:e.getUTF8()||null}}function $i(e){return new Li({getSerializedSize:Wi,getKeys:e=>[Hi(e)],serialize:qi,deserialize:Gi,values:e})}function Ki(e){if(null===e)return!1;if("object"!=typeof e)return!1;const{key:t,name:o,category:i,organization:n,alias:s,website_url:c,domains:r,filters:a}=e;return"string"==typeof t&&("string"==typeof o&&("string"==typeof i&&((null===n||"string"==typeof n)&&(("string"==typeof s||null===s)&&((null===c||"string"==typeof c)&&(!(!Array.isArray(r)||!r.every((e=>"string"==typeof e)))&&!(!Array.isArray(a)||!a.every((e=>"string"==typeof e)))))))))}function Qi(e){const t=[];for(const o of e.filters){const e=lo.parse(o);null!==e&&t.push(e.getId())}for(const o of e.domains){const e=lo.parse(`||${o}^`);null!==e&&t.push(e.getId())}return[...new Set(t)]}function Yi(e){let t=J(e.domains.length);for(const o of e.domains)t+=ie(o);let o=J(e.filters.length);for(const t of e.filters)o+=ie(t);return ie(e.key)+ie(e.name)+ie(e.category)+ie(e.organization||"")+ie(e.alias||"")+ie(e.website_url||"")+ie(e.ghostery_id||"")+t+o}function Xi(e,t){t.pushUTF8(e.key),t.pushUTF8(e.name),t.pushUTF8(e.category),t.pushUTF8(e.organization||""),t.pushUTF8(e.alias||""),t.pushUTF8(e.website_url||""),t.pushUTF8(e.ghostery_id||""),t.pushLength(e.domains.length);for(const o of e.domains)t.pushUTF8(o);t.pushLength(e.filters.length);for(const o of e.filters)t.pushUTF8(o)}function Zi(e){const t=e.getUTF8(),o=e.getUTF8(),i=e.getUTF8(),n=e.getUTF8()||null,s=e.getUTF8()||null,c=e.getUTF8()||null,r=e.getUTF8()||null,a=e.getLength(),l=[];for(let t=0;t=2;t.shift()){const e=t.join("."),o=lo.parse(`||${e}^`);if(null===o)continue;const i=this.fromId(o.getId());if(i.length>0)return i}return[]}fromId(e){var t,o;const i=[];for(const n of this.patterns.get(e))i.push({pattern:n,category:null===(t=this.categories.get(Ui({key:n.category})))||void 0===t?void 0:t[0],organization:null!==n.organization?null===(o=this.organizations.get(Hi({key:n.organization})))||void 0===o?void 0:o[0]:null});return i}},tn=class{static deserialize(e){const t=new Set;for(let o=0,i=e.getUint32();ot.condition===e.condition));if(t)for(const o of e.filterIDs)t.filterIDs.delete(o)}if(e)for(const t of e){const e=this.preprocessors.find((e=>e.condition===t.condition));if(e)for(const o of t.filterIDs)e.filterIDs.add(o);else this.preprocessors.push(t)}(t&&0!==t.length||e&&0!==e.length)&&this.updateEnv(o)}serialize(e){e.pushUint32(this.excluded.size);for(const t of this.excluded)e.pushUint32(t);e.pushUint32(this.preprocessors.length);for(const t of this.preprocessors)t.serialize(e)}getSerializedSize(){let e=4*(1+this.excluded.size);e+=4;for(const t of this.preprocessors)e+=t.getSerializedSize();return e}};function on(e){if(0===e.length)return!1;let t,o=0;for(const i of e){const e=(i.isImportant()?4:0)|(i.isException()?1:2);e>=o&&(o=e,t=i)}return void 0!==t&&t.isException()}var nn=class extends ue{static fromCached(e,t){if(void 0===t)return e();const{path:o,read:i,write:n}=t;return i(o).then((e=>this.deserialize(e))).catch((()=>e().then((e=>n(o,e.serialize()).then((()=>e))))))}static empty(e={}){return new this({config:e})}static fromLists(e,t,o={},i){return this.fromCached((()=>{const i=function(e,t){return Promise.all(t.map((t=>he(e,t))))}(e,t),n=function(e){return he(e,`${me}/ublock-origin/resources.json`)}(e);return Promise.all([i,n]).then((([e,t])=>{const i=this.parse(e.join("\n"),o);return void 0!==t&&i.updateResources(t,""+t.length),i}))}),i)}static fromPrebuiltAdsOnly(e=fetch,t){return this.fromLists(e,Ae,{},t)}static fromPrebuiltAdsAndTracking(e=fetch,t){return this.fromLists(e,ge,{},t)}static fromPrebuiltFull(e=fetch,t){return this.fromLists(e,fe,{},t)}static fromTrackerDB(e,t={}){const o=new re(t),i=new en(e),n=[];for(const e of i.getPatterns())n.push(...e.filters);const s=this.parse(n.join("\n"),o);return s.metadata=i,s}static merge(e,{skipResources:t=!1}={}){if(!e||e.length<2)throw new Error("merging engines requires at least two engines");const o=e[0].config,i=new Map,n=new Map,s=new Map,c=[],r={organizations:{},categories:{},patterns:{}},a=[],l=Object.keys(o).filter((function(e){return"boolean"==typeof o[e]&&!a.includes(e)}));for(const t of e){for(const e of l)if(o[e]!==t.config[e])throw new Error(`config "${e}" of all merged engines must be the same`);const e=t.getFilters();for(const t of e.networkFilters)n.set(t.getId(),t);for(const t of e.cosmeticFilters)s.set(t.getId(),t);for(const e of t.preprocessors.preprocessors)c.push(e);for(const[e,o]of t.lists)i.has(e)||i.set(e,o);if(void 0!==t.metadata){for(const e of t.metadata.organizations.getValues())void 0===r.organizations[e.key]&&(r.organizations[e.key]=e);for(const e of t.metadata.categories.getValues())void 0===r.categories[e.key]&&(r.categories[e.key]=e);for(const e of t.metadata.patterns.getValues())void 0===r.patterns[e.key]&&(r.patterns[e.key]=e)}}const p=new this({networkFilters:Array.from(n.values()),cosmeticFilters:Array.from(s.values()),preprocessors:c,lists:i,config:o});if(Object.keys(r.categories).length+Object.keys(r.organizations).length+Object.keys(r.patterns).length!==0&&(p.metadata=new en(r)),!0!==t){for(const t of e.slice(1))if(t.resources.checksum!==e[0].resources.checksum)throw new Error(`resource checksum of all merged engines must match with the first one: "${e[0].resources.checksum}" but got: "${t.resources.checksum}"`);p.resources=di.copy(e[0].resources)}return p}static parse(e,t={}){const o=new re(t);return new this({...So(e,o),config:o})}static deserialize(e){const t=ce.fromUint8Array(e,{enableCompression:!1}),o=t.getUint16();if(699!==o)throw new Error(`serialized engine version mismatch, expected 699 but got ${o}`);const i=re.deserialize(t);if(i.enableCompression&&t.enableCompression(),i.integrityCheck){const o=t.pos;t.pos=e.length-4;const i=t.checksum(),n=t.getUint32();if(i!==n)throw new Error(`serialized engine checksum mismatch, expected ${n} but got ${i}`);t.pos=o}const n=new this({config:i});n.resources=di.deserialize(t);const s=new Map,c=t.getUint16();for(let e=0;ee.getId()))).concat(o.map((e=>e.getId()))));l.push(new Co({condition:e,filterIDs:n}))}if(void 0!==t.added&&0!==t.added.length){const{networkFilters:o,cosmeticFilters:i}=So(t.added.join("\n"),this.config),n=new Set([].concat(i.map((e=>e.getId()))).concat(o.map((e=>e.getId()))));c.push(new Co({condition:e,filterIDs:n}))}}return this.update({newCosmeticFilters:n,newNetworkFilters:s,newPreprocessors:c,removedCosmeticFilters:r.map((e=>e.getId())),removedNetworkFilters:a.map((e=>e.getId())),removedPreprocessors:l},i)}getHtmlFilters(e){const t=[];if(!1===this.config.enableHtmlFiltering)return t;const{networkFilters:o,exceptions:i,cosmeticFilters:n,unhides:s}=this.htmlFilters.getHTMLFilters(e,this.isFilterExcluded.bind(this));if(0!==n.length){const o=new Map(s.map((e=>[e.getSelector(),e])));for(const i of n){const n=i.getExtendedSelector();if(void 0===n)continue;const s=o.get(i.getSelector());void 0===s&&t.push(n),this.emit("filter-matched",{filter:i,exception:s},{request:e,filterType:go.COSMETIC})}}if(0!==o.length){const n=new Map;let s;for(const e of i){const t=e.optionValue;if(""===t){s=e;break}n.set(t,e)}for(const i of o){const o=i.getHtmlModifier();if(null===o)continue;const c=s||n.get(i.optionValue);this.emit("filter-matched",{filter:i,exception:c},{request:e,filterType:go.NETWORK}),void 0===c&&t.push(["replace",o])}}return 0!==t.length&&this.emit("html-filtered",t,e.url),t}getCosmeticsFilters({url:e,hostname:t,domain:o,classes:i,hrefs:n,ids:s,getBaseRules:c=!0,getInjectionRules:r=!0,getExtendedRules:a=!0,getRulesFromDOM:l=!0,getRulesFromHostname:p=!0,hidingStyle:d,callerContext:u}){if(!1===this.config.loadCosmeticFilters)return{active:!1,extended:[],scripts:[],styles:""};o||(o="");let h=!0,m=!0;const A=this.hideExceptions.matchAll(Ft.fromRawDetails({domain:o,hostname:t,url:e,sourceDomain:"",sourceHostname:"",sourceUrl:""}),this.isFilterExcluded.bind(this)),g=[],f=[];for(const e of A){if(e.isElemHide()){h=!1,m=!1;break}e.isSpecificHide()?f.push(e):e.isGenericHide()&&g.push(e)}!0===h&&(h=!1===on(g)),!0===m&&(m=!1===on(f));const{filters:k,unhides:b}=this.cosmetics.getCosmeticsFilters({domain:o,hostname:t,classes:i,hrefs:n,ids:s,allowGenericHides:h,allowSpecificHides:m,getRulesFromDOM:l,getRulesFromHostname:p,hidingStyle:d,isFilterExcluded:this.isFilterExcluded.bind(this)});let y=!1;const w=new Map;for(const e of b)!0===e.isScriptInject()&&!0===e.isUnhide()&&0===e.getSelector().length&&(y=!0),w.set(Dt(e,this.resources.getScriptletCanonicalName.bind(this.resources)),e);const v=[],_=[],C=[];if(0!==k.length)for(const t of k){const o=w.get(Dt(t,this.resources.getScriptletCanonicalName.bind(this.resources)));if(void 0!==o)continue;let i=!1;!0===t.isScriptInject()?!0===r&&!1===y&&(v.push(t),i=!0):t.isExtended()?!0===a&&(C.push(t),i=!0):(_.push(t),i=!0),i&&this.emit("filter-matched",{filter:t,exception:o},{url:e,callerContext:u,filterType:go.COSMETIC})}const x=[];for(const t of v){const o=t.getScript(this.resources.getScriptlet.bind(this.resources));void 0!==o&&(this.emit("script-injected",o,e),x.push(o))}const{stylesheet:S,extended:E}=this.cosmetics.getStylesheetsFromFilters({filters:_,extendedFilters:C},{getBaseRules:c,allowGenericHides:h,hidingStyle:d});return 0!==S.length&&this.emit("style-injected",S,e),{active:!0,extended:E,scripts:x,styles:S}}matchAll(e){const t=[];return e.isSupported&&(Array.prototype.push.apply(t,this.importants.matchAll(e,this.isFilterExcluded.bind(this))),Array.prototype.push.apply(t,this.filters.matchAll(e,this.isFilterExcluded.bind(this))),Array.prototype.push.apply(t,this.exceptions.matchAll(e,this.isFilterExcluded.bind(this))),Array.prototype.push.apply(t,this.csp.matchAll(e,this.isFilterExcluded.bind(this))),Array.prototype.push.apply(t,this.hideExceptions.matchAll(e,this.isFilterExcluded.bind(this))),Array.prototype.push.apply(t,this.redirects.matchAll(e,this.isFilterExcluded.bind(this)))),new Set(t)}getCSPDirectives(e){if(!this.config.loadNetworkFilters)return;if(!0!==e.isSupported||!1===e.isMainFrame())return;const t=this.csp.matchAll(e,this.isFilterExcluded.bind(this));if(0===t.length)return;const o=new Map,i=[];for(const n of t)if(n.isException()){if(void 0===n.csp)return void this.emit("filter-matched",{exception:n},{request:e,filterType:go.NETWORK});o.set(n.csp,n)}else i.push(n);if(0===i.length)return;const n=new Set;for(const t of i.values()){const i=o.get(t.csp);void 0===i&&n.add(t.csp),this.emit("filter-matched",{filter:t,exception:i},{request:e,filterType:go.NETWORK})}const s=Array.from(n).join("; ");return s.length>0&&this.emit("csp-injected",e,s),s}match(e,t=!1){const o={exception:void 0,filter:void 0,match:!1,redirect:void 0,metadata:void 0};if(!this.config.loadNetworkFilters)return o;if(e.isSupported){let t,i;if(o.filter=this.importants.match(e,this.isFilterExcluded.bind(this)),void 0===o.filter){const n=this.redirects.matchAll(e,this.isFilterExcluded.bind(this)).sort(((e,t)=>t.getRedirectPriority()-e.getRedirectPriority()));if(0!==n.length)for(const e of n)"none"===e.getRedirectResource()?t=e:e.isRedirectRule()?void 0===i&&(i=e):void 0===o.filter&&(o.filter=e);void 0===o.filter&&(o.filter=this.filters.match(e,this.isFilterExcluded.bind(this)),void 0!==i&&void 0!==o.filter&&(o.filter=i)),void 0!==o.filter&&(o.exception=this.exceptions.match(e,this.isFilterExcluded.bind(this)))}void 0!==o.filter&&void 0===o.exception&&o.filter.isRedirect()&&(void 0!==t?o.exception=t:o.redirect=this.resources.getResource(o.filter.getRedirectResource()))}return o.match=void 0===o.exception&&void 0!==o.filter,o.filter&&this.emit("filter-matched",{filter:o.filter,exception:o.exception},{request:e,filterType:go.NETWORK}),void 0!==o.exception?this.emit("request-whitelisted",e,o):void 0!==o.redirect?this.emit("request-redirected",e,o):void 0!==o.filter?this.emit("request-blocked",e,o):this.emit("request-allowed",e,o),!0===t&&void 0!==o.filter&&this.metadata&&(o.metadata=this.metadata.fromFilter(o.filter)),o}getPatternMetadata(e,{getDomainMetadata:t=!1}={}){if(void 0===this.metadata)return[];const o=new Set,i=[];for(const t of this.matchAll(e))for(const e of this.metadata.fromFilter(t))o.has(e.pattern.key)||(o.add(e.pattern.key),i.push(e));if(t)for(const t of this.metadata.fromDomain(e.hostname))o.has(t.pattern.key)||(o.add(t.pattern.key),i.push(t));return i}blockScripts(){return this.updateFromDiff({added:[qt().scripts().redirectTo("javascript").toString()]}),this}blockImages(){return this.updateFromDiff({added:[qt().images().redirectTo("png").toString()]}),this}blockMedias(){return this.updateFromDiff({added:[qt().medias().redirectTo("mp4").toString()]}),this}blockFrames(){return this.updateFromDiff({added:[qt().frames().redirectTo("html").toString()]}),this}blockFonts(){return this.updateFromDiff({added:[qt().fonts().toString()]}),this}blockStyles(){return this.updateFromDiff({added:[qt().styles().toString()]}),this}};function sn(e){const t=new Set(["br","head","link","meta","script","style","s"]),o=new Set,i=new Set,n=new Set,s=new Set;for(const c of e)for(const e of[c,...c.querySelectorAll("[id]:not(html):not(body),[class]:not(html):not(body),[href]:not(html):not(body)")]){if(s.has(e))continue;if(s.add(e),t.has(e.nodeName.toLowerCase()))continue;const c=e.getAttribute("id");"string"==typeof c&&n.add(c);const r=e.classList;for(const e of r)o.add(e);const a=e.getAttribute("href");"string"==typeof a&&i.add(a)}return{classes:Array.from(o),hrefs:Array.from(i),ids:Array.from(n)}}function cn(e){try{const t=ot(location.href),o=t.hostname||"",i=t.domain||"";return e.getCosmeticsFilters({url:location.href,hostname:o,domain:i,...sn([document.documentElement]),getBaseRules:!0,getInjectionRules:!1,getExtendedRules:!0,getRulesFromDOM:!0,getRulesFromHostname:!0,hidingStyle:A("opacity")}).styles}catch(e){return console.error("Error getting cosmetic rules",e),""}}function rn(e){if(e){return e.replace(/\s*{[^\\}]*}\s*/g,",").replace(/,$/,"")}return""}var an=new Uint8Array([]);var ln=[{name:"192.com",detectCmp:[{exists:".ont-cookies"}],detectPopup:[{visible:".ont-cookies"}],optIn:[{click:".ont-btn-main.ont-cookies-btn.js-ont-btn-ok2"}],optOut:[{click:".ont-cookes-btn-manage"},{click:".ont-btn-main.ont-cookies-btn.js-ont-btn-choose"}],test:[{eval:"EVAL_ONENINETWO_0"}]},{name:"1password-com",cosmetic:!0,prehideSelectors:['footer #footer-root [aria-label="Cookie Consent"]'],detectCmp:[{exists:'footer #footer-root [aria-label="Cookie Consent"]'}],detectPopup:[{visible:'footer #footer-root [aria-label="Cookie Consent"]'}],optIn:[{click:'footer #footer-root [aria-label="Cookie Consent"] button'}],optOut:[{hide:'footer #footer-root [aria-label="Cookie Consent"]'}]},{name:"aa",vendorUrl:"https://aa.com",prehideSelectors:[],cosmetic:!0,detectCmp:[{exists:"#aa_optoutmulti-Modal,#cookieBannerMessage"}],detectPopup:[{visible:"#aa_optoutmulti-Modal,#cookieBannerMessage"}],optIn:[{hide:"#aa_optoutmulti-Modal,#cookieBannerMessage"},{waitForThenClick:"#aa_optoutmulti_checkBox"},{waitForThenClick:"#aa_optoutmulti-Modal button.optoutmulti_button"}],optOut:[{hide:"#aa_optoutmulti-Modal,#cookieBannerMessage"}]},{name:"abc",vendorUrl:"https://abc.net.au",runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?abc\\.net\\.au/"},prehideSelectors:[],detectCmp:[{exists:"[data-component=CookieBanner]"}],detectPopup:[{visible:"[data-component=CookieBanner] [data-component=CookieBanner_AcceptAll]"}],optIn:[{waitForThenClick:"[data-component=CookieBanner] [data-component=CookieBanner_AcceptAll]"}],optOut:[{waitForThenClick:"[data-component=CookieBanner] [data-component=CookieBanner_AcceptABCRequired]"}],test:[{eval:"EVAL_ABC_TEST"}]},{name:"abconcerts.be",vendorUrl:"https://unknown",intermediate:!1,prehideSelectors:["dialog.cookie-consent"],detectCmp:[{exists:"dialog.cookie-consent form.cookie-consent__form"}],detectPopup:[{visible:"dialog.cookie-consent form.cookie-consent__form"}],optIn:[{waitForThenClick:"dialog.cookie-consent form.cookie-consent__form button[value=yes]"}],optOut:[{if:{exists:"dialog.cookie-consent form.cookie-consent__form button[value=no]"},then:[{click:"dialog.cookie-consent form.cookie-consent__form button[value=no]"}],else:[{click:"dialog.cookie-consent form.cookie-consent__form button.cookie-consent__options-toggle"},{waitForThenClick:'dialog.cookie-consent form.cookie-consent__form button[value="save_options"]'}]}]},{name:"acris",prehideSelectors:["div.acris-cookie-consent"],detectCmp:[{exists:"[data-acris-cookie-consent]"}],detectPopup:[{visible:".acris-cookie-consent.is--modal"}],optIn:[{waitForVisible:"#ccConsentAcceptAllButton",check:"any"},{wait:500},{waitForThenClick:"#ccConsentAcceptAllButton"}],optOut:[{waitForVisible:"#ccAcceptOnlyFunctional",check:"any"},{wait:500},{waitForThenClick:"#ccAcceptOnlyFunctional"}]},{name:"activobank.pt",runContext:{urlPattern:"^https://(www\\.)?activobank\\.pt"},prehideSelectors:["aside#cookies,.overlay-cookies"],detectCmp:[{exists:"#cookies .cookies-btn"}],detectPopup:[{visible:"#cookies #submitCookies"}],optIn:[{waitForThenClick:"#cookies #submitCookies"}],optOut:[{waitForThenClick:"#cookies #rejectCookies"}]},{name:"Adroll",prehideSelectors:["#adroll_consent_container"],detectCmp:[{exists:"#adroll_consent_container"}],detectPopup:[{visible:"#adroll_consent_container"}],optIn:[{waitForThenClick:"#adroll_consent_accept"}],optOut:[{waitForThenClick:"#adroll_consent_reject"}],test:[{eval:"EVAL_ADROLL_0"}]},{name:"affinity.serif.com",detectCmp:[{exists:".c-cookie-banner button[data-qa='allow-all-cookies']"}],detectPopup:[{visible:".c-cookie-banner"}],optIn:[{click:'button[data-qa="allow-all-cookies"]'}],optOut:[{click:'button[data-qa="manage-cookies"]'},{waitFor:'.c-cookie-banner ~ [role="dialog"]'},{waitForThenClick:'.c-cookie-banner ~ [role="dialog"] input[type="checkbox"][value="true"]',all:!0},{click:'.c-cookie-banner ~ [role="dialog"] .c-modal__action button'}],test:[{wait:500},{eval:"EVAL_AFFINITY_SERIF_COM_0"}]},{name:"agolde.com",cosmetic:!0,prehideSelectors:["#modal-1 div[data-micromodal-close]"],detectCmp:[{exists:"#modal-1 div[aria-labelledby=modal-1-title]"}],detectPopup:[{exists:"#modal-1 div[data-micromodal-close]"}],optIn:[{click:'button[aria-label="Close modal"]'}],optOut:[{hide:"#modal-1 div[data-micromodal-close]"}]},{name:"aliexpress",vendorUrl:"https://aliexpress.com/",runContext:{urlPattern:"^https://.*\\.aliexpress\\.com/"},prehideSelectors:["#gdpr-new-container"],detectCmp:[{exists:"#gdpr-new-container,#voyager-gdpr > div"}],detectPopup:[{visible:"#gdpr-new-container,#voyager-gdpr > div"}],optIn:[{waitForThenClick:"#gdpr-new-container .btn-accept,#voyager-gdpr > div > div > button:nth-child(1)"}],optOut:[{if:{exists:"#voyager-gdpr > div"},then:[{waitForThenClick:"#voyager-gdpr > div > div > button:nth-child(2)"}],else:[{waitForThenClick:"#gdpr-new-container .btn-more"},{waitFor:"#gdpr-new-container .gdpr-dialog-switcher"},{click:"#gdpr-new-container .switcher-on",all:!0,optional:!0},{click:"#gdpr-new-container .btn-save"}]}]},{name:"almacmp",prehideSelectors:["#alma-cmpv2-container"],detectCmp:[{exists:"#alma-cmpv2-container"}],detectPopup:[{visible:"#alma-cmpv2-container #almacmp-modal-layer1"}],optIn:[{waitForThenClick:"#alma-cmpv2-container #almacmp-modal-layer1 #almacmp-modalConfirmBtn"}],optOut:[{waitForThenClick:"#alma-cmpv2-container #almacmp-modal-layer1 #almacmp-modalSettingBtn"},{waitFor:"#alma-cmpv2-container #almacmp-modal-layer2"},{waitForThenClick:"#alma-cmpv2-container #almacmp-modal-layer2 #almacmp-reject-all-layer2"}],test:[{eval:"EVAL_ALMACMP_0"}]},{name:"altium.com",cosmetic:!0,prehideSelectors:[".altium-privacy-bar"],detectCmp:[{exists:".altium-privacy-bar"}],detectPopup:[{exists:".altium-privacy-bar"}],optIn:[{click:"a.altium-privacy-bar__btn"}],optOut:[{hide:".altium-privacy-bar"}]},{name:"amazon.com",prehideSelectors:['span[data-action="sp-cc"][data-sp-cc*="rejectAllAction"]'],detectCmp:[{exists:'span[data-action="sp-cc"][data-sp-cc*="rejectAllAction"]'}],detectPopup:[{visible:'span[data-action="sp-cc"][data-sp-cc*="rejectAllAction"]'}],optIn:[{waitForVisible:"#sp-cc-accept"},{wait:500},{click:"#sp-cc-accept"}],optOut:[{waitForVisible:"#sp-cc-rejectall-link"},{wait:500},{click:"#sp-cc-rejectall-link"}]},{name:"amex",vendorUrl:"https://www.americanexpress.com/",cosmetic:!1,prehideSelectors:["#user-consent-management-granular-banner-overlay"],detectCmp:[{exists:"#user-consent-management-granular-banner-overlay"}],detectPopup:[{visible:"#user-consent-management-granular-banner-overlay"}],optIn:[{waitForThenClick:"[data-testid=granular-banner-button-accept-all]"}],optOut:[{waitForThenClick:"[data-testid=granular-banner-button-decline-all]"}]},{name:"aquasana.com",prehideSelectors:["#consent-tracking"],detectCmp:[{exists:"#consent-tracking"}],detectPopup:[{exists:"#consent-tracking"}],optIn:[{waitForThenClick:"#consent-tracking .affirm.btn"}],optOut:[{if:{exists:"#consent-tracking .decline.btn"},then:[{click:"#consent-tracking .decline.btn"}],else:[{hide:"#consent-tracking"}]}]},{name:"arbeitsagentur",vendorUrl:"https://www.arbeitsagentur.de/",prehideSelectors:[".modal-open bahf-cookie-disclaimer-dpl3"],detectCmp:[{exists:"bahf-cookie-disclaimer-dpl3"}],detectPopup:[{visible:"bahf-cookie-disclaimer-dpl3"}],optIn:[{waitForThenClick:["bahf-cookie-disclaimer-dpl3","#bahf-cookie-disclaimer-modal .ba-btn-primary"]}],optOut:[{waitForThenClick:["bahf-cookie-disclaimer-dpl3","#bahf-cookie-disclaimer-modal .ba-btn-contrast"]}],test:[{eval:"EVAL_ARBEITSAGENTUR_TEST"}]},{name:"asus",vendorUrl:"https://www.asus.com/",runContext:{urlPattern:"^https://www\\.asus\\.com/"},prehideSelectors:["#cookie-policy-info,#cookie-policy-info-bg"],detectCmp:[{exists:"#cookie-policy-info"}],detectPopup:[{visible:"#cookie-policy-info"}],optIn:[{waitForThenClick:'#cookie-policy-info [data-agree="Accept Cookies"]'}],optOut:[{if:{exists:"#cookie-policy-info .btn-reject"},then:[{waitForThenClick:"#cookie-policy-info .btn-reject"}],else:[{waitForThenClick:"#cookie-policy-info .btn-setting"},{waitForThenClick:'#cookie-policy-lightbox-wrapper [data-agree="Save Settings"]'}]}]},{name:"athlinks-com",runContext:{urlPattern:"^https://(www\\.)?athlinks\\.com/"},cosmetic:!0,prehideSelectors:["#footer-container ~ div"],detectCmp:[{exists:"#footer-container ~ div"}],detectPopup:[{visible:"#footer-container > div"}],optIn:[{click:"#footer-container ~ div button"}],optOut:[{hide:"#footer-container ~ div"}]},{name:"ausopen.com",cosmetic:!0,detectCmp:[{exists:".gdpr-popup__message"}],detectPopup:[{visible:".gdpr-popup__message"}],optOut:[{hide:".gdpr-popup__message"}],optIn:[{click:".gdpr-popup__message button"}]},{name:"automattic-cmp-optout",prehideSelectors:['form[class*="cookie-banner"][method="post"]'],detectCmp:[{exists:'form[class*="cookie-banner"][method="post"]'}],detectPopup:[{visible:'form[class*="cookie-banner"][method="post"]'}],optIn:[{click:'a[class*="accept-all-button"]'}],optOut:[{click:'form[class*="cookie-banner"] div[class*="simple-options"] a[class*="customize-button"]'},{waitForThenClick:"input[type=checkbox][checked]:not([disabled])",all:!0},{click:'a[class*="accept-selection-button"]'}]},{name:"aws.amazon.com",prehideSelectors:["#awsccc-cb-content","#awsccc-cs-container","#awsccc-cs-modalOverlay","#awsccc-cs-container-inner"],detectCmp:[{exists:"#awsccc-cb-content"}],detectPopup:[{visible:"#awsccc-cb-content"}],optIn:[{click:"button[data-id=awsccc-cb-btn-accept"}],optOut:[{click:"button[data-id=awsccc-cb-btn-customize]"},{waitFor:"input[aria-checked]"},{click:"input[aria-checked=true]",all:!0,optional:!0},{click:"button[data-id=awsccc-cs-btn-save]"}]},{name:"axeptio",prehideSelectors:[".axeptio_widget"],detectCmp:[{exists:".axeptio_widget"}],detectPopup:[{visible:".axeptio_widget"}],optIn:[{waitFor:".axeptio-widget--open"},{click:"button#axeptio_btn_acceptAll"}],optOut:[{waitFor:".axeptio-widget--open"},{click:"button#axeptio_btn_dismiss"}],test:[{eval:"EVAL_AXEPTIO_0"}]},{name:"baden-wuerttemberg.de",prehideSelectors:[".cookie-alert.t-dark"],cosmetic:!0,detectCmp:[{exists:".cookie-alert.t-dark"}],detectPopup:[{visible:".cookie-alert.t-dark"}],optIn:[{click:".cookie-alert__form input:not([disabled]):not([checked])"},{click:".cookie-alert__button button"}],optOut:[{hide:".cookie-alert.t-dark"}]},{name:"bahn-de",vendorUrl:"https://www.bahn.de/",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?bahn\\.de/"},intermediate:!1,prehideSelectors:[],detectCmp:[{exists:["body > div:first-child","#consent-layer"]}],detectPopup:[{visible:["body > div:first-child","#consent-layer"]}],optIn:[{waitForThenClick:["body > div:first-child","#consent-layer .js-accept-all-cookies"]}],optOut:[{waitForThenClick:["body > div:first-child","#consent-layer .js-accept-essential-cookies"]}],test:[{eval:"EVAL_BAHN_TEST"}]},{name:"bbb.org",runContext:{urlPattern:"^https://www\\.bbb\\.org/"},cosmetic:!0,prehideSelectors:['div[aria-label="use of cookies on bbb.org"]'],detectCmp:[{exists:'div[aria-label="use of cookies on bbb.org"]'}],detectPopup:[{visible:'div[aria-label="use of cookies on bbb.org"]'}],optIn:[{click:'div[aria-label="use of cookies on bbb.org"] button.bds-button-unstyled span.visually-hidden'}],optOut:[{hide:'div[aria-label="use of cookies on bbb.org"]'}]},{name:"bing.com",prehideSelectors:["#bnp_container"],detectCmp:[{exists:"#bnp_cookie_banner"}],detectPopup:[{visible:"#bnp_cookie_banner"},{visible:"#bnp_btn_accept,#bnp_btn_reject"}],optIn:[{waitForThenClick:"#bnp_btn_accept"}],optOut:[{wait:500},{waitForThenClick:"#bnp_btn_reject"}],test:[{eval:"EVAL_BING_0"}]},{name:"blocksy",vendorUrl:"https://creativethemes.com/blocksy/docs/extensions/cookies-consent/",cosmetic:!1,runContext:{main:!0,frame:!1},intermediate:!1,prehideSelectors:[".cookie-notification"],detectCmp:[{exists:"#blocksy-ext-cookies-consent-styles-css"}],detectPopup:[{visible:".cookie-notification"}],optIn:[{click:".cookie-notification .ct-cookies-decline-button"}],optOut:[{waitForThenClick:".cookie-notification .ct-cookies-decline-button"}],test:[{eval:"EVAL_BLOCKSY_0"}]},{name:"borlabs",detectCmp:[{exists:"._brlbs-block-content"}],detectPopup:[{visible:"._brlbs-bar-wrap,._brlbs-box-wrap"}],optIn:[{click:"a[data-cookie-accept-all]"}],optOut:[{click:"a[data-cookie-individual]"},{waitForVisible:".cookie-preference"},{click:"input[data-borlabs-cookie-checkbox]:checked",all:!0,optional:!0},{click:"#CookiePrefSave"},{wait:500}],prehideSelectors:["#BorlabsCookieBox"],test:[{eval:"EVAL_BORLABS_0"}]},{name:"bundesregierung.de",prehideSelectors:[".bpa-cookie-banner"],detectCmp:[{exists:".bpa-cookie-banner"}],detectPopup:[{visible:".bpa-cookie-banner .bpa-module-full-hero"}],optIn:[{click:".bpa-accept-all-button"}],optOut:[{wait:500,comment:"click is not immediately recognized"},{waitForThenClick:".bpa-close-button"}],test:[{eval:"EVAL_BUNDESREGIERUNG_DE_0"}]},{name:"burpee.com",cosmetic:!0,prehideSelectors:["#notice-cookie-block"],detectCmp:[{exists:"#notice-cookie-block"}],detectPopup:[{exists:"#html-body #notice-cookie-block"}],optIn:[{click:"#btn-cookie-allow"}],optOut:[{hide:"#html-body #notice-cookie-block, #notice-cookie"}]},{name:"canva.com",prehideSelectors:['div[role="dialog"] a[data-anchor-id="cookie-policy"]'],detectCmp:[{exists:'div[role="dialog"] a[data-anchor-id="cookie-policy"]'}],detectPopup:[{exists:'div[role="dialog"] a[data-anchor-id="cookie-policy"]'}],optIn:[{click:'div[role="dialog"] button:nth-child(1)'}],optOut:[{if:{exists:'div[role="dialog"] button:nth-child(3)'},then:[{click:'div[role="dialog"] button:nth-child(2)'}],else:[{click:'div[role="dialog"] button:nth-child(2)'},{waitFor:'div[role="dialog"] a[data-anchor-id="cookie-policy"]'},{waitFor:'div[role="dialog"] button[role=switch]'},{click:'div[role="dialog"] button:nth-child(2):not([role])'},{click:'div[role="dialog"] div:last-child button:only-child'}]}],test:[{eval:"EVAL_CANVA_0"}]},{name:"canyon.com",runContext:{urlPattern:"^https://www\\.canyon\\.com/"},prehideSelectors:["div.modal.cookiesModal.is-open"],detectCmp:[{exists:"div.modal.cookiesModal.is-open"}],detectPopup:[{visible:"div.modal.cookiesModal.is-open"}],optIn:[{click:'div.cookiesModal__buttonWrapper > button[data-closecause="close-by-submit"]'}],optOut:[{click:'div.cookiesModal__buttonWrapper > button[data-closecause="close-by-manage-cookies"]'},{waitForThenClick:"button#js-manage-data-privacy-save-button"}]},{name:"cassie",vendorUrl:"https://trustcassie.com",cosmetic:!1,runContext:{main:!0,frame:!1},prehideSelectors:[".cassie-cookie-module"],detectCmp:[{exists:".cassie-pre-banner"}],detectPopup:[{visible:"#cassie_pre_banner_text"}],optIn:[{waitForThenClick:".cassie-accept-all"}],optOut:[{waitForThenClick:".cassie-reject-all"}]},{name:"cc-banner-springer",prehideSelectors:[".cc-banner[data-cc-banner]"],detectCmp:[{exists:".cc-banner[data-cc-banner]"}],detectPopup:[{visible:".cc-banner[data-cc-banner]"}],optIn:[{waitForThenClick:".cc-banner[data-cc-banner] button[data-cc-action=accept]"}],optOut:[{if:{exists:".cc-banner[data-cc-banner] button[data-cc-action=reject]"},then:[{click:".cc-banner[data-cc-banner] button[data-cc-action=reject]"}],else:[{waitForThenClick:".cc-banner[data-cc-banner] button[data-cc-action=preferences]"},{waitFor:".cc-preferences[data-cc-preferences]"},{click:".cc-preferences[data-cc-preferences] input[type=radio][data-cc-action=toggle-category][value=off]",all:!0,optional:!0},{if:{exists:".cc-preferences[data-cc-preferences] button[data-cc-action=reject]"},then:[{click:".cc-preferences[data-cc-preferences] button[data-cc-action=reject]"}],else:[{click:".cc-preferences[data-cc-preferences] button[data-cc-action=save]"}]}]}],test:[{eval:"EVAL_CC_BANNER2_0"}]},{name:"cc_banner",cosmetic:!0,prehideSelectors:[".cc_banner-wrapper"],detectCmp:[{exists:".cc_banner-wrapper"}],detectPopup:[{visible:".cc_banner"}],optIn:[{click:".cc_btn_accept_all"}],optOut:[{hide:".cc_banner-wrapper"}]},{name:"check24-partnerprogramm-de",prehideSelectors:["[data-modal-content]:has([data-toggle-target^='cookie'])"],detectCmp:[{exists:"[data-toggle-target^='cookie']"}],detectPopup:[{visible:"[data-toggle-target^='cookie']",check:"any"}],optIn:[{waitForThenClick:"[data-cookie-accept-all]"}],optOut:[{waitForThenClick:"[data-cookie-dismiss-all]"}]},{name:"ciaopeople.it",prehideSelectors:["#cp-gdpr-choices"],detectCmp:[{exists:"#cp-gdpr-choices"}],detectPopup:[{visible:"#cp-gdpr-choices"}],optIn:[{waitForThenClick:".gdpr-btm__right > button:nth-child(2)"}],optOut:[{waitForThenClick:".gdpr-top-content > button"},{waitFor:".gdpr-top-back"},{waitForThenClick:".gdpr-btm__right > button:nth-child(1)"}],test:[{visible:"#cp-gdpr-choices",check:"none"}]},{vendorUrl:"https://www.civicuk.com/cookie-control/",name:"civic-cookie-control",prehideSelectors:["#ccc-module,#ccc-overlay,#ccc"],detectCmp:[{exists:"#ccc-module,#ccc-notify"}],detectPopup:[{visible:"#ccc"},{visible:"#ccc-module,#ccc-notify"}],optOut:[{if:{exists:"#ccc-notify"},then:[{waitForThenClick:["#ccc #ccc-notify .ccc-notify-buttons","xpath///button[contains(., 'Settings') or contains(., 'Cookie Preferences')]"]},{waitForVisible:"#ccc-module"}]},{if:{exists:"#ccc-reject-settings"},then:[{waitForThenClick:"#ccc-reject-settings"}],else:[{waitForThenClick:"#ccc-dismiss-button"}]}],optIn:[{waitForThenClick:"#ccc-recommended-settings,#ccc-notify-accept"}]},{name:"click.io",prehideSelectors:["#cl-consent"],detectCmp:[{exists:"#cl-consent"}],detectPopup:[{visible:"#cl-consent"}],optIn:[{waitForThenClick:'#cl-consent [data-role="b_agree"]'}],optOut:[{waitFor:'#cl-consent [data-role="b_options"]'},{wait:500},{click:'#cl-consent [data-role="b_options"]'},{waitFor:'.cl-consent-popup.cl-consent-visible [data-role="alloff"]'},{click:'.cl-consent-popup.cl-consent-visible [data-role="alloff"]',all:!0},{click:'[data-role="b_save"]'}],test:[{eval:"EVAL_CLICKIO_0",comment:"TODO: this only checks if we interacted at all"}]},{name:"clinch",intermediate:!1,runContext:{frame:!1,main:!0},prehideSelectors:[".consent-modal[role=dialog]"],detectCmp:[{exists:".consent-modal[role=dialog]"}],detectPopup:[{visible:".consent-modal[role=dialog]"}],optIn:[{click:"#consent_agree"}],optOut:[{if:{exists:"#consent_reject"},then:[{click:"#consent_reject"}],else:[{click:"#manage_cookie_preferences"},{click:"#cookie_consent_preferences input:checked",all:!0,optional:!0},{click:"#consent_save"}]}],test:[{eval:"EVAL_CLINCH_0"}]},{name:"clustrmaps.com",runContext:{urlPattern:"^https://(www\\.)?clustrmaps\\.com/"},cosmetic:!0,prehideSelectors:["#gdpr-cookie-message"],detectCmp:[{exists:"#gdpr-cookie-message"}],detectPopup:[{visible:"#gdpr-cookie-message"}],optIn:[{click:"button#gdpr-cookie-accept"}],optOut:[{hide:"#gdpr-cookie-message"}]},{name:"coinbase",intermediate:!1,runContext:{frame:!0,main:!0,urlPattern:"^https://(www|help)\\.coinbase\\.com"},prehideSelectors:[],detectCmp:[{exists:"div[class^=CookieBannerContent__Container]"}],detectPopup:[{visible:"div[class^=CookieBannerContent__Container]"}],optIn:[{click:"div[class^=CookieBannerContent__CTA] :nth-last-child(1)"}],optOut:[{click:"button[class^=CookieBannerContent__Settings]"},{click:"div[class^=CookiePreferencesModal__CategoryContainer] input:checked",all:!0,optional:!0},{click:"div[class^=CookiePreferencesModal__ButtonContainer] > button"}],test:[{eval:"EVAL_COINBASE_0"}]},{name:"Complianz banner",prehideSelectors:["#cmplz-cookiebanner-container"],detectCmp:[{exists:"#cmplz-cookiebanner-container .cmplz-cookiebanner"}],detectPopup:[{visible:"#cmplz-cookiebanner-container .cmplz-cookiebanner",check:"any"}],optIn:[{waitForThenClick:".cmplz-cookiebanner .cmplz-accept"}],optOut:[{waitForThenClick:".cmplz-cookiebanner .cmplz-deny"}],test:[{eval:"EVAL_COMPLIANZ_BANNER_0"}]},{name:"Complianz categories",prehideSelectors:['.cc-type-categories[aria-describedby="cookieconsent:desc"]'],detectCmp:[{exists:'.cc-type-categories[aria-describedby="cookieconsent:desc"]'}],detectPopup:[{visible:'.cc-type-categories[aria-describedby="cookieconsent:desc"]'}],optIn:[{any:[{click:".cc-accept-all"},{click:".cc-allow-all"},{click:".cc-allow"},{click:".cc-dismiss"}]}],optOut:[{if:{exists:'.cc-type-categories[aria-describedby="cookieconsent:desc"] .cc-dismiss'},then:[{click:".cc-dismiss"}],else:[{click:".cc-type-categories input[type=checkbox]:not([disabled]):checked",all:!0,optional:!0},{click:".cc-save"}]}]},{name:"Complianz notice",prehideSelectors:['.cc-type-info[aria-describedby="cookieconsent:desc"]'],cosmetic:!0,detectCmp:[{exists:'.cc-type-info[aria-describedby="cookieconsent:desc"] .cc-compliance .cc-btn'}],detectPopup:[{visible:'.cc-type-info[aria-describedby="cookieconsent:desc"] .cc-compliance .cc-btn'}],optIn:[{click:".cc-accept-all",optional:!0},{click:".cc-allow",optional:!0},{click:".cc-dismiss",optional:!0}],optOut:[{if:{exists:".cc-deny"},then:[{click:".cc-deny"}],else:[{hide:'[aria-describedby="cookieconsent:desc"]'}]}]},{name:"Complianz opt-both",prehideSelectors:['[aria-describedby="cookieconsent:desc"] .cc-type-opt-both'],detectCmp:[{exists:'[aria-describedby="cookieconsent:desc"] .cc-type-opt-both'}],detectPopup:[{visible:'[aria-describedby="cookieconsent:desc"] .cc-type-opt-both'}],optIn:[{click:".cc-accept-all",optional:!0},{click:".cc-allow",optional:!0},{click:".cc-dismiss",optional:!0}],optOut:[{waitForThenClick:".cc-deny"}]},{name:"Complianz opt-out",prehideSelectors:['[aria-describedby="cookieconsent:desc"].cc-type-opt-out'],detectCmp:[{exists:'[aria-describedby="cookieconsent:desc"].cc-type-opt-out'}],detectPopup:[{visible:'[aria-describedby="cookieconsent:desc"].cc-type-opt-out'}],optIn:[{click:".cc-accept-all",optional:!0},{click:".cc-allow",optional:!0},{click:".cc-dismiss",optional:!0}],optOut:[{if:{exists:".cc-deny"},then:[{click:".cc-deny"}],else:[{if:{exists:".cmp-pref-link"},then:[{click:".cmp-pref-link"},{waitForThenClick:".cmp-body [id*=rejectAll]"},{waitForThenClick:".cmp-body .cmp-save-btn"}]}]}]},{name:"Complianz optin",prehideSelectors:['.cc-type-opt-in[aria-describedby="cookieconsent:desc"]'],detectCmp:[{exists:'.cc-type-opt-in[aria-describedby="cookieconsent:desc"]'}],detectPopup:[{visible:'.cc-type-opt-in[aria-describedby="cookieconsent:desc"]'}],optIn:[{any:[{click:".cc-accept-all"},{click:".cc-allow"},{click:".cc-dismiss"}]}],optOut:[{if:{visible:".cc-deny"},then:[{click:".cc-deny"}],else:[{if:{visible:".cc-settings"},then:[{waitForThenClick:".cc-settings"},{waitForVisible:".cc-settings-view"},{click:".cc-settings-view input[type=checkbox]:not([disabled]):checked",all:!0,optional:!0},{click:".cc-settings-view .cc-btn-accept-selected"}],else:[{click:".cc-dismiss"}]}]}]},{name:"cookie-consent-spice",cosmetic:!1,runContext:{main:!0,frame:!1},prehideSelectors:[".spicy-consent-wrapper",".spicy-consent-bar"],detectCmp:[{exists:".spicy-consent-bar"}],detectPopup:[{visible:".spicy-consent-bar"}],optIn:[{waitForThenClick:".spicy-consent-bar__action-accept"}],optOut:[{waitForThenClick:".js-decline-all-cookies"}]},{name:"cookie-law-info",prehideSelectors:["#cookie-law-info-bar"],detectCmp:[{exists:"#cookie-law-info-bar"},{eval:"EVAL_COOKIE_LAW_INFO_DETECT"}],detectPopup:[{visible:"#cookie-law-info-bar"}],optIn:[{click:'[data-cli_action="accept_all"]'}],optOut:[{hide:"#cookie-law-info-bar"},{eval:"EVAL_COOKIE_LAW_INFO_0"}],test:[{eval:"EVAL_COOKIE_LAW_INFO_1"}]},{name:"cookie-manager-popup",cosmetic:!1,runContext:{main:!0,frame:!1},intermediate:!1,detectCmp:[{exists:"#notice-cookie-block #allow-functional-cookies, #notice-cookie-block #btn-cookie-settings"}],detectPopup:[{visible:"#notice-cookie-block"}],optIn:[{click:"#btn-cookie-allow"}],optOut:[{if:{exists:"#allow-functional-cookies"},then:[{click:"#allow-functional-cookies"}],else:[{waitForThenClick:"#btn-cookie-settings"},{waitForVisible:".modal-body"},{click:'.modal-body input:checked, .switch[data-switch="on"]',all:!0,optional:!0},{click:'[role="dialog"] .modal-footer button'}]}],prehideSelectors:["#btn-cookie-settings"],test:[{eval:"EVAL_COOKIE_MANAGER_POPUP_0"}]},{name:"cookie-notice",prehideSelectors:["#cookie-notice"],cosmetic:!0,detectCmp:[{visible:"#cookie-notice .cookie-notice-container"}],detectPopup:[{visible:"#cookie-notice"}],optIn:[{click:"#cn-accept-cookie"}],optOut:[{hide:"#cookie-notice"}]},{name:"cookie-script",vendorUrl:"https://cookie-script.com/",prehideSelectors:["#cookiescript_injected"],detectCmp:[{exists:"#cookiescript_injected"}],detectPopup:[{visible:"#cookiescript_injected"}],optOut:[{if:{exists:"#cookiescript_reject"},then:[{wait:100},{click:"#cookiescript_reject"}],else:[{click:"#cookiescript_manage"},{waitForVisible:".cookiescript_fsd_main"},{waitForThenClick:"#cookiescript_reject"}]}],optIn:[{click:"#cookiescript_accept"}]},{name:"cookieacceptbar",vendorUrl:"https://unknown",cosmetic:!0,prehideSelectors:["#cookieAcceptBar.cookieAcceptBar"],detectCmp:[{exists:"#cookieAcceptBar.cookieAcceptBar"}],detectPopup:[{visible:"#cookieAcceptBar.cookieAcceptBar"}],optIn:[{waitForThenClick:"#cookieAcceptBarConfirm"}],optOut:[{hide:"#cookieAcceptBar.cookieAcceptBar"}]},{name:"cookiealert",intermediate:!1,prehideSelectors:[],runContext:{frame:!0,main:!0},detectCmp:[{exists:".cookie-alert-extended"}],detectPopup:[{visible:".cookie-alert-extended-modal"}],optIn:[{click:"button[data-controller='cookie-alert/extended/button/accept']"},{eval:"EVAL_COOKIEALERT_0"}],optOut:[{click:"a[data-controller='cookie-alert/extended/detail-link']"},{click:".cookie-alert-configuration-input:checked",all:!0,optional:!0},{click:"button[data-controller='cookie-alert/extended/button/configuration']"},{eval:"EVAL_COOKIEALERT_0"}],test:[{eval:"EVAL_COOKIEALERT_2"}]},{name:"cookieconsent2",vendorUrl:"https://www.github.com/orestbida/cookieconsent",comment:"supports v2.x.x of the library",prehideSelectors:["#cc--main"],detectCmp:[{exists:"#cc--main"}],detectPopup:[{visible:"#cm"},{exists:"#s-all-bn"}],optIn:[{waitForThenClick:"#s-all-bn"}],optOut:[{waitForThenClick:"#s-rall-bn"}],test:[{eval:"EVAL_COOKIECONSENT2_TEST"}]},{name:"cookieconsent3",vendorUrl:"https://www.github.com/orestbida/cookieconsent",comment:"supports v3.x.x of the library",prehideSelectors:["#cc-main"],detectCmp:[{exists:"#cc-main"}],detectPopup:[{visible:"#cc-main .cm-wrapper"}],optIn:[{waitForThenClick:".cm__btn[data-role=all]"}],optOut:[{waitForThenClick:".cm__btn[data-role=necessary]"}],test:[{eval:"EVAL_COOKIECONSENT3_TEST"}]},{name:"cookiecuttr",vendorUrl:"https://github.com/cdwharton/cookieCuttr",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:""},prehideSelectors:[".cc-cookies"],detectCmp:[{exists:".cc-cookies .cc-cookie-accept"}],detectPopup:[{visible:".cc-cookies .cc-cookie-accept"}],optIn:[{waitForThenClick:".cc-cookies .cc-cookie-accept"}],optOut:[{if:{exists:".cc-cookies .cc-cookie-decline"},then:[{click:".cc-cookies .cc-cookie-decline"}],else:[{hide:".cc-cookies"}]}]},{name:"cookiefirst.com",prehideSelectors:["#cookiefirst-root,.cookiefirst-root,[aria-labelledby=cookie-preference-panel-title]"],detectCmp:[{exists:"#cookiefirst-root,.cookiefirst-root"}],detectPopup:[{visible:"#cookiefirst-root,.cookiefirst-root"}],optIn:[{click:"button[data-cookiefirst-action=accept]"}],optOut:[{if:{exists:"button[data-cookiefirst-action=adjust]"},then:[{click:"button[data-cookiefirst-action=adjust]"},{waitForVisible:"[data-cookiefirst-widget=modal]",timeout:1e3},{eval:"EVAL_COOKIEFIRST_1"},{wait:1e3},{click:"button[data-cookiefirst-action=save]"}],else:[{click:"button[data-cookiefirst-action=reject]"}]}],test:[{eval:"EVAL_COOKIEFIRST_0"}]},{name:"Cookie Information Banner",prehideSelectors:["#cookie-information-template-wrapper"],detectCmp:[{exists:"#cookie-information-template-wrapper"}],detectPopup:[{visible:"#cookie-information-template-wrapper"}],optIn:[{eval:"EVAL_COOKIEINFORMATION_1"}],optOut:[{hide:"#cookie-information-template-wrapper",comment:"some templates don't hide the banner automatically"},{eval:"EVAL_COOKIEINFORMATION_0"}],test:[{eval:"EVAL_COOKIEINFORMATION_2"}]},{name:"cookieyes",prehideSelectors:[".cky-overlay,.cky-consent-container"],detectCmp:[{exists:".cky-consent-container"}],detectPopup:[{visible:".cky-consent-container"}],optIn:[{waitForThenClick:".cky-consent-container [data-cky-tag=accept-button]"}],optOut:[{if:{exists:".cky-consent-container [data-cky-tag=reject-button]"},then:[{waitForThenClick:".cky-consent-container [data-cky-tag=reject-button]"}],else:[{if:{exists:".cky-consent-container [data-cky-tag=settings-button]"},then:[{click:".cky-consent-container [data-cky-tag=settings-button]"},{waitFor:".cky-modal-open input[type=checkbox]"},{click:".cky-modal-open input[type=checkbox]:checked",all:!0,optional:!0},{waitForThenClick:".cky-modal [data-cky-tag=detail-save-button]"}],else:[{hide:".cky-consent-container,.cky-overlay"}]}]}],test:[{eval:"EVAL_COOKIEYES_0"}]},{name:"corona-in-zahlen.de",prehideSelectors:[".cookiealert"],detectCmp:[{exists:".cookiealert"}],detectPopup:[{visible:".cookiealert"}],optOut:[{click:".configurecookies"},{click:".confirmcookies"}],optIn:[{click:".acceptcookies"}]},{name:"crossfit-com",cosmetic:!0,prehideSelectors:['body #modal > div > div[class^="_wrapper_"]'],detectCmp:[{exists:'body #modal > div > div[class^="_wrapper_"]'}],detectPopup:[{visible:'body #modal > div > div[class^="_wrapper_"]'}],optIn:[{click:'button[aria-label="accept cookie policy"]'}],optOut:[{hide:'body #modal > div > div[class^="_wrapper_"]'}]},{name:"csu-landtag-de",runContext:{urlPattern:"^https://(www\\.|)?csu-landtag\\.de"},prehideSelectors:["#cookie-disclaimer"],detectCmp:[{exists:"#cookie-disclaimer"}],detectPopup:[{visible:"#cookie-disclaimer"}],optIn:[{click:"#cookieall"}],optOut:[{click:"#cookiesel"}]},{name:"dailymotion-us",cosmetic:!0,prehideSelectors:['div[class*="CookiePopup__desktopContainer"]:has(div[class*="CookiePopup"])'],detectCmp:[{exists:'div[class*="CookiePopup__desktopContainer"]'}],detectPopup:[{visible:'div[class*="CookiePopup__desktopContainer"]'}],optIn:[{click:'div[class*="CookiePopup__desktopContainer"] > button > span'}],optOut:[{hide:'div[class*="CookiePopup__desktopContainer"]'}]},{name:"dailymotion.com",runContext:{urlPattern:"^https://(www\\.)?dailymotion\\.com/"},prehideSelectors:['div[class*="Overlay__container"]:has(div[class*="TCF2Popup"])'],detectCmp:[{exists:'div[class*="TCF2Popup"]'}],detectPopup:[{visible:'[class*="TCF2Popup"] a[href^="https://www.dailymotion.com/legal/cookiemanagement"]'}],optIn:[{waitForThenClick:'button[class*="TCF2Popup__button"]:not([class*="TCF2Popup__personalize"])'}],optOut:[{waitForThenClick:'button[class*="TCF2ContinueWithoutAcceptingButton"]'}],test:[{eval:"EVAL_DAILYMOTION_0"}]},{name:"dan-com",vendorUrl:"https://unknown",runContext:{main:!0,frame:!1},prehideSelectors:[],detectCmp:[{exists:".cookie-banner.show .cookie-banner__content-all-btn"}],detectPopup:[{visible:".cookie-banner.show .cookie-banner__content-all-btn"}],optIn:[{waitForThenClick:".cookie-banner__content-all-btn"}],optOut:[{waitForThenClick:".cookie-banner__content-essential-btn"}]},{name:"deepl.com",prehideSelectors:[".dl_cookieBanner_container"],detectCmp:[{exists:".dl_cookieBanner_container"}],detectPopup:[{visible:".dl_cookieBanner_container"}],optOut:[{click:".dl_cookieBanner--buttonSelected"}],optIn:[{click:".dl_cookieBanner--buttonAll"}]},{name:"delta.com",runContext:{urlPattern:"^https://www\\.delta\\.com/"},cosmetic:!0,prehideSelectors:["ngc-cookie-banner"],detectCmp:[{exists:"div.cookie-footer-container"}],detectPopup:[{visible:"div.cookie-footer-container"}],optIn:[{click:" button.cookie-close-icon"}],optOut:[{hide:"div.cookie-footer-container"}]},{name:"dmgmedia-us",prehideSelectors:["#mol-ads-cmp-iframe, div.mol-ads-cmp > form > div"],detectCmp:[{exists:"div.mol-ads-cmp > form > div"}],detectPopup:[{waitForVisible:"div.mol-ads-cmp > form > div"}],optIn:[{waitForThenClick:"button.mol-ads-cmp--btn-primary"}],optOut:[{waitForThenClick:"div.mol-ads-ccpa--message > u > a"},{waitForVisible:".mol-ads-cmp--modal-dialog"},{waitForThenClick:"a.mol-ads-cmp-footer-privacy"},{waitForThenClick:"button.mol-ads-cmp--btn-secondary"}]},{name:"dmgmedia",prehideSelectors:['[data-project="mol-fe-cmp"]'],detectCmp:[{exists:'[data-project="mol-fe-cmp"] [class*=footer]'}],detectPopup:[{visible:'[data-project="mol-fe-cmp"] [class*=footer]'}],optIn:[{waitForThenClick:'[data-project="mol-fe-cmp"] button[class*=primary]'}],optOut:[{waitForThenClick:'[data-project="mol-fe-cmp"] button[class*=basic]'},{waitForVisible:'[data-project="mol-fe-cmp"] div[class*="tabContent"]'},{waitForThenClick:'[data-project="mol-fe-cmp"] div[class*="toggle"][class*="enabled"]',all:!0},{waitForThenClick:['[data-project="mol-fe-cmp"] [class*=footer]',"xpath///button[contains(., 'Save & Exit')]"]}]},{name:"dndbeyond",vendorUrl:"https://www.dndbeyond.com/",runContext:{urlPattern:"^https://(www\\.)?dndbeyond\\.com/"},prehideSelectors:["[id^=cookie-consent-banner]"],detectCmp:[{exists:"[id^=cookie-consent-banner]"}],detectPopup:[{visible:"[id^=cookie-consent-banner]"}],optIn:[{waitForThenClick:"#cookie-consent-granted"}],optOut:[{waitForThenClick:"#cookie-consent-denied"}],test:[{eval:"EVAL_DNDBEYOND_TEST"}]},{name:"dpgmedia-nl",prehideSelectors:["#pg-shadow-root-host"],detectCmp:[{exists:"#pg-shadow-root-host"}],detectPopup:[{visible:["#pg-shadow-root-host","#pg-modal"]}],optIn:[{waitForThenClick:["#pg-shadow-root-host","#pg-accept-btn"]}],optOut:[{waitForThenClick:["#pg-shadow-root-host","#pg-configure-btn"]},{waitForThenClick:["#pg-shadow-root-host","#pg-reject-btn"]}]},{name:"Drupal",detectCmp:[{exists:"#drupalorg-crosssite-gdpr"}],detectPopup:[{visible:"#drupalorg-crosssite-gdpr"}],optOut:[{click:".no"}],optIn:[{click:".yes"}]},{name:"WP DSGVO Tools",link:"https://wordpress.org/plugins/shapepress-dsgvo/",prehideSelectors:[".sp-dsgvo"],cosmetic:!0,detectCmp:[{exists:".sp-dsgvo.sp-dsgvo-popup-overlay"}],detectPopup:[{visible:".sp-dsgvo.sp-dsgvo-popup-overlay",check:"any"}],optIn:[{click:".sp-dsgvo-privacy-btn-accept-all",all:!0}],optOut:[{hide:".sp-dsgvo.sp-dsgvo-popup-overlay"}],test:[{eval:"EVAL_DSGVO_0"}]},{name:"dunelm.com",prehideSelectors:["div[data-testid=cookie-consent-modal-backdrop]"],detectCmp:[{exists:"div[data-testid=cookie-consent-message-contents]"}],detectPopup:[{visible:"div[data-testid=cookie-consent-message-contents]"}],optIn:[{click:'[data-testid="cookie-consent-allow-all"]'}],optOut:[{click:"button[data-testid=cookie-consent-adjust-settings]"},{click:"button[data-testid=cookie-consent-preferences-save]"}],test:[{eval:"EVAL_DUNELM_0"}]},{name:"ebay",vendorUrl:"https://ebay.com",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?ebay\\.([.a-z]+)/"},prehideSelectors:["#gdpr-banner"],detectCmp:[{exists:"#gdpr-banner"}],detectPopup:[{visible:"#gdpr-banner"}],optIn:[{waitForThenClick:"#gdpr-banner-accept"}],optOut:[{waitForThenClick:"#gdpr-banner-decline"}]},{name:"ecosia",vendorUrl:"https://www.ecosia.org/",runContext:{urlPattern:"^https://www\\.ecosia\\.org/"},prehideSelectors:[".cookie-wrapper"],detectCmp:[{exists:".cookie-wrapper > .cookie-notice"}],detectPopup:[{visible:".cookie-wrapper > .cookie-notice"}],optIn:[{waitForThenClick:"[data-test-id=cookie-notice-accept]"}],optOut:[{waitForThenClick:"[data-test-id=cookie-notice-reject]"}]},{name:"Ensighten ensModal",prehideSelectors:[".ensModal"],detectCmp:[{exists:".ensModal"},{visible:"#ensModalWrapper[style*=block]"}],detectPopup:[{visible:"#ensModalWrapper[style*=block]"}],optIn:[{waitForThenClick:"#modalAcceptButton"}],optOut:[{wait:500},{visible:"#ensModalWrapper[style*=block]"},{waitForThenClick:".ensCheckbox:checked",all:!0},{waitForThenClick:"#ensSave"}]},{name:"Ensighten ensNotifyBanner",prehideSelectors:["#ensNotifyBanner"],detectCmp:[{exists:"#ensNotifyBanner"}],detectPopup:[{visible:"#ensNotifyBanner[style*=block]"}],optIn:[{waitForThenClick:"#ensCloseBanner"}],optOut:[{wait:500},{visible:"#ensNotifyBanner[style*=block]"},{waitForThenClick:"#ensRejectAll,#rejectAll,#ensRejectBanner,.rejectAll,#ensCloseBanner",timeout:2e3}]},{name:"espace-personnel.agirc-arrco.fr",runContext:{urlPattern:"^https://espace-personnel\\.agirc-arrco\\.fr/"},prehideSelectors:[".cdk-overlay-container"],detectCmp:[{exists:".cdk-overlay-container app-esaa-cookie-component"}],detectPopup:[{visible:".cdk-overlay-container app-esaa-cookie-component"}],optIn:[{waitForThenClick:".btn-cookie-accepter"}],optOut:[{waitForThenClick:".btn-cookie-refuser"}]},{name:"etsy",prehideSelectors:["#gdpr-single-choice-overlay","#gdpr-privacy-settings"],detectCmp:[{exists:"#gdpr-single-choice-overlay"}],detectPopup:[{visible:"#gdpr-single-choice-overlay"}],optOut:[{click:"button[data-gdpr-open-full-settings]"},{waitForVisible:".gdpr-overlay-body input",timeout:3e3},{wait:1e3},{eval:"EVAL_ETSY_0"},{eval:"EVAL_ETSY_1"}],optIn:[{click:"button[data-gdpr-single-choice-accept]"}]},{name:"eu-cookie-compliance-banner",detectCmp:[{exists:"body.eu-cookie-compliance-popup-open"}],detectPopup:[{exists:"body.eu-cookie-compliance-popup-open"}],optIn:[{click:".agree-button"}],optOut:[{if:{visible:".decline-button,.eu-cookie-compliance-save-preferences-button"},then:[{click:".decline-button,.eu-cookie-compliance-save-preferences-button"}]},{hide:".eu-cookie-compliance-banner-info, #sliding-popup"}],test:[{eval:"EVAL_EU_COOKIE_COMPLIANCE_0"}]},{name:"EU Cookie Law",prehideSelectors:[".pea_cook_wrapper,.pea_cook_more_info_popover"],cosmetic:!0,detectCmp:[{exists:".pea_cook_wrapper"}],detectPopup:[{wait:500},{visible:".pea_cook_wrapper"}],optIn:[{click:"#pea_cook_btn"}],optOut:[{hide:".pea_cook_wrapper"}],test:[{eval:"EVAL_EU_COOKIE_LAW_0"}]},{name:"europa-eu",vendorUrl:"https://ec.europa.eu/",runContext:{urlPattern:"^https://[^/]*europa\\.eu/"},prehideSelectors:["#cookie-consent-banner"],detectCmp:[{exists:".cck-container"}],detectPopup:[{visible:".cck-container"}],optIn:[{waitForThenClick:'.cck-actions-button[href="#accept"]'}],optOut:[{waitForThenClick:'.cck-actions-button[href="#refuse"]',hide:".cck-container"}]},{name:"EZoic",prehideSelectors:["#ez-cookie-dialog-wrapper"],detectCmp:[{exists:"#ez-cookie-dialog-wrapper"}],detectPopup:[{visible:"#ez-cookie-dialog-wrapper"}],optIn:[{click:"#ez-accept-all",optional:!0},{eval:"EVAL_EZOIC_0",optional:!0}],optOut:[{wait:500},{click:"#ez-manage-settings"},{waitFor:"#ez-cookie-dialog input[type=checkbox]"},{click:"#ez-cookie-dialog input[type=checkbox]:checked",all:!0},{click:"#ez-save-settings"}],test:[{eval:"EVAL_EZOIC_1"}]},{name:"facebook",runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?facebook\\.com/"},prehideSelectors:['div[data-testid="cookie-policy-manage-dialog"]'],detectCmp:[{exists:'div[data-testid="cookie-policy-manage-dialog"]'}],detectPopup:[{visible:'div[data-testid="cookie-policy-manage-dialog"]'}],optIn:[{waitForThenClick:'button[data-cookiebanner="accept_button"]'},{waitForVisible:'div[data-testid="cookie-policy-manage-dialog"]',check:"none"}],optOut:[{waitForThenClick:'button[data-cookiebanner="accept_only_essential_button"]'},{waitForVisible:'div[data-testid="cookie-policy-manage-dialog"]',check:"none"}]},{name:"fides",vendorUrl:"https://github.com/ethyca/fides",prehideSelectors:["#fides-overlay"],detectCmp:[{exists:"#fides-overlay #fides-banner"}],detectPopup:[{visible:"#fides-overlay #fides-banner"},{eval:"EVAL_FIDES_DETECT_POPUP"}],optIn:[{waitForThenClick:"#fides-banner .fides-accept-all-button"}],optOut:[{waitForThenClick:"#fides-banner .fides-reject-all-button"}]},{name:"funding-choices",prehideSelectors:[".fc-consent-root,.fc-dialog-container,.fc-dialog-overlay,.fc-dialog-content"],detectCmp:[{exists:".fc-consent-root"}],detectPopup:[{exists:".fc-dialog-container"}],optOut:[{click:".fc-cta-do-not-consent,.fc-cta-manage-options"},{click:".fc-preference-consent:checked,.fc-preference-legitimate-interest:checked",all:!0,optional:!0},{click:".fc-confirm-choices",optional:!0}],optIn:[{click:".fc-cta-consent"}]},{name:"geeks-for-geeks",runContext:{urlPattern:"^https://www\\.geeksforgeeks\\.org/"},cosmetic:!0,prehideSelectors:[".cookie-consent"],detectCmp:[{exists:".cookie-consent"}],detectPopup:[{visible:".cookie-consent"}],optIn:[{click:".cookie-consent button.consent-btn"}],optOut:[{hide:".cookie-consent"}]},{name:"google-consent-standalone",prehideSelectors:[],detectCmp:[{exists:'a[href^="https://policies.google.com/technologies/cookies"'},{exists:'form[action^="https://consent.google."][action$="/save"]'}],detectPopup:[{visible:'a[href^="https://policies.google.com/technologies/cookies"'}],optIn:[{waitForThenClick:'form[action^="https://consent.google."][action$="/save"]:has(input[name=set_eom][value=false]) button'}],optOut:[{waitForThenClick:'form[action^="https://consent.google."][action$="/save"]:has(input[name=set_eom][value=true]) button'}]},{name:"google-cookiebar",vendorUrl:"https://www.android.com/better-together/quick-share-app/",cosmetic:!1,prehideSelectors:[".glue-cookie-notification-bar"],detectCmp:[{exists:".glue-cookie-notification-bar"}],detectPopup:[{visible:".glue-cookie-notification-bar"}],optIn:[{waitForThenClick:".glue-cookie-notification-bar__accept"}],optOut:[{if:{exists:".glue-cookie-notification-bar__reject"},then:[{click:".glue-cookie-notification-bar__reject"}],else:[{hide:".glue-cookie-notification-bar"}]}],test:[]},{name:"google.com",prehideSelectors:[".HTjtHe#xe7COe"],detectCmp:[{exists:".HTjtHe#xe7COe"},{exists:'.HTjtHe#xe7COe a[href^="https://policies.google.com/technologies/cookies"]'}],detectPopup:[{visible:".HTjtHe#xe7COe button#W0wltc"}],optIn:[{waitForThenClick:".HTjtHe#xe7COe button#L2AGLb"}],optOut:[{waitForThenClick:".HTjtHe#xe7COe button#W0wltc"}],test:[{eval:"EVAL_GOOGLE_0"}]},{name:"gov.uk",detectCmp:[{exists:"#global-cookie-message"}],detectPopup:[{exists:"#global-cookie-message"}],optIn:[{click:"button[data-accept-cookies=true]"}],optOut:[{click:"button[data-reject-cookies=true],#reject-cookies"},{click:"button[data-hide-cookie-banner=true],#hide-cookie-decision"}]},{name:"hashicorp",vendorUrl:"https://hashicorp.com/",runContext:{urlPattern:"^https://[^.]*\\.hashicorp\\.com/"},prehideSelectors:["[data-testid=consent-banner]"],detectCmp:[{exists:"[data-testid=consent-banner]"}],detectPopup:[{visible:"[data-testid=consent-banner]"}],optIn:[{waitForThenClick:"[data-testid=accept]"}],optOut:[{waitForThenClick:"[data-testid=manage-preferences]"},{waitForThenClick:"[data-testid=consent-mgr-dialog] [data-ga-button=save-preferences]"}]},{name:"healthline-media",prehideSelectors:["#modal-host > div.no-hash > div.window-wrapper"],detectCmp:[{exists:"#modal-host > div.no-hash > div.window-wrapper, div[data-testid=qualtrics-container]"}],detectPopup:[{exists:"#modal-host > div.no-hash > div.window-wrapper, div[data-testid=qualtrics-container]"}],optIn:[{click:"#modal-host > div.no-hash > div.window-wrapper > div:last-child button"}],optOut:[{if:{exists:'#modal-host > div.no-hash > div.window-wrapper > div:last-child a[href="/privacy-settings"]'},then:[{click:'#modal-host > div.no-hash > div.window-wrapper > div:last-child a[href="/privacy-settings"]'}],else:[{waitForVisible:"div#__next"},{click:"#__next div:nth-child(1) > button:first-child"}]}]},{name:"hema",prehideSelectors:[".cookie-modal"],detectCmp:[{visible:".cookie-modal .cookie-accept-btn"}],detectPopup:[{visible:".cookie-modal .cookie-accept-btn"}],optIn:[{waitForThenClick:".cookie-modal .cookie-accept-btn"}],optOut:[{waitForThenClick:".cookie-modal .js-cookie-reject-btn"}],test:[{eval:"EVAL_HEMA_TEST_0"}]},{name:"hetzner.com",runContext:{urlPattern:"^https://www\\.hetzner\\.com/"},prehideSelectors:["#CookieConsent"],detectCmp:[{exists:"#CookieConsent"}],detectPopup:[{visible:"#CookieConsent"}],optIn:[{click:"#CookieConsentGiven"}],optOut:[{click:"#CookieConsentDeclined"}]},{name:"hl.co.uk",prehideSelectors:[".cookieModalContent","#cookie-banner-overlay"],detectCmp:[{exists:"#cookie-banner-overlay"}],detectPopup:[{exists:"#cookie-banner-overlay"}],optIn:[{click:"#acceptCookieButton"}],optOut:[{click:"#manageCookie"},{hide:".cookieSettingsModal"},{waitFor:"#AOCookieToggle"},{click:"#AOCookieToggle[aria-pressed=true]",optional:!0},{waitFor:"#TPCookieToggle"},{click:"#TPCookieToggle[aria-pressed=true]",optional:!0},{click:"#updateCookieButton"}]},{name:"holidaymedia",vendorUrl:"https://holidaymedia.nl/",prehideSelectors:["dialog[data-cookie-consent]"],detectCmp:[{exists:"dialog[data-cookie-consent]"}],detectPopup:[{visible:"dialog[data-cookie-consent]"}],optIn:[{waitForThenClick:"button.cookie-consent__button--accept-all"}],optOut:[{waitForThenClick:'a[data-cookie-accept="functional"]',timeout:2e3}]},{name:"hu-manity",vendorUrl:"https://hu-manity.co/",prehideSelectors:["#hu.hu-wrapper"],detectCmp:[{exists:"#hu.hu-visible"}],detectPopup:[{visible:"#hu.hu-visible"}],optIn:[{waitForThenClick:"[data-hu-action=cookies-notice-consent-choices-3]"},{waitForThenClick:"#hu-cookies-save"}],optOut:[{waitForThenClick:"#hu-cookies-save"}]},{name:"hubspot",detectCmp:[{exists:"#hs-eu-cookie-confirmation"}],detectPopup:[{visible:"#hs-eu-cookie-confirmation"}],optIn:[{click:"#hs-eu-confirmation-button"}],optOut:[{click:"#hs-eu-decline-button"}]},{name:"indeed.com",cosmetic:!0,prehideSelectors:["#CookiePrivacyNotice"],detectCmp:[{exists:"#CookiePrivacyNotice"}],detectPopup:[{visible:"#CookiePrivacyNotice"}],optIn:[{click:"#CookiePrivacyNotice button[data-gnav-element-name=CookiePrivacyNoticeOk]"}],optOut:[{hide:"#CookiePrivacyNotice"}]},{name:"ing.de",runContext:{urlPattern:"^https://www\\.ing\\.de/"},cosmetic:!0,prehideSelectors:['div[slot="backdrop"]'],detectCmp:[{exists:'[data-tag-name="ing-cc-dialog-frame"]'}],detectPopup:[{visible:'[data-tag-name="ing-cc-dialog-frame"]'}],optIn:[{click:['[data-tag-name="ing-cc-dialog-level0"]','[data-tag-name="ing-cc-button"][class*="accept"]']}],optOut:[{click:['[data-tag-name="ing-cc-dialog-level0"]','[data-tag-name="ing-cc-button"][class*="more"]']}]},{name:"instagram",vendorUrl:"https://instagram.com",runContext:{urlPattern:"^https://www\\.instagram\\.com/"},prehideSelectors:[],detectCmp:[{exists:'xpath///span[contains(., "Vill du tillåta användningen av cookies från Instagram i den här webbläsaren?") or contains(., "Allow the use of cookies from Instagram on this browser?") or contains(., "Povolit v prohlížeči použití souborů cookie z Instagramu?") or contains(., "Dopustiti upotrebu kolačića s Instagrama na ovom pregledniku?") or contains(., "Разрешить использование файлов cookie от Instagram в этом браузере?") or contains(., "Vuoi consentire l\'uso dei cookie di Instagram su questo browser?") or contains(., "Povoliť používanie cookies zo služby Instagram v tomto prehliadači?") or contains(., "Die Verwendung von Cookies durch Instagram in diesem Browser erlauben?") or contains(., "Sallitaanko Instagramin evästeiden käyttö tällä selaimella?") or contains(., "Engedélyezed az Instagram cookie-jainak használatát ebben a böngészőben?") or contains(., "Het gebruik van cookies van Instagram toestaan in deze browser?") or contains(., "Bu tarayıcıda Instagram\'dan çerez kullanımına izin verilsin mi?") or contains(., "Permitir o uso de cookies do Instagram neste navegador?") or contains(., "Permiţi folosirea modulelor cookie de la Instagram în acest browser?") or contains(., "Autoriser l’utilisation des cookies d’Instagram sur ce navigateur ?") or contains(., "¿Permitir el uso de cookies de Instagram en este navegador?") or contains(., "Zezwolić na użycie plików cookie z Instagramu w tej przeglądarce?") or contains(., "Να επιτρέπεται η χρήση cookies από τo Instagram σε αυτό το πρόγραμμα περιήγησης;") or contains(., "Разрешавате ли използването на бисквитки от Instagram на този браузър?") or contains(., "Vil du tillade brugen af cookies fra Instagram i denne browser?") or contains(., "Vil du tillate bruk av informasjonskapsler fra Instagram i denne nettleseren?")]'}],detectPopup:[{visible:'xpath///span[contains(., "Vill du tillåta användningen av cookies från Instagram i den här webbläsaren?") or contains(., "Allow the use of cookies from Instagram on this browser?") or contains(., "Povolit v prohlížeči použití souborů cookie z Instagramu?") or contains(., "Dopustiti upotrebu kolačića s Instagrama na ovom pregledniku?") or contains(., "Разрешить использование файлов cookie от Instagram в этом браузере?") or contains(., "Vuoi consentire l\'uso dei cookie di Instagram su questo browser?") or contains(., "Povoliť používanie cookies zo služby Instagram v tomto prehliadači?") or contains(., "Die Verwendung von Cookies durch Instagram in diesem Browser erlauben?") or contains(., "Sallitaanko Instagramin evästeiden käyttö tällä selaimella?") or contains(., "Engedélyezed az Instagram cookie-jainak használatát ebben a böngészőben?") or contains(., "Het gebruik van cookies van Instagram toestaan in deze browser?") or contains(., "Bu tarayıcıda Instagram\'dan çerez kullanımına izin verilsin mi?") or contains(., "Permitir o uso de cookies do Instagram neste navegador?") or contains(., "Permiţi folosirea modulelor cookie de la Instagram în acest browser?") or contains(., "Autoriser l’utilisation des cookies d’Instagram sur ce navigateur ?") or contains(., "¿Permitir el uso de cookies de Instagram en este navegador?") or contains(., "Zezwolić na użycie plików cookie z Instagramu w tej przeglądarce?") or contains(., "Να επιτρέπεται η χρήση cookies από τo Instagram σε αυτό το πρόγραμμα περιήγησης;") or contains(., "Разрешавате ли използването на бисквитки от Instagram на този браузър?") or contains(., "Vil du tillade brugen af cookies fra Instagram i denne browser?") or contains(., "Vil du tillate bruk av informasjonskapsler fra Instagram i denne nettleseren?")]'}],optIn:[{waitForThenClick:"xpath///button[contains(., 'Tillad alle cookies') or contains(., 'Alle Cookies erlauben') or contains(., 'Allow all cookies') or contains(., 'Разрешаване на всички бисквитки') or contains(., 'Tillåt alla cookies') or contains(., 'Povolit všechny soubory cookie') or contains(., 'Tüm çerezlere izin ver') or contains(., 'Permite toate modulele cookie') or contains(., 'Να επιτρέπονται όλα τα cookies') or contains(., 'Tillat alle informasjonskapsler') or contains(., 'Povoliť všetky cookies') or contains(., 'Permitir todas las cookies') or contains(., 'Permitir todos os cookies') or contains(., 'Alle cookies toestaan') or contains(., 'Salli kaikki evästeet') or contains(., 'Consenti tutti i cookie') or contains(., 'Az összes cookie engedélyezése') or contains(., 'Autoriser tous les cookies') or contains(., 'Zezwól na wszystkie pliki cookie') or contains(., 'Разрешить все cookie') or contains(., 'Dopusti sve kolačiće')]"}],optOut:[{waitForThenClick:"xpath///button[contains(., 'Отклонить необязательные файлы cookie') or contains(., 'Decline optional cookies') or contains(., 'Refuser les cookies optionnels') or contains(., 'Hylkää valinnaiset evästeet') or contains(., 'Afvis valgfrie cookies') or contains(., 'Odmietnuť nepovinné cookies') or contains(., 'Απόρριψη προαιρετικών cookies') or contains(., 'Neka valfria cookies') or contains(., 'Optionale Cookies ablehnen') or contains(., 'Rifiuta cookie facoltativi') or contains(., 'Odbij neobavezne kolačiće') or contains(., 'Avvis valgfrie informasjonskapsler') or contains(., 'İsteğe bağlı çerezleri reddet') or contains(., 'Recusar cookies opcionais') or contains(., 'Optionele cookies afwijzen') or contains(., 'Rechazar cookies opcionales') or contains(., 'Odrzuć opcjonalne pliki cookie') or contains(., 'Отхвърляне на бисквитките по избор') or contains(., 'Odmítnout volitelné soubory cookie') or contains(., 'Refuză modulele cookie opţionale') or contains(., 'A nem kötelező cookie-k elutasítása')]"},{wait:2e3}]},{name:"ionos.de",prehideSelectors:[".privacy-consent--backdrop",".privacy-consent--modal"],detectCmp:[{exists:".privacy-consent--modal"}],detectPopup:[{visible:".privacy-consent--modal"}],optIn:[{click:"#selectAll"}],optOut:[{click:".footer-config-link"},{click:"#confirmSelection"}]},{name:"itopvpn.com",cosmetic:!0,prehideSelectors:[".pop-cookie"],detectCmp:[{exists:".pop-cookie"}],detectPopup:[{exists:".pop-cookie"}],optIn:[{click:"#_pcookie"}],optOut:[{hide:".pop-cookie"}]},{name:"iubenda",prehideSelectors:["#iubenda-cs-banner"],detectCmp:[{exists:"#iubenda-cs-banner"}],detectPopup:[{visible:".iubenda-cs-accept-btn"}],optIn:[{waitForThenClick:".iubenda-cs-accept-btn"}],optOut:[{waitForThenClick:".iubenda-cs-customize-btn"},{eval:"EVAL_IUBENDA_0"},{waitForThenClick:"#iubFooterBtn"}],test:[{eval:"EVAL_IUBENDA_1"}]},{name:"iWink",prehideSelectors:["body.cookies-request #cookie-bar"],detectCmp:[{exists:"body.cookies-request #cookie-bar"}],detectPopup:[{visible:"body.cookies-request #cookie-bar"}],optIn:[{waitForThenClick:"body.cookies-request #cookie-bar .allow-cookies"}],optOut:[{waitForThenClick:"body.cookies-request #cookie-bar .disallow-cookies"}],test:[{eval:"EVAL_IWINK_TEST"}]},{name:"jdsports",vendorUrl:"https://www.jdsports.co.uk/",runContext:{urlPattern:"^https://(www|m)\\.jdsports\\."},prehideSelectors:[".miniConsent,#PrivacyPolicyBanner"],detectCmp:[{exists:".miniConsent,#PrivacyPolicyBanner"}],detectPopup:[{visible:".miniConsent,#PrivacyPolicyBanner"}],optIn:[{waitForThenClick:".miniConsent .accept-all-cookies"}],optOut:[{if:{exists:"#PrivacyPolicyBanner"},then:[{hide:"#PrivacyPolicyBanner"}],else:[{waitForThenClick:"#cookie-settings"},{waitForThenClick:"#reject-all-cookies"}]}]},{name:"johnlewis.com",prehideSelectors:["div[class^=pecr-cookie-banner-]"],detectCmp:[{exists:"div[class^=pecr-cookie-banner-]"}],detectPopup:[{exists:"div[class^=pecr-cookie-banner-]"}],optOut:[{click:"button[data-test^=manage-cookies]"},{wait:"500"},{click:"label[data-test^=toggle][class*=checked]:not([class*=disabled])",all:!0,optional:!0},{click:"button[data-test=save-preferences]"}],optIn:[{click:"button[data-test=allow-all]"}]},{name:"jquery.cookieBar",vendorUrl:"https://github.com/kovarp/jquery.cookieBar",prehideSelectors:[".cookie-bar"],cosmetic:!0,detectCmp:[{exists:".cookie-bar .cookie-bar__message,.cookie-bar .cookie-bar__buttons"}],detectPopup:[{visible:".cookie-bar .cookie-bar__message,.cookie-bar .cookie-bar__buttons",check:"any"}],optIn:[{click:".cookie-bar .cookie-bar__btn"}],optOut:[{hide:".cookie-bar"}],test:[{visible:".cookie-bar .cookie-bar__message,.cookie-bar .cookie-bar__buttons",check:"none"},{eval:"EVAL_JQUERY_COOKIEBAR_0"}]},{name:"justwatch.com",prehideSelectors:[".consent-banner"],detectCmp:[{exists:".consent-banner .consent-banner__actions"}],detectPopup:[{visible:".consent-banner .consent-banner__actions"}],optIn:[{click:".consent-banner__actions button.basic-button.primary"}],optOut:[{click:".consent-banner__actions button.basic-button.secondary"},{waitForThenClick:".consent-modal__footer button.basic-button.secondary"},{waitForThenClick:".consent-modal ion-content > div > a:nth-child(9)"},{click:"label.consent-switch input[type=checkbox]:checked",all:!0,optional:!0},{waitForVisible:".consent-modal__footer button.basic-button.primary"},{click:".consent-modal__footer button.basic-button.primary"}]},{name:"kconsent",cosmetic:!1,runContext:{main:!0,frame:!1},prehideSelectors:[".kc-overlay"],detectCmp:[{exists:"#kconsent"}],detectPopup:[{visible:".kc-dialog"}],optIn:[{waitForThenClick:"#kc-acceptAndHide"}],optOut:[{waitForThenClick:"#kc-denyAndHide"}]},{name:"ketch",vendorUrl:"https://www.ketch.com",runContext:{frame:!1,main:!0},intermediate:!1,prehideSelectors:["#lanyard_root div[role='dialog']"],detectCmp:[{exists:"#lanyard_root div[role='dialog']"}],detectPopup:[{visible:"#lanyard_root div[role='dialog']"}],optIn:[{if:{exists:"#lanyard_root button[class='confirmButton']"},then:[{waitForThenClick:"#lanyard_root div[class*=buttons] > :nth-child(2)"},{click:"#lanyard_root button[class='confirmButton']"}],else:[{waitForThenClick:"#lanyard_root div[class*=buttons] > :nth-child(2)"}]}],optOut:[{if:{exists:"#lanyard_root [aria-describedby=banner-description]"},then:[{waitForThenClick:"#lanyard_root div[class*=buttons] > button[class*=secondaryButton], #lanyard_root button[class*=buttons-secondary]",comment:"can be either settings or reject button"}]},{waitFor:"#lanyard_root [aria-describedby=preference-description],#lanyard_root [aria-describedby=modal-description], #ketch-preferences",timeout:1e3,optional:!0},{if:{exists:"#lanyard_root [aria-describedby=preference-description],#lanyard_root [aria-describedby=modal-description], #ketch-preferences"},then:[{waitForThenClick:"#lanyard_root button[class*=rejectButton], #lanyard_root button[class*=rejectAllButton]"},{click:"#lanyard_root button[class*=confirmButton],#lanyard_root div[class*=actions_] > button:nth-child(1), #lanyard_root button[class*=actionButton]"}]}],test:[{eval:"EVAL_KETCH_TEST"}]},{name:"kleinanzeigen-de",runContext:{urlPattern:"^https?://(www\\.)?kleinanzeigen\\.de"},prehideSelectors:["#gdpr-banner-container"],detectCmp:[{any:[{exists:"#gdpr-banner-container #gdpr-banner [data-testid=gdpr-banner-cmp-button]"},{exists:"#ConsentManagementPage"}]}],detectPopup:[{any:[{visible:"#gdpr-banner-container #gdpr-banner [data-testid=gdpr-banner-cmp-button]"},{visible:"#ConsentManagementPage"}]}],optIn:[{if:{exists:"#gdpr-banner-container #gdpr-banner"},then:[{click:"#gdpr-banner-container #gdpr-banner [data-testid=gdpr-banner-accept]"}],else:[{click:"#ConsentManagementPage .Button-primary"}]}],optOut:[{if:{exists:"#gdpr-banner-container #gdpr-banner"},then:[{click:"#gdpr-banner-container #gdpr-banner [data-testid=gdpr-banner-cmp-button]"}],else:[{click:"#ConsentManagementPage .Button-secondary"}]}]},{name:"lightbox",prehideSelectors:[".darken-layer.open,.lightbox.lightbox--cookie-consent"],detectCmp:[{exists:"body.cookie-consent-is-active div.lightbox--cookie-consent > div.lightbox__content > div.cookie-consent[data-jsb]"}],detectPopup:[{visible:"body.cookie-consent-is-active div.lightbox--cookie-consent > div.lightbox__content > div.cookie-consent[data-jsb]"}],optOut:[{click:".cookie-consent__footer > button[type='submit']:not([data-button='selectAll'])"}],optIn:[{click:".cookie-consent__footer > button[type='submit'][data-button='selectAll']"}]},{name:"lineagrafica",vendorUrl:"https://addons.prestashop.com/en/legal/8734-eu-cookie-law-gdpr-banner-blocker.html",cosmetic:!0,prehideSelectors:["#lgcookieslaw_banner,#lgcookieslaw_modal,.lgcookieslaw-overlay"],detectCmp:[{exists:"#lgcookieslaw_banner,#lgcookieslaw_modal,.lgcookieslaw-overlay"}],detectPopup:[{exists:"#lgcookieslaw_banner,#lgcookieslaw_modal,.lgcookieslaw-overlay"}],optIn:[{waitForThenClick:"#lgcookieslaw_accept"}],optOut:[{hide:"#lgcookieslaw_banner,#lgcookieslaw_modal,.lgcookieslaw-overlay"}]},{name:"linkedin.com",prehideSelectors:[".artdeco-global-alert[type=COOKIE_CONSENT]"],detectCmp:[{exists:".artdeco-global-alert[type=COOKIE_CONSENT]"}],detectPopup:[{visible:".artdeco-global-alert[type=COOKIE_CONSENT]"}],optIn:[{waitForVisible:".artdeco-global-alert[type=COOKIE_CONSENT] button[action-type=ACCEPT]"},{wait:500},{waitForThenClick:".artdeco-global-alert[type=COOKIE_CONSENT] button[action-type=ACCEPT]"}],optOut:[{waitForVisible:".artdeco-global-alert[type=COOKIE_CONSENT] button[action-type=DENY]"},{wait:500},{waitForThenClick:".artdeco-global-alert[type=COOKIE_CONSENT] button[action-type=DENY]"}],test:[{waitForVisible:".artdeco-global-alert[type=COOKIE_CONSENT]",check:"none"}]},{name:"livejasmin",vendorUrl:"https://www.livejasmin.com/",runContext:{urlPattern:"^https://(m|www)\\.livejasmin\\.com/"},prehideSelectors:["#consent_modal"],detectCmp:[{exists:"#consent_modal"}],detectPopup:[{visible:"#consent_modal"}],optIn:[{waitForThenClick:"#consent_modal button[data-testid=ButtonStyledButton]:first-of-type"}],optOut:[{waitForThenClick:"#consent_modal button[data-testid=ButtonStyledButton]:nth-of-type(2)"},{waitForVisible:"[data-testid=PrivacyPreferenceCenterWithConsentCookieContent]"},{click:"[data-testid=PrivacyPreferenceCenterWithConsentCookieContent] input[data-testid=PrivacyPreferenceCenterWithConsentCookieSwitch]:checked",optional:!0,all:!0},{waitForThenClick:"[data-testid=PrivacyPreferenceCenterWithConsentCookieContent] button[data-testid=ButtonStyledButton]:last-child"}]},{name:"macpaw.com",cosmetic:!0,prehideSelectors:['div[data-banner="cookies"]'],detectCmp:[{exists:'div[data-banner="cookies"]'}],detectPopup:[{exists:'div[data-banner="cookies"]'}],optIn:[{click:'button[data-banner-close="cookies"]'}],optOut:[{hide:'div[data-banner="cookies"]'}]},{name:"marksandspencer.com",cosmetic:!0,detectCmp:[{exists:".navigation-cookiebbanner"}],detectPopup:[{visible:".navigation-cookiebbanner"}],optOut:[{hide:".navigation-cookiebbanner"}],optIn:[{click:".navigation-cookiebbanner__submit"}]},{name:"mediamarkt.de",prehideSelectors:["div[aria-labelledby=pwa-consent-layer-title]","div[class^=StyledConsentLayerWrapper-]"],detectCmp:[{exists:"div[aria-labelledby^=pwa-consent-layer-title]"}],detectPopup:[{exists:"div[aria-labelledby^=pwa-consent-layer-title]"}],optOut:[{click:"button[data-test^=pwa-consent-layer-deny-all]"}],optIn:[{click:"button[data-test^=pwa-consent-layer-accept-all"}]},{name:"Mediavine",prehideSelectors:['[data-name="mediavine-gdpr-cmp"]'],detectCmp:[{exists:'[data-name="mediavine-gdpr-cmp"]'}],detectPopup:[{wait:500},{visible:'[data-name="mediavine-gdpr-cmp"]'}],optIn:[{waitForThenClick:'[data-name="mediavine-gdpr-cmp"] [format="primary"]'}],optOut:[{waitForThenClick:'[data-name="mediavine-gdpr-cmp"] [data-view="manageSettings"]'},{waitFor:'[data-name="mediavine-gdpr-cmp"] input[type=checkbox]'},{eval:"EVAL_MEDIAVINE_0",optional:!0},{click:'[data-name="mediavine-gdpr-cmp"] [format="secondary"]'}]},{name:"medium",vendorUrl:"https://medium.com",cosmetic:!0,runContext:{main:!0,frame:!1,urlPattern:"^https://([a-z0-9-]+\\.)?medium\\.com/"},prehideSelectors:[],detectCmp:[{exists:'div:has(> div > div > div[role=alert] > a[href^="https://policy.medium.com/medium-privacy-policy-"])'}],detectPopup:[{visible:'div:has(> div > div > div[role=alert] > a[href^="https://policy.medium.com/medium-privacy-policy-"])'}],optIn:[{waitForThenClick:"[data-testid=close-button]"}],optOut:[{hide:'div:has(> div > div > div[role=alert] > a[href^="https://policy.medium.com/medium-privacy-policy-"])'}]},{name:"microsoft.com",prehideSelectors:["#wcpConsentBannerCtrl"],detectCmp:[{exists:"#wcpConsentBannerCtrl"}],detectPopup:[{exists:"#wcpConsentBannerCtrl"}],optOut:[{eval:"EVAL_MICROSOFT_0"}],optIn:[{eval:"EVAL_MICROSOFT_1"}],test:[{eval:"EVAL_MICROSOFT_2"}]},{name:"midway-usa",runContext:{urlPattern:"^https://www\\.midwayusa\\.com/"},cosmetic:!0,prehideSelectors:["#cookie-container"],detectCmp:[{exists:['div[aria-label="Cookie Policy Banner"]']}],detectPopup:[{visible:"#cookie-container"}],optIn:[{click:"button#cookie-btn"}],optOut:[{hide:'div[aria-label="Cookie Policy Banner"]'}]},{name:"moneysavingexpert.com",detectCmp:[{exists:"dialog[data-testid=accept-our-cookies-dialog]"}],detectPopup:[{visible:"dialog[data-testid=accept-our-cookies-dialog]"}],optIn:[{click:"#banner-accept"}],optOut:[{click:"#banner-manage"},{click:"#pc-confirm"}]},{name:"monzo.com",prehideSelectors:[".cookie-alert, cookie-alert__content"],detectCmp:[{exists:'div.cookie-alert[role="dialog"]'},{exists:'a[href*="monzo"]'}],detectPopup:[{visible:".cookie-alert__content"}],optIn:[{click:".js-accept-cookie-policy"}],optOut:[{click:".js-decline-cookie-policy"}]},{name:"Moove",prehideSelectors:["#moove_gdpr_cookie_info_bar"],detectCmp:[{exists:"#moove_gdpr_cookie_info_bar"}],detectPopup:[{visible:"#moove_gdpr_cookie_info_bar:not(.moove-gdpr-info-bar-hidden)"}],optIn:[{waitForThenClick:".moove-gdpr-infobar-allow-all"}],optOut:[{if:{exists:"#moove_gdpr_cookie_info_bar .change-settings-button"},then:[{click:"#moove_gdpr_cookie_info_bar .change-settings-button"},{waitForVisible:"#moove_gdpr_cookie_modal"},{eval:"EVAL_MOOVE_0"},{click:".moove-gdpr-modal-save-settings"}],else:[{hide:"#moove_gdpr_cookie_info_bar"}]}],test:[{visible:"#moove_gdpr_cookie_info_bar",check:"none"}]},{name:"national-lottery.co.uk",detectCmp:[{exists:".cuk_cookie_consent"}],detectPopup:[{visible:".cuk_cookie_consent",check:"any"}],optOut:[{click:".cuk_cookie_consent_manage_pref"},{click:".cuk_cookie_consent_save_pref"},{click:".cuk_cookie_consent_close"}],optIn:[{click:".cuk_cookie_consent_accept_all"}]},{name:"nba.com",runContext:{urlPattern:"^https://(www\\.)?nba.com/"},cosmetic:!0,prehideSelectors:["#onetrust-banner-sdk"],detectCmp:[{exists:"#onetrust-banner-sdk"}],detectPopup:[{visible:"#onetrust-banner-sdk"}],optIn:[{click:"#onetrust-accept-btn-handler"}],optOut:[{hide:"#onetrust-banner-sdk"}]},{name:"netbeat.de",runContext:{urlPattern:"^https://(www\\.)?netbeat\\.de/"},prehideSelectors:["div#cookieWarning"],detectCmp:[{exists:"div#cookieWarning"}],detectPopup:[{visible:"div#cookieWarning"}],optIn:[{waitForThenClick:"a#btnCookiesAcceptAll"}],optOut:[{waitForThenClick:"a#btnCookiesDenyAll"}]},{name:"netflix.de",detectCmp:[{exists:"#cookie-disclosure"}],detectPopup:[{visible:".cookie-disclosure-message",check:"any"}],optIn:[{click:".btn-accept"}],optOut:[{hide:"#cookie-disclosure"},{click:".btn-reject"}]},{name:"nhs.uk",prehideSelectors:["#nhsuk-cookie-banner"],detectCmp:[{exists:"#nhsuk-cookie-banner"}],detectPopup:[{exists:"#nhsuk-cookie-banner"}],optOut:[{click:"#nhsuk-cookie-banner__link_accept"}],optIn:[{click:"#nhsuk-cookie-banner__link_accept_analytics"}]},{name:"nike",vendorUrl:"https://nike.com",runContext:{urlPattern:"^https://(www\\.)?nike\\.com/"},prehideSelectors:[],detectCmp:[{exists:"[data-testid=cookie-dialog-root]"}],detectPopup:[{visible:"[data-testid=cookie-dialog-root]"}],optIn:[{waitForThenClick:"[data-testid=dialog-accept-button]"}],optOut:[{waitForThenClick:"input[type=radio][id$=-declineLabel]",all:!0},{waitForThenClick:"[data-testid=confirm-choice-button]"}]},{name:"notice-cookie",prehideSelectors:[".button--notice"],cosmetic:!0,detectCmp:[{exists:".notice--cookie"}],detectPopup:[{visible:".notice--cookie"}],optIn:[{click:".button--notice"}],optOut:[{hide:".notice--cookie"}]},{name:"nrk.no",cosmetic:!0,prehideSelectors:[".nrk-masthead__info-banner--cookie"],detectCmp:[{exists:".nrk-masthead__info-banner--cookie"}],detectPopup:[{exists:".nrk-masthead__info-banner--cookie"}],optIn:[{click:"div.nrk-masthead__info-banner--cookie button > span:has(+ svg.nrk-close)"}],optOut:[{hide:".nrk-masthead__info-banner--cookie"}]},{name:"obi.de",prehideSelectors:[".disc-cp--active"],detectCmp:[{exists:".disc-cp-modal__modal"}],detectPopup:[{visible:".disc-cp-modal__modal"}],optIn:[{click:".js-disc-cp-accept-all"}],optOut:[{click:".js-disc-cp-deny-all"}]},{name:"om",vendorUrl:"https://olli-machts.de/en/extension/cookie-manager",prehideSelectors:[".tx-om-cookie-consent"],detectCmp:[{exists:".tx-om-cookie-consent .active[data-omcookie-panel]"}],detectPopup:[{exists:".tx-om-cookie-consent .active[data-omcookie-panel]"}],optIn:[{waitForThenClick:"[data-omcookie-panel-save=all]"}],optOut:[{if:{exists:"[data-omcookie-panel-save=min]"},then:[{waitForThenClick:"[data-omcookie-panel-save=min]"}],else:[{click:"input[data-omcookie-panel-grp]:checked:not(:disabled)",all:!0,optional:!0},{waitForThenClick:"[data-omcookie-panel-save=save]"}]}]},{name:"onlyFans.com",runContext:{urlPattern:"^https://onlyfans\\.com/"},prehideSelectors:["div.b-cookies-informer"],detectCmp:[{exists:"div.b-cookies-informer"}],detectPopup:[{exists:"div.b-cookies-informer"}],optIn:[{click:"div.b-cookies-informer__nav > button:nth-child(2)"}],optOut:[{click:"div.b-cookies-informer__nav > button:nth-child(1)"},{if:{exists:"div.b-cookies-informer__switchers"},then:[{click:"div.b-cookies-informer__switchers input:not([disabled])",all:!0},{click:"div.b-cookies-informer__nav > button"}]}]},{name:"openai",vendorUrl:"https://platform.openai.com/",cosmetic:!1,runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?openai\\.com/"},prehideSelectors:["[data-testid=cookie-consent-banner]"],detectCmp:[{exists:"[data-testid=cookie-consent-banner]"}],detectPopup:[{visible:"[data-testid=cookie-consent-banner]"}],optIn:[{waitForThenClick:"xpath///button[contains(., 'Accept all')]"}],optOut:[{waitForThenClick:"xpath///button[contains(., 'Reject all')]"}],test:[{wait:500},{eval:"EVAL_OPENAI_TEST"}]},{name:"openli",vendorUrl:"https://openli.com",prehideSelectors:[".legalmonster-cleanslate"],detectCmp:[{exists:".legalmonster-cleanslate"}],detectPopup:[{visible:".legalmonster-cleanslate #lm-cookie-wall-container",check:"any"}],optIn:[{waitForThenClick:"#lm-accept-all"}],optOut:[{waitForThenClick:"#lm-accept-necessary"}]},{name:"opera.com",vendorUrl:"https://unknown",cosmetic:!1,runContext:{main:!0,frame:!1},intermediate:!1,prehideSelectors:[],detectCmp:[{exists:"#cookie-consent .manage-cookies__btn"}],detectPopup:[{visible:"#cookie-consent .cookie-basic-consent__btn"}],optIn:[{waitForThenClick:"#cookie-consent .cookie-basic-consent__btn"}],optOut:[{waitForThenClick:"#cookie-consent .manage-cookies__btn"},{waitForThenClick:"#cookie-consent .active.marketing_option_switch.cookie-consent__switch",all:!0},{waitForThenClick:"#cookie-consent .cookie-selection__btn"}],test:[{eval:"EVAL_OPERA_0"}]},{name:"osano",prehideSelectors:[".osano-cm-window,.osano-cm-dialog"],detectCmp:[{exists:".osano-cm-window"}],detectPopup:[{visible:".osano-cm-dialog"}],optIn:[{click:".osano-cm-accept-all",optional:!0}],optOut:[{waitForThenClick:".osano-cm-denyAll"}]},{name:"otto.de",prehideSelectors:[".cookieBanner--visibility"],detectCmp:[{exists:".cookieBanner--visibility"}],detectPopup:[{visible:".cookieBanner__wrapper"}],optIn:[{click:".js_cookieBannerPermissionButton"}],optOut:[{click:".js_cookieBannerProhibitionButton"}]},{name:"ourworldindata",vendorUrl:"https://ourworldindata.org/",runContext:{urlPattern:"^https://ourworldindata\\.org/"},prehideSelectors:[".cookie-manager"],detectCmp:[{exists:".cookie-manager"}],detectPopup:[{visible:".cookie-manager .cookie-notice.open"}],optIn:[{waitForThenClick:".cookie-notice [data-test=accept]"}],optOut:[{waitForThenClick:".cookie-notice [data-test=reject]"}]},{name:"pabcogypsum",vendorUrl:"https://unknown",prehideSelectors:[".js-cookie-notice:has(#cookie_settings-form)"],detectCmp:[{exists:".js-cookie-notice #cookie_settings-form"}],detectPopup:[{visible:".js-cookie-notice #cookie_settings-form"}],optIn:[{waitForThenClick:".js-cookie-notice button[value=allow]"}],optOut:[{waitForThenClick:".js-cookie-notice button[value=disable]"}]},{name:"paypal-us",prehideSelectors:["#ccpaCookieContent_wrapper, article.ppvx_modal--overpanel"],detectCmp:[{exists:"#ccpaCookieBanner, .privacy-sheet-content"}],detectPopup:[{visible:"#ccpaCookieBanner, .privacy-sheet-content"}],optIn:[{click:"#acceptAllButton"}],optOut:[{if:{exists:"#bannerDeclineButton"},then:[{click:"#bannerDeclineButton"}],else:[{if:{exists:"a#manageCookiesLink"},then:[{click:"a#manageCookiesLink"}],else:[{waitForVisible:".privacy-sheet-content #formContent"},{click:"#formContent .cookiepref-11m2iee-checkbox_base input:checked",all:!0,optional:!0},{click:".cookieAction.saveCookie,.confirmCookie #submitCookiesBtn"}]}]}]},{name:"paypal.com",prehideSelectors:["#gdprCookieBanner"],detectCmp:[{exists:"#gdprCookieBanner"}],detectPopup:[{visible:"#gdprCookieContent_wrapper"}],optIn:[{click:"#acceptAllButton"}],optOut:[{wait:200},{click:".gdprCookieBanner_decline-button"}],test:[{wait:500},{eval:"EVAL_PAYPAL_0"}]},{name:"pinetools.com",cosmetic:!0,prehideSelectors:["#aviso_cookies"],detectCmp:[{exists:"#aviso_cookies"}],detectPopup:[{exists:".lang_en #aviso_cookies"}],optIn:[{click:"#aviso_cookies .a_boton_cerrar"}],optOut:[{hide:"#aviso_cookies"}]},{name:"pinterest-business",vendorUrl:"https://business.pinterest.com/",runContext:{urlPattern:"^https://.*\\.pinterest\\.com/"},prehideSelectors:[".BusinessCookieConsent"],detectCmp:[{exists:".BusinessCookieConsent"}],detectPopup:[{visible:".BusinessCookieConsent [data-id=cookie-consent-banner-buttons]"}],optIn:[{waitForThenClick:"[data-id=cookie-consent-banner-buttons] > div:nth-child(1) button"}],optOut:[{waitForThenClick:"[data-id=cookie-consent-banner-buttons] > div:nth-child(2) button"}]},{name:"pmc",cosmetic:!0,prehideSelectors:["#pmc-pp-tou--notice"],detectCmp:[{exists:"#pmc-pp-tou--notice"}],detectPopup:[{visible:"#pmc-pp-tou--notice"}],optIn:[{click:"span.pmc-pp-tou--notice-close-btn"}],optOut:[{hide:"#pmc-pp-tou--notice"}]},{name:"pornhub.com",runContext:{urlPattern:"^https://(www\\.)?pornhub\\.com/"},cosmetic:!1,prehideSelectors:["#cookieBanner #cookieBannerContent"],detectCmp:[{exists:"#cookieBanner #cookieBannerContent"}],detectPopup:[{visible:"#cookieBanner #cookieBannerContent"}],optIn:[{waitForThenClick:"#cookieBanner [data-label=accept_all]"}],optOut:[{waitForThenClick:"#cookieBanner [data-label=accept_essential]"}]},{name:"pornpics.com",cosmetic:!0,prehideSelectors:["#cookie-contract"],detectCmp:[{exists:"#cookie-contract"}],detectPopup:[{visible:"#cookie-contract"}],optIn:[{click:"#cookie-contract .icon-cross"}],optOut:[{hide:"#cookie-contract"}]},{name:"PrimeBox CookieBar",prehideSelectors:["#cookie-bar"],detectCmp:[{exists:"#cookie-bar .cb-enable,#cookie-bar .cb-disable,#cookie-bar .cb-policy"}],detectPopup:[{visible:"#cookie-bar .cb-enable,#cookie-bar .cb-disable,#cookie-bar .cb-policy",check:"any"}],optIn:[{waitForThenClick:"#cookie-bar .cb-enable"}],optOut:[{click:"#cookie-bar .cb-disable",optional:!0},{hide:"#cookie-bar"}],test:[{eval:"EVAL_PRIMEBOX_0"}]},{name:"privacymanager.io",prehideSelectors:["#gdpr-consent-tool-wrapper",'iframe[src^="https://cmp-consent-tool.privacymanager.io"]'],runContext:{urlPattern:"^https://cmp-consent-tool\\.privacymanager\\.io/",main:!1,frame:!0},detectCmp:[{exists:"button#save"}],detectPopup:[{visible:"button#save"}],optIn:[{click:"button#save"}],optOut:[{if:{exists:"#denyAll"},then:[{click:"#denyAll"},{waitForThenClick:".okButton"}],else:[{waitForThenClick:"#manageSettings"},{waitFor:".purposes-overview-list"},{waitFor:"button#saveAndExit"},{click:"span[role=checkbox][aria-checked=true]",all:!0,optional:!0},{click:"button#saveAndExit"}]}]},{name:"productz.com",vendorUrl:"https://productz.com/",runContext:{urlPattern:"^https://productz\\.com/"},prehideSelectors:[],detectCmp:[{exists:".c-modal.is-active"}],detectPopup:[{visible:".c-modal.is-active"}],optIn:[{waitForThenClick:".c-modal.is-active .is-accept"}],optOut:[{waitForThenClick:".c-modal.is-active .is-dismiss"}]},{name:"pubtech",prehideSelectors:["#pubtech-cmp"],detectCmp:[{exists:"#pubtech-cmp"}],detectPopup:[{visible:"#pubtech-cmp #pt-actions"}],optIn:[{if:{exists:"#pt-accept-all"},then:[{click:"#pubtech-cmp #pt-actions #pt-accept-all"}],else:[{click:"#pubtech-cmp #pt-actions button:nth-of-type(2)"}]}],optOut:[{click:"#pubtech-cmp #pt-close"}],test:[{eval:"EVAL_PUBTECH_0"}]},{name:"quantcast",prehideSelectors:["#qc-cmp2-main,#qc-cmp2-container"],detectCmp:[{exists:"#qc-cmp2-container"}],detectPopup:[{visible:"#qc-cmp2-ui"}],optOut:[{waitFor:'.qc-cmp2-summary-buttons > button[mode="secondary"]',timeout:2e3},{if:{exists:'.qc-cmp2-summary-buttons > button[mode="secondary"]:nth-of-type(2)'},then:[{click:'.qc-cmp2-summary-buttons > button[mode="secondary"]:nth-of-type(2)'}],else:[{click:'.qc-cmp2-summary-buttons > button[mode="secondary"]:nth-of-type(1)'},{waitFor:"#qc-cmp2-ui"},{click:'.qc-cmp2-toggle-switch > button[aria-checked="true"]',all:!0,optional:!0},{click:'.qc-cmp2-main button[aria-label="REJECT ALL"]',optional:!0},{waitForThenClick:'.qc-cmp2-main button[aria-label="SAVE & EXIT"],.qc-cmp2-buttons-desktop > button[mode="primary"]',timeout:5e3}]}],optIn:[{click:'.qc-cmp2-summary-buttons > button[mode="primary"]'}]},{name:"reddit.com",runContext:{urlPattern:"^https://www\\.reddit\\.com/"},prehideSelectors:["[bundlename=reddit_cookie_banner]"],detectCmp:[{exists:"reddit-cookie-banner"}],detectPopup:[{visible:"reddit-cookie-banner"}],optIn:[{waitForThenClick:["reddit-cookie-banner","#accept-all-cookies-button > button"]}],optOut:[{waitForThenClick:["reddit-cookie-banner","#reject-nonessential-cookies-button > button"]}],test:[{eval:"EVAL_REDDIT_0"}]},{name:"roblox",vendorUrl:"https://roblox.com",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?roblox\\.com/"},prehideSelectors:[],detectCmp:[{exists:".cookie-banner-wrapper"}],detectPopup:[{visible:".cookie-banner-wrapper .cookie-banner"}],optIn:[{waitForThenClick:".cookie-banner-wrapper button.btn-cta-lg"}],optOut:[{waitForThenClick:".cookie-banner-wrapper button.btn-secondary-lg"}],test:[{eval:"EVAL_ROBLOX_TEST"}]},{name:"rog-forum.asus.com",runContext:{urlPattern:"^https://rog-forum\\.asus\\.com/"},prehideSelectors:["#cookie-policy-info"],detectCmp:[{exists:"#cookie-policy-info"}],detectPopup:[{visible:"#cookie-policy-info"}],optIn:[{click:'div.cookie-btn-box > div[aria-label="Accept"]'}],optOut:[{click:'div.cookie-btn-box > div[aria-label="Reject"]'},{waitForThenClick:'.cookie-policy-lightbox-bottom > div[aria-label="Save Settings"]'}]},{name:"roofingmegastore.co.uk",runContext:{urlPattern:"^https://(www\\.)?roofingmegastore\\.co\\.uk"},prehideSelectors:["#m-cookienotice"],detectCmp:[{exists:"#m-cookienotice"}],detectPopup:[{visible:"#m-cookienotice"}],optIn:[{click:"#accept-cookies"}],optOut:[{click:"#manage-cookies"},{waitForThenClick:"#accept-selected"}]},{name:"samsung.com",runContext:{urlPattern:"^https://www\\.samsung\\.com/"},cosmetic:!0,prehideSelectors:["div.cookie-bar"],detectCmp:[{exists:"div.cookie-bar"}],detectPopup:[{visible:"div.cookie-bar"}],optIn:[{click:"div.cookie-bar__manage > a"}],optOut:[{hide:"div.cookie-bar"}]},{name:"setapp.com",vendorUrl:"https://setapp.com/",cosmetic:!0,runContext:{urlPattern:"^https://setapp\\.com/"},prehideSelectors:[],detectCmp:[{exists:".cookie-banner.js-cookie-banner"}],detectPopup:[{visible:".cookie-banner.js-cookie-banner"}],optIn:[{waitForThenClick:".cookie-banner.js-cookie-banner button"}],optOut:[{hide:".cookie-banner.js-cookie-banner"}]},{name:"sibbo",prehideSelectors:["sibbo-cmp-layout"],detectCmp:[{exists:"sibbo-cmp-layout"}],detectPopup:[{visible:"#rejectAllMain"}],optIn:[{click:"#acceptAllMain"}],optOut:[{click:"#rejectAllMain"}]},{name:"similarweb.com",cosmetic:!0,prehideSelectors:[".app-cookies-notification"],detectCmp:[{exists:".app-cookies-notification"}],detectPopup:[{exists:".app-layout .app-cookies-notification"}],optIn:[{click:"button.app-cookies-notification__dismiss"}],optOut:[{hide:".app-layout .app-cookies-notification"}]},{name:"Sirdata",cosmetic:!1,prehideSelectors:["#sd-cmp"],detectCmp:[{exists:"#sd-cmp"}],detectPopup:[{visible:"#sd-cmp"}],optIn:[{waitForThenClick:"#sd-cmp .sd-cmp-3cRQ2"}],optOut:[{waitForThenClick:["#sd-cmp","xpath///span[contains(., 'Do not accept') or contains(., 'Acceptera inte') or contains(., 'No aceptar') or contains(., 'Ikke acceptere') or contains(., 'Nicht akzeptieren') or contains(., 'Не приемам') or contains(., 'Να μην γίνει αποδοχή') or contains(., 'Niet accepteren') or contains(., 'Nepřijímat') or contains(., 'Nie akceptuj') or contains(., 'Nu acceptați') or contains(., 'Não aceitar') or contains(., 'Continuer sans accepter') or contains(., 'Non accettare') or contains(., 'Nem fogad el')]"]}]},{name:"skyscanner",vendorUrl:"https://skyscanner.com",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?skyscanner[\\.a-z]+/"},prehideSelectors:[".cookie-banner-wrapper"],detectCmp:[{exists:"#cookieBannerContent"}],detectPopup:[{visible:"#cookieBannerContent"}],optIn:[{waitForThenClick:"[data-tracking-element-id=cookie_banner_accept_all]"}],optOut:[{waitForThenClick:"[data-tracking-element-id=cookie_banner_essential_only]"},{waitForVisible:"#cookieBannerContent",check:"none"}],test:[{eval:"EVAL_SKYSCANNER_TEST"}]},{name:"snigel",detectCmp:[{exists:".snigel-cmp-framework"}],detectPopup:[{visible:".snigel-cmp-framework"}],optOut:[{click:"#sn-b-custom"},{click:"#sn-b-save"}],test:[{eval:"EVAL_SNIGEL_0"}],optIn:[{click:".snigel-cmp-framework #accept-choices"}]},{name:"steampowered.com",detectCmp:[{exists:".cookiepreferences_popup"},{visible:".cookiepreferences_popup"}],detectPopup:[{visible:".cookiepreferences_popup"}],optOut:[{click:"#rejectAllButton"}],optIn:[{click:"#acceptAllButton"}],test:[{wait:1e3},{eval:"EVAL_STEAMPOWERED_0"}]},{name:"strato.de",prehideSelectors:[".consent__wrapper"],runContext:{urlPattern:"^https://www\\.strato\\.de/"},detectCmp:[{exists:".consent"}],detectPopup:[{visible:".consent"}],optIn:[{click:"button.consentAgree"}],optOut:[{click:"button.consentSettings"},{waitForThenClick:"button#consentSubmit"}]},{name:"svt.se",vendorUrl:"https://www.svt.se/",runContext:{urlPattern:"^https://www\\.svt\\.se/"},prehideSelectors:["[class*=CookieConsent__root___]"],detectCmp:[{exists:"[class*=CookieConsent__root___]"}],detectPopup:[{visible:"[class*=CookieConsent__modal___]"}],optIn:[{waitForThenClick:"[class*=CookieConsent__modal___] > div > button[class*=primary]"}],optOut:[{waitForThenClick:"[class*=CookieConsent__modal___] > div > button[class*=secondary]:nth-child(2)"}],test:[{eval:"EVAL_SVT_TEST"}]},{name:"takealot.com",cosmetic:!0,prehideSelectors:['div[class^="cookies-banner-module_"]'],detectCmp:[{exists:'div[class^="cookies-banner-module_cookie-banner_"]'}],detectPopup:[{exists:'div[class^="cookies-banner-module_cookie-banner_"]'}],optIn:[{click:'button[class*="cookies-banner-module_dismiss-button_"]'}],optOut:[{hide:'div[class^="cookies-banner-module_"]'},{if:{exists:'div[class^="cookies-banner-module_small-cookie-banner_"]'},then:[{eval:"EVAL_TAKEALOT_0"}],else:[]}]},{name:"tarteaucitron.js",prehideSelectors:["#tarteaucitronRoot"],detectCmp:[{exists:"#tarteaucitronRoot"}],detectPopup:[{visible:"#tarteaucitronRoot #tarteaucitronAlertBig",check:"any"}],optIn:[{eval:"EVAL_TARTEAUCITRON_1"}],optOut:[{eval:"EVAL_TARTEAUCITRON_0"}],test:[{eval:"EVAL_TARTEAUCITRON_2",comment:"sometimes there are required categories, so we check that at least something is false"}]},{name:"taunton",vendorUrl:"https://www.taunton.com/",prehideSelectors:["#taunton-user-consent__overlay"],detectCmp:[{exists:"#taunton-user-consent__overlay"}],detectPopup:[{exists:"#taunton-user-consent__overlay:not([aria-hidden=true])"}],optIn:[{click:"#taunton-user-consent__toolbar input[type=checkbox]:not(:checked)"},{click:"#taunton-user-consent__toolbar button[type=submit]"}],optOut:[{click:"#taunton-user-consent__toolbar input[type=checkbox]:checked",optional:!0,all:!0},{click:"#taunton-user-consent__toolbar button[type=submit]"}],test:[{eval:"EVAL_TAUNTON_TEST"}]},{name:"Tealium",prehideSelectors:["#__tealiumGDPRecModal,#__tealiumGDPRcpPrefs,#__tealiumImplicitmodal,#consent-layer"],detectCmp:[{exists:"#__tealiumGDPRecModal *,#__tealiumGDPRcpPrefs *,#__tealiumImplicitmodal *"},{eval:"EVAL_TEALIUM_0"}],detectPopup:[{visible:"#__tealiumGDPRecModal *,#__tealiumGDPRcpPrefs *,#__tealiumImplicitmodal *",check:"any"}],optOut:[{eval:"EVAL_TEALIUM_1"},{eval:"EVAL_TEALIUM_DONOTSELL"},{hide:"#__tealiumGDPRecModal,#__tealiumGDPRcpPrefs,#__tealiumImplicitmodal"},{waitForThenClick:"#cm-acceptNone,.js-accept-essential-cookies,#continueWithoutAccepting",timeout:1e3,optional:!0}],optIn:[{hide:"#__tealiumGDPRecModal,#__tealiumGDPRcpPrefs"},{eval:"EVAL_TEALIUM_2"}],test:[{eval:"EVAL_TEALIUM_3"},{eval:"EVAL_TEALIUM_DONOTSELL_CHECK"},{visible:"#__tealiumGDPRecModal,#__tealiumGDPRcpPrefs",check:"none"}]},{name:"temu",vendorUrl:"https://temu.com",runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?temu\\.com/"},prehideSelectors:[],detectCmp:[{exists:'div > div > div > div > span[href*="/cookie-and-similar-technologies-policy.html"]'}],detectPopup:[{visible:'div > div > div > div > span[href*="/cookie-and-similar-technologies-policy.html"]'}],optIn:[{waitForThenClick:'div > div > div:has(> div > span[href*="/cookie-and-similar-technologies-policy.html"]) > [role=button]:nth-child(3)'}],optOut:[{if:{exists:"xpath///span[contains(., 'Alle afwijzen') or contains(., 'Reject all') or contains(., 'Tümünü reddet') or contains(., 'Odrzuć wszystko')]"},then:[{waitForThenClick:"xpath///span[contains(., 'Alle afwijzen') or contains(., 'Reject all') or contains(., 'Tümünü reddet') or contains(., 'Odrzuć wszystko')]"}],else:[{waitForThenClick:'div > div > div:has(> div > span[href*="/cookie-and-similar-technologies-policy.html"]) > [role=button]:nth-child(2)'}]}]},{name:"Termly",prehideSelectors:["#termly-code-snippet-support"],detectCmp:[{exists:"#termly-code-snippet-support"}],detectPopup:[{visible:"#termly-code-snippet-support div"}],optIn:[{waitForThenClick:'[data-tid="banner-accept"]'}],optOut:[{if:{exists:'[data-tid="banner-decline"]'},then:[{click:'[data-tid="banner-decline"]'}],else:[{click:".t-preference-button"},{wait:500},{if:{exists:".t-declineAllButton"},then:[{click:".t-declineAllButton"}],else:[{waitForThenClick:".t-preference-modal input[type=checkbox][checked]:not([disabled])",all:!0},{waitForThenClick:".t-saveButton"}]}]}]},{name:"termsfeed",vendorUrl:"https://termsfeed.com",comment:"v4.x.x",prehideSelectors:[".termsfeed-com---nb"],detectCmp:[{exists:".termsfeed-com---nb"}],detectPopup:[{visible:".termsfeed-com---nb"}],optIn:[{waitForThenClick:".cc-nb-okagree"}],optOut:[{waitForThenClick:".cc-nb-reject"}]},{name:"termsfeed3",vendorUrl:"https://termsfeed.com",comment:"v3.x.x",prehideSelectors:[".cc_dialog.cc_css_reboot,.cc_overlay_lock"],detectCmp:[{exists:".cc_dialog.cc_css_reboot"}],detectPopup:[{visible:".cc_dialog.cc_css_reboot"}],optIn:[{waitForThenClick:".cc_dialog.cc_css_reboot .cc_b_ok"}],optOut:[{if:{exists:".cc_dialog.cc_css_reboot .cc_b_cp"},then:[{click:".cc_dialog.cc_css_reboot .cc_b_cp"},{waitForVisible:".cookie-consent-preferences-dialog .cc_cp_f_save button"},{waitForThenClick:".cookie-consent-preferences-dialog .cc_cp_f_save button"}],else:[{hide:".cc_dialog.cc_css_reboot,.cc_overlay_lock"}]}]},{name:"tesco",vendorUrl:"https://www.tesco.com",cosmetic:!1,runContext:{urlPattern:"^https://(www\\.)?tesco\\.com/"},prehideSelectors:["[class*=CookieBanner__Sizer]"],detectCmp:[{exists:"[aria-label=consent-banner]"}],detectPopup:[{visible:"[aria-label=consent-banner]"}],optIn:[{wait:1e3},{waitForThenClick:"xpath///button[contains(., 'Accept all')]"}],optOut:[{wait:1e3},{waitForThenClick:"xpath///button[contains(., 'Reject all')]"}]},{name:"tesla",vendorUrl:"https://tesla.com/",runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?tesla\\.com/"},prehideSelectors:[],detectCmp:[{exists:"#cookie_banner"}],detectPopup:[{visible:"#cookie_banner"}],optIn:[{waitForThenClick:"#tsla-accept-cookie"}],optOut:[{waitForThenClick:"#tsla-reject-cookie"}],test:[{eval:"EVAL_TESLA_TEST"}]},{name:"Test page cosmetic CMP",cosmetic:!0,prehideSelectors:["#privacy-test-page-cmp-test-prehide"],detectCmp:[{exists:"#privacy-test-page-cmp-test-banner"}],detectPopup:[{visible:"#privacy-test-page-cmp-test-banner"}],optIn:[{waitFor:"#accept-all"},{click:"#accept-all"}],optOut:[{hide:"#privacy-test-page-cmp-test-banner"}],test:[{wait:500},{eval:"EVAL_TESTCMP_COSMETIC_0"}]},{name:"Test page CMP",prehideSelectors:["#reject-all"],detectCmp:[{exists:"#privacy-test-page-cmp-test"}],detectPopup:[{visible:"#privacy-test-page-cmp-test"}],optIn:[{waitFor:"#accept-all"},{click:"#accept-all"}],optOut:[{waitFor:"#reject-all"},{eval:"EVAL_TESTCMP_STEP"},{click:"#reject-all"}],test:[{eval:"EVAL_TESTCMP_0"}]},{name:"thalia.de",prehideSelectors:[".consent-banner-box"],detectCmp:[{exists:"consent-banner[component=consent-banner]"}],detectPopup:[{visible:".consent-banner-box"}],optIn:[{click:".button-zustimmen"}],optOut:[{click:"button[data-consent=disagree]"}]},{name:"thefreedictionary.com",prehideSelectors:["#cmpBanner"],detectCmp:[{exists:"#cmpBanner"}],detectPopup:[{visible:"#cmpBanner"}],optIn:[{eval:"EVAL_THEFREEDICTIONARY_1"}],optOut:[{eval:"EVAL_THEFREEDICTIONARY_0"}]},{name:"theverge",runContext:{frame:!1,main:!0,urlPattern:"^https://(www)?\\.theverge\\.com"},intermediate:!1,prehideSelectors:[".duet--cta--cookie-banner"],detectCmp:[{exists:".duet--cta--cookie-banner"}],detectPopup:[{visible:".duet--cta--cookie-banner"}],optIn:[{click:".duet--cta--cookie-banner button.tracking-12",all:!1}],optOut:[{click:".duet--cta--cookie-banner button.tracking-12 > span"}],test:[{eval:"EVAL_THEVERGE_0"}]},{name:"tidbits-com",cosmetic:!0,prehideSelectors:["#eu_cookie_law_widget-2"],detectCmp:[{exists:"#eu_cookie_law_widget-2"}],detectPopup:[{visible:"#eu_cookie_law_widget-2"}],optIn:[{click:"#eu-cookie-law form > input.accept"}],optOut:[{hide:"#eu_cookie_law_widget-2"}]},{name:"tractor-supply",runContext:{urlPattern:"^https://www\\.tractorsupply\\.com/"},cosmetic:!0,prehideSelectors:[".tsc-cookie-banner"],detectCmp:[{exists:".tsc-cookie-banner"}],detectPopup:[{visible:".tsc-cookie-banner"}],optIn:[{click:"#cookie-banner-cancel"}],optOut:[{hide:".tsc-cookie-banner"}]},{name:"trader-joes-com",cosmetic:!0,prehideSelectors:['div.aem-page > div[class^="CookiesAlert_cookiesAlert__"]'],detectCmp:[{exists:'div.aem-page > div[class^="CookiesAlert_cookiesAlert__"]'}],detectPopup:[{visible:'div.aem-page > div[class^="CookiesAlert_cookiesAlert__"]'}],optIn:[{click:'div[class^="CookiesAlert_cookiesAlert__container__"] button'}],optOut:[{hide:'div.aem-page > div[class^="CookiesAlert_cookiesAlert__"]'}]},{name:"transcend",vendorUrl:"https://unknown",cosmetic:!0,prehideSelectors:["#transcend-consent-manager"],detectCmp:[{exists:"#transcend-consent-manager"}],detectPopup:[{visible:"#transcend-consent-manager"}],optIn:[{waitForThenClick:["#transcend-consent-manager","#consentManagerMainDialog .inner-container button"]}],optOut:[{hide:"#transcend-consent-manager"}]},{name:"transip-nl",runContext:{urlPattern:"^https://www\\.transip\\.nl/"},prehideSelectors:["#consent-modal"],detectCmp:[{any:[{exists:"#consent-modal"},{exists:"#privacy-settings-content"}]}],detectPopup:[{any:[{visible:"#consent-modal"},{visible:"#privacy-settings-content"}]}],optIn:[{click:'button[type="submit"]'}],optOut:[{if:{exists:"#privacy-settings-content"},then:[{click:'button[type="submit"]'}],else:[{click:"div.one-modal__action-footer-column--secondary > a"}]}]},{name:"tropicfeel-com",prehideSelectors:["#shopify-section-cookies-controller"],detectCmp:[{exists:"#shopify-section-cookies-controller"}],detectPopup:[{visible:"#shopify-section-cookies-controller #cookies-controller-main-pane",check:"any"}],optIn:[{waitForThenClick:"#cookies-controller-main-pane form[data-form-allow-all] button"}],optOut:[{click:"#cookies-controller-main-pane a[data-tab-target=manage-cookies]"},{waitFor:"#manage-cookies-pane.active"},{click:"#manage-cookies-pane.active input[type=checkbox][checked]:not([disabled])",all:!0},{click:"#manage-cookies-pane.active button[type=submit]"}],test:[]},{name:"true-car",runContext:{urlPattern:"^https://www\\.truecar\\.com/"},cosmetic:!0,prehideSelectors:[['div[aria-labelledby="cookie-banner-heading"]']],detectCmp:[{exists:'div[aria-labelledby="cookie-banner-heading"]'}],detectPopup:[{visible:'div[aria-labelledby="cookie-banner-heading"]'}],optIn:[{click:'div[aria-labelledby="cookie-banner-heading"] > button[aria-label="Close"]'}],optOut:[{hide:'div[aria-labelledby="cookie-banner-heading"]'}]},{name:"truyo",prehideSelectors:["#truyo-consent-module"],detectCmp:[{exists:"#truyo-cookieBarContent"}],detectPopup:[{visible:"#truyo-consent-module"}],optIn:[{click:"button#acceptAllCookieButton"}],optOut:[{click:"button#declineAllCookieButton"}]},{name:"twcc",vendorUrl:"https://unknown",cosmetic:!1,runContext:{main:!0,frame:!1,urlPattern:""},prehideSelectors:["#twcc__mechanism"],detectCmp:[{exists:"#twcc__mechanism .twcc__notice"}],detectPopup:[{visible:"#twcc__mechanism .twcc__notice"}],optIn:[{waitForThenClick:"#twcc__accept-button"}],optOut:[{waitForThenClick:"#twcc__decline-button"}],test:[{eval:"EVAL_TWCC_TEST"}]},{name:"twitch-mobile",vendorUrl:"https://m.twitch.tv/",cosmetic:!0,runContext:{urlPattern:"^https?://m\\.twitch\\.tv"},prehideSelectors:[],detectCmp:[{exists:'.ReactModal__Overlay [href="https://www.twitch.tv/p/cookie-policy"]'}],detectPopup:[{visible:'.ReactModal__Overlay [href="https://www.twitch.tv/p/cookie-policy"]'}],optIn:[{waitForThenClick:'.ReactModal__Overlay:has([href="https://www.twitch.tv/p/cookie-policy"]) button'}],optOut:[{hide:'.ReactModal__Overlay:has([href="https://www.twitch.tv/p/cookie-policy"])'}]},{name:"twitch.tv",runContext:{urlPattern:"^https?://(www\\.)?twitch\\.tv"},prehideSelectors:["div:has(> .consent-banner .consent-banner__content--gdpr-v2),.ReactModalPortal:has([data-a-target=consent-modal-save])"],detectCmp:[{exists:".consent-banner .consent-banner__content--gdpr-v2"}],detectPopup:[{visible:".consent-banner .consent-banner__content--gdpr-v2"}],optIn:[{click:'button[data-a-target="consent-banner-accept"]'}],optOut:[{hide:"div:has(> .consent-banner .consent-banner__content--gdpr-v2)"},{click:'button[data-a-target="consent-banner-manage-preferences"]'},{waitFor:"input[type=checkbox][data-a-target=tw-checkbox]"},{click:"input[type=checkbox][data-a-target=tw-checkbox][checked]:not([disabled])",all:!0,optional:!0},{waitForThenClick:"[data-a-target=consent-modal-save]"},{waitForVisible:".ReactModalPortal:has([data-a-target=consent-modal-save])",check:"none"}]},{name:"twitter",runContext:{urlPattern:"^https://([a-z0-9-]+\\.)?(twitter|x)\\.com/"},prehideSelectors:['[data-testid="BottomBar"]'],detectCmp:[{exists:'[data-testid="BottomBar"] div'}],detectPopup:[{visible:'[data-testid="BottomBar"] div'}],optIn:[{waitForThenClick:'[data-testid="BottomBar"] > div:has(>div:first-child>div:last-child>button[role=button]>span) > div:last-child > button[role=button]:first-child'}],optOut:[{waitForThenClick:'[data-testid="BottomBar"] > div:has(>div:first-child>div:last-child>button[role=button]>span) > div:last-child > button[role=button]:last-child'}],TODOtest:[{eval:"EVAL_document.cookie.includes('d_prefs=MjoxLGNvbnNlbnRfdmVyc2lvbjoy')"}]},{name:"ubuntu.com",prehideSelectors:["dialog.cookie-policy"],detectCmp:[{any:[{exists:"dialog.cookie-policy header"},{exists:'xpath///*[@id="modal"]/div/header'}]}],detectPopup:[{any:[{visible:"dialog header"},{visible:'xpath///*[@id="modal"]/div/header'}]}],optIn:[{any:[{waitForThenClick:"#cookie-policy-button-accept"},{waitForThenClick:'xpath///*[@id="cookie-policy-button-accept"]'}]}],optOut:[{any:[{waitForThenClick:"button.js-manage"},{waitForThenClick:'xpath///*[@id="cookie-policy-content"]/p[4]/button[2]'}]},{waitForThenClick:"dialog.cookie-policy .p-switch__input:checked",optional:!0,all:!0,timeout:500},{any:[{waitForThenClick:"dialog.cookie-policy .js-save-preferences"},{waitForThenClick:'xpath///*[@id="modal"]/div/button'}]}],test:[{eval:"EVAL_UBUNTU_COM_0"}]},{name:"UK Cookie Consent",prehideSelectors:["#catapult-cookie-bar"],cosmetic:!0,detectCmp:[{exists:"#catapult-cookie-bar"}],detectPopup:[{exists:".has-cookie-bar #catapult-cookie-bar"}],optIn:[{click:"#catapultCookie"}],optOut:[{hide:"#catapult-cookie-bar"}],test:[{eval:"EVAL_UK_COOKIE_CONSENT_0"}]},{name:"urbanarmorgear-com",cosmetic:!0,prehideSelectors:['div[class^="Layout__CookieBannerContainer-"]'],detectCmp:[{exists:'div[class^="Layout__CookieBannerContainer-"]'}],detectPopup:[{visible:'div[class^="Layout__CookieBannerContainer-"]'}],optIn:[{click:'button[class^="CookieBanner__AcceptButton"]'}],optOut:[{hide:'div[class^="Layout__CookieBannerContainer-"]'}]},{name:"usercentrics-api",detectCmp:[{exists:"#usercentrics-root,#usercentrics-cmp-ui"}],detectPopup:[{eval:"EVAL_USERCENTRICS_API_0"},{if:{exists:"#usercentrics-cmp-ui"},then:[{waitForVisible:"#usercentrics-cmp-ui",timeout:2e3}],else:[{exists:["#usercentrics-root","[data-testid=uc-container]"]},{waitForVisible:"#usercentrics-root",timeout:2e3}]}],optIn:[{eval:"EVAL_USERCENTRICS_API_3"},{eval:"EVAL_USERCENTRICS_API_1"},{eval:"EVAL_USERCENTRICS_API_5"}],optOut:[{eval:"EVAL_USERCENTRICS_API_1"},{eval:"EVAL_USERCENTRICS_API_2"}],test:[{eval:"EVAL_USERCENTRICS_API_6"}]},{name:"usercentrics-button",detectCmp:[{exists:"#usercentrics-button"}],detectPopup:[{visible:"#usercentrics-button #uc-btn-accept-banner"}],optIn:[{click:"#usercentrics-button #uc-btn-accept-banner"}],optOut:[{click:"#usercentrics-button #uc-btn-deny-banner"}],test:[{eval:"EVAL_USERCENTRICS_BUTTON_0"}]},{name:"uswitch.com",runContext:{main:!0,frame:!1,urlPattern:"^https://(www\\.)?uswitch\\.com/"},prehideSelectors:[".ucb"],detectCmp:[{exists:".ucb-banner"}],detectPopup:[{visible:".ucb-banner"}],optIn:[{waitForThenClick:".ucb-banner .ucb-btn-accept"}],optOut:[{waitForThenClick:".ucb-banner .ucb-btn-save"}]},{name:"vodafone.de",runContext:{urlPattern:"^https://www\\.vodafone\\.de/"},prehideSelectors:[".dip-consent,.dip-consent-container"],detectCmp:[{exists:".dip-consent-container"}],detectPopup:[{visible:".dip-consent-content"}],optOut:[{click:'.dip-consent-btn[tabindex="2"]'}],optIn:[{click:'.dip-consent-btn[tabindex="1"]'}]},{name:"waitrose.com",prehideSelectors:["div[aria-labelledby=CookieAlertModalHeading]","section[data-test=initial-waitrose-cookie-consent-banner]","section[data-test=cookie-consent-modal]"],detectCmp:[{exists:"section[data-test=initial-waitrose-cookie-consent-banner]"}],detectPopup:[{visible:"section[data-test=initial-waitrose-cookie-consent-banner]"}],optIn:[{click:"button[data-test=accept-all]"}],optOut:[{click:"button[data-test=manage-cookies]"},{wait:200},{eval:"EVAL_WAITROSE_0"},{click:"button[data-test=submit]"}],test:[{eval:"EVAL_WAITROSE_1"}]},{name:"webflow",vendorUrl:"https://webflow.com/",prehideSelectors:[".fs-cc-components"],detectCmp:[{exists:".fs-cc-components"}],detectPopup:[{visible:".fs-cc-components"},{visible:"[fs-cc=banner]"}],optIn:[{wait:500},{waitForThenClick:"[fs-cc=banner] [fs-cc=allow]"}],optOut:[{wait:500},{waitForThenClick:"[fs-cc=banner] [fs-cc=deny]"}]},{name:"wetransfer.com",detectCmp:[{exists:".welcome__cookie-notice"}],detectPopup:[{visible:".welcome__cookie-notice"}],optIn:[{click:".welcome__button--accept"}],optOut:[{click:".welcome__button--decline"}]},{name:"whitepages.com",runContext:{urlPattern:"^https://www\\.whitepages\\.com/"},cosmetic:!0,prehideSelectors:[".cookie-wrapper, .cookie-overlay"],detectCmp:[{exists:".cookie-wrapper"}],detectPopup:[{visible:".cookie-overlay"}],optIn:[{click:'button[aria-label="Got it"]'}],optOut:[{hide:".cookie-wrapper"}]},{name:"wolframalpha",vendorUrl:"https://www.wolframalpha.com",prehideSelectors:[],cosmetic:!0,runContext:{urlPattern:"^https://www\\.wolframalpha\\.com/"},detectCmp:[{exists:"section._a_yb"}],detectPopup:[{visible:"section._a_yb"}],optIn:[{waitForThenClick:"section._a_yb button"}],optOut:[{hide:"section._a_yb"}]},{name:"woo-commerce-com",prehideSelectors:[".wccom-comp-privacy-banner .wccom-privacy-banner"],detectCmp:[{exists:".wccom-comp-privacy-banner .wccom-privacy-banner"}],detectPopup:[{exists:".wccom-comp-privacy-banner .wccom-privacy-banner"}],optIn:[{click:".wccom-privacy-banner__content-buttons button.is-primary"}],optOut:[{click:".wccom-privacy-banner__content-buttons button.is-secondary"},{waitForThenClick:"input[type=checkbox][checked]:not([disabled])",all:!0},{click:"div.wccom-modal__footer > button"}]},{name:"WP Cookie Notice for GDPR",vendorUrl:"https://wordpress.org/plugins/gdpr-cookie-consent/",prehideSelectors:["#gdpr-cookie-consent-bar"],detectCmp:[{exists:"#gdpr-cookie-consent-bar"}],detectPopup:[{visible:"#gdpr-cookie-consent-bar"}],optIn:[{waitForThenClick:"#gdpr-cookie-consent-bar #cookie_action_accept"}],optOut:[{waitForThenClick:"#gdpr-cookie-consent-bar #cookie_action_reject"}],test:[{eval:"EVAL_WP_COOKIE_NOTICE_0"}]},{name:"wpcc",cosmetic:!0,prehideSelectors:[".wpcc-container"],detectCmp:[{exists:".wpcc-container"}],detectPopup:[{exists:".wpcc-container .wpcc-message"}],optIn:[{click:".wpcc-compliance .wpcc-btn"}],optOut:[{hide:".wpcc-container"}]},{name:"xe.com",vendorUrl:"https://www.xe.com/",runContext:{urlPattern:"^https://www\\.xe\\.com/"},prehideSelectors:["[class*=ConsentBanner]"],detectCmp:[{exists:"[class*=ConsentBanner]"}],detectPopup:[{visible:"[class*=ConsentBanner]"}],optIn:[{waitForThenClick:"[class*=ConsentBanner] .egnScw"}],optOut:[{wait:1e3},{waitForThenClick:"[class*=ConsentBanner] .frDWEu"},{waitForThenClick:"[class*=ConsentBanner] .hXIpFU"}],test:[{eval:"EVAL_XE_TEST"}]},{name:"xhamster-eu",prehideSelectors:[".cookies-modal"],detectCmp:[{exists:".cookies-modal"}],detectPopup:[{exists:".cookies-modal"}],optIn:[{click:"button.cmd-button-accept-all"}],optOut:[{click:"button.cmd-button-reject-all"}]},{name:"xhamster-us",runContext:{urlPattern:"^https://(www\\.)?xhamster\\d?\\.com"},cosmetic:!0,prehideSelectors:[".cookie-announce"],detectCmp:[{exists:".cookie-announce"}],detectPopup:[{visible:".cookie-announce .announce-text"}],optIn:[{click:".cookie-announce button.xh-button"}],optOut:[{hide:".cookie-announce"}]},{name:"xing.com",detectCmp:[{exists:"div[class^=cookie-consent-CookieConsent]"}],detectPopup:[{exists:"div[class^=cookie-consent-CookieConsent]"}],optIn:[{click:"#consent-accept-button"}],optOut:[{click:"#consent-settings-button"},{click:".consent-banner-button-accept-overlay"}],test:[{eval:"EVAL_XING_0"}]},{name:"xnxx-com",cosmetic:!0,prehideSelectors:["#cookies-use-alert"],detectCmp:[{exists:"#cookies-use-alert"}],detectPopup:[{visible:"#cookies-use-alert"}],optIn:[{click:"#cookies-use-alert .close"}],optOut:[{hide:"#cookies-use-alert"}]},{name:"xvideos",vendorUrl:"https://xvideos.com",runContext:{urlPattern:"^https://[^/]*xvideos\\.com/"},prehideSelectors:[],detectCmp:[{exists:".disclaimer-opened #disclaimer-cookies"}],detectPopup:[{visible:".disclaimer-opened #disclaimer-cookies"}],optIn:[{waitForThenClick:"#disclaimer-accept_cookies"}],optOut:[{waitForThenClick:"#disclaimer-reject_cookies"}]},{name:"Yahoo",runContext:{urlPattern:"^https://consent\\.yahoo\\.com/v2/"},prehideSelectors:["#reject-all"],detectCmp:[{exists:"#consent-page"}],detectPopup:[{visible:"#consent-page"}],optIn:[{waitForThenClick:"#consent-page button[value=agree]"}],optOut:[{waitForThenClick:"#consent-page button[value=reject]"}]},{name:"youporn.com",cosmetic:!0,prehideSelectors:[".euCookieModal, #js_euCookieModal"],detectCmp:[{exists:".euCookieModal"}],detectPopup:[{exists:".euCookieModal, #js_euCookieModal"}],optIn:[{click:'button[name="user_acceptCookie"]'}],optOut:[{hide:".euCookieModal"}]},{name:"youtube-desktop",prehideSelectors:["tp-yt-iron-overlay-backdrop.opened","ytd-consent-bump-v2-lightbox"],detectCmp:[{exists:"ytd-consent-bump-v2-lightbox tp-yt-paper-dialog"},{exists:'ytd-consent-bump-v2-lightbox tp-yt-paper-dialog a[href^="https://consent.youtube.com/"]'}],detectPopup:[{visible:"ytd-consent-bump-v2-lightbox tp-yt-paper-dialog"}],optIn:[{waitForThenClick:"ytd-consent-bump-v2-lightbox .eom-buttons .eom-button-row:first-child ytd-button-renderer:last-child #button,ytd-consent-bump-v2-lightbox .eom-buttons .eom-button-row:first-child ytd-button-renderer:last-child button"},{wait:500}],optOut:[{waitForThenClick:"ytd-consent-bump-v2-lightbox .eom-buttons .eom-button-row:first-child ytd-button-renderer:first-child #button,ytd-consent-bump-v2-lightbox .eom-buttons .eom-button-row:first-child ytd-button-renderer:first-child button"},{wait:500}],test:[{wait:500},{eval:"EVAL_YOUTUBE_DESKTOP_0"}]},{name:"youtube-mobile",prehideSelectors:[".consent-bump-v2-lightbox"],detectCmp:[{exists:"ytm-consent-bump-v2-renderer"}],detectPopup:[{visible:"ytm-consent-bump-v2-renderer"}],optIn:[{waitForThenClick:"ytm-consent-bump-v2-renderer .privacy-terms + .one-col-dialog-buttons c3-material-button:first-child button, ytm-consent-bump-v2-renderer .privacy-terms + .one-col-dialog-buttons ytm-button-renderer:first-child button"},{wait:500}],optOut:[{waitForThenClick:"ytm-consent-bump-v2-renderer .privacy-terms + .one-col-dialog-buttons c3-material-button:nth-child(2) button, ytm-consent-bump-v2-renderer .privacy-terms + .one-col-dialog-buttons ytm-button-renderer:nth-child(2) button"},{wait:500}],test:[{wait:500},{eval:"EVAL_YOUTUBE_MOBILE_0"}]},{name:"zdf",prehideSelectors:["#zdf-cmp-banner-sdk"],detectCmp:[{exists:"#zdf-cmp-banner-sdk"}],detectPopup:[{visible:"#zdf-cmp-main.zdf-cmp-show"}],optIn:[{waitForThenClick:"#zdf-cmp-main #zdf-cmp-accept-btn"}],optOut:[{waitForThenClick:"#zdf-cmp-main #zdf-cmp-deny-btn"}],test:[]},{name:"zentralruf-de",runContext:{urlPattern:"^https://(www\\.)?zentralruf\\.de"},prehideSelectors:["#cookie_modal_wrapper"],detectCmp:[{exists:"#cookie_modal_wrapper"}],detectPopup:[{visible:"#cookie_modal_wrapper"}],optIn:[{waitForThenClick:"#cookie_modal_wrapper #cookie_modal_button_consent_all"}],optOut:[{waitForThenClick:"#cookie_modal_wrapper #cookie_modal_button_choose"}]}],pn={"didomi.io":{detectors:[{presentMatcher:{target:{selector:"#didomi-host, #didomi-notice"},type:"css"},showingMatcher:{target:{selector:"body.didomi-popup-open, .didomi-notice-banner"},type:"css"}}],methods:[{action:{target:{selector:".didomi-popup-notice-buttons .didomi-button:not(.didomi-button-highlight), .didomi-notice-banner .didomi-learn-more-button"},type:"click"},name:"OPEN_OPTIONS"},{action:{actions:[{retries:50,target:{selector:"#didomi-purpose-cookies"},type:"waitcss",waitTime:50},{consents:[{description:"Share (everything) with others",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-share_whith_others]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-share_whith_others]:last-child"},type:"click"},type:"X"},{description:"Information storage and access",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-cookies]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-cookies]:last-child"},type:"click"},type:"D"},{description:"Content selection, offers and marketing",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-CL-T1Rgm7]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-CL-T1Rgm7]:last-child"},type:"click"},type:"E"},{description:"Analytics",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-analytics]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-analytics]:last-child"},type:"click"},type:"B"},{description:"Analytics",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-M9NRHJe3G]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-M9NRHJe3G]:last-child"},type:"click"},type:"B"},{description:"Ad and content selection",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-advertising_personalization]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-advertising_personalization]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection",falseAction:{parent:{childFilter:{target:{selector:"#didomi-purpose-pub-ciblee"}},selector:".didomi-consent-popup-data-processing, .didomi-components-accordion-label-container"},target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-pub-ciblee]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-pub-ciblee]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection - basics",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-q4zlJqdcD]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-q4zlJqdcD]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection - partners and subsidiaries",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-partenaire-cAsDe8jC]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-partenaire-cAsDe8jC]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection - social networks",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-p4em9a8m]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-p4em9a8m]:last-child"},type:"click"},type:"F"},{description:"Ad and content selection - others",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-autres-pub]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-autres-pub]:last-child"},type:"click"},type:"F"},{description:"Social networks",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-reseauxsociaux]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-reseauxsociaux]:last-child"},type:"click"},type:"A"},{description:"Social networks",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-social_media]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-social_media]:last-child"},type:"click"},type:"A"},{description:"Content selection",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-content_personalization]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-content_personalization]:last-child"},type:"click"},type:"E"},{description:"Ad delivery",falseAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-ad_delivery]:first-child"},type:"click"},trueAction:{target:{selector:".didomi-components-radio__option[aria-describedby=didomi-purpose-ad_delivery]:last-child"},type:"click"},type:"F"}],type:"consent"},{action:{consents:[{matcher:{childFilter:{target:{selector:":not(.didomi-components-radio__option--selected)"}},type:"css"},trueAction:{target:{selector:":nth-child(2)"},type:"click"},falseAction:{target:{selector:":first-child"},type:"click"},type:"X"}],type:"consent"},target:{selector:".didomi-components-radio"},type:"foreach"}],type:"list"},name:"DO_CONSENT"},{action:{parent:{selector:".didomi-consent-popup-footer .didomi-consent-popup-actions"},target:{selector:".didomi-components-button:first-child"},type:"click"},name:"SAVE_CONSENT"}]},oil:{detectors:[{presentMatcher:{target:{selector:".as-oil-content-overlay"},type:"css"},showingMatcher:{target:{selector:".as-oil-content-overlay"},type:"css"}}],methods:[{action:{actions:[{target:{selector:".as-js-advanced-settings"},type:"click"},{retries:"10",target:{selector:".as-oil-cpc__purpose-container"},type:"waitcss",waitTime:"250"}],type:"list"},name:"OPEN_OPTIONS"},{action:{actions:[{consents:[{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Information storage and access","Opbevaring af og adgang til oplysninger på din enhed"]},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Information storage and access","Opbevaring af og adgang til oplysninger på din enhed"]},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"D"},{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Personlige annoncer","Personalisation"]},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Personlige annoncer","Personalisation"]},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"E"},{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Annoncevalg, levering og rapportering","Ad selection, delivery, reporting"]},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Annoncevalg, levering og rapportering","Ad selection, delivery, reporting"]},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"F"},{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Personalisering af indhold","Content selection, delivery, reporting"]},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:["Personalisering af indhold","Content selection, delivery, reporting"]},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"E"},{matcher:{parent:{childFilter:{target:{selector:".as-oil-cpc__purpose-header",textFilter:["Måling","Measurement"]}},selector:".as-oil-cpc__purpose-container"},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{childFilter:{target:{selector:".as-oil-cpc__purpose-header",textFilter:["Måling","Measurement"]}},selector:".as-oil-cpc__purpose-container"},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"B"},{matcher:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:"Google"},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".as-oil-cpc__purpose-container",textFilter:"Google"},target:{selector:".as-oil-cpc__switch"},type:"click"},type:"F"}],type:"consent"}],type:"list"},name:"DO_CONSENT"},{action:{target:{selector:".as-oil__btn-optin"},type:"click"},name:"SAVE_CONSENT"},{action:{target:{selector:"div.as-oil"},type:"hide"},name:"HIDE_CMP"}]},optanon:{detectors:[{presentMatcher:{target:{selector:"#optanon-menu, .optanon-alert-box-wrapper"},type:"css"},showingMatcher:{target:{displayFilter:!0,selector:".optanon-alert-box-wrapper"},type:"css"}}],methods:[{action:{actions:[{target:{selector:".optanon-alert-box-wrapper .optanon-toggle-display, a[onclick*='OneTrust.ToggleInfoDisplay()'], a[onclick*='Optanon.ToggleInfoDisplay()']"},type:"click"}],type:"list"},name:"OPEN_OPTIONS"},{action:{actions:[{target:{selector:".preference-menu-item #Your-privacy"},type:"click"},{target:{selector:"#optanon-vendor-consent-text"},type:"click"},{action:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"X"}],type:"consent"},target:{selector:"#optanon-vendor-consent-list .vendor-item"},type:"foreach"},{target:{selector:".vendor-consent-back-link"},type:"click"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-performance"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-performance"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-functional"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-functional"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-advertising"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-advertising"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-social"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-social"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Social Media Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Social Media Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Personalisation"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Personalisation"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Site monitoring cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Site monitoring cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Third party privacy-enhanced content"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Third party privacy-enhanced content"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"X"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Performance & Advertising Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Performance & Advertising Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Information storage and access"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Information storage and access"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"D"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Ad selection, delivery, reporting"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Ad selection, delivery, reporting"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Content selection, delivery, reporting"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Content selection, delivery, reporting"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Measurement"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Measurement"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Recommended Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Recommended Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"X"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Unclassified Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Unclassified Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"X"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Analytical Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Analytical Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"B"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Marketing Cookies"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Marketing Cookies"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Personalization"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Personalization"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Ad Selection, Delivery & Reporting"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Ad Selection, Delivery & Reporting"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},type:"ifcss"},{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Content Selection, Delivery & Reporting"},trueAction:{actions:[{parent:{selector:"#optanon-menu, .optanon-menu"},target:{selector:".menu-item-necessary",textFilter:"Content Selection, Delivery & Reporting"},type:"click"},{consents:[{matcher:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status input"},type:"checkbox"},toggleAction:{parent:{selector:"#optanon-popup-body-right"},target:{selector:".optanon-status label"},type:"click"},type:"E"}],type:"consent"}],type:"list"},type:"ifcss"}],type:"list"},name:"DO_CONSENT"},{action:{parent:{selector:".optanon-save-settings-button"},target:{selector:".optanon-white-button-middle"},type:"click"},name:"SAVE_CONSENT"},{action:{actions:[{target:{selector:"#optanon-popup-wrapper"},type:"hide"},{target:{selector:"#optanon-popup-bg"},type:"hide"},{target:{selector:".optanon-alert-box-wrapper"},type:"hide"}],type:"list"},name:"HIDE_CMP"}]},quantcast2:{detectors:[{presentMatcher:{target:{selector:"[data-tracking-opt-in-overlay]"},type:"css"},showingMatcher:{target:{selector:"[data-tracking-opt-in-overlay] [data-tracking-opt-in-learn-more]"},type:"css"}}],methods:[{action:{target:{selector:"[data-tracking-opt-in-overlay] [data-tracking-opt-in-learn-more]"},type:"click"},name:"OPEN_OPTIONS"},{action:{actions:[{type:"wait",waitTime:500},{action:{actions:[{target:{selector:"div",textFilter:["Information storage and access"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"D"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Personalization"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"F"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Ad selection, delivery, reporting"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"F"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Content selection, delivery, reporting"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"E"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Measurement"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"B"}],type:"consent"},type:"ifcss"},{target:{selector:"div",textFilter:["Other Partners"]},trueAction:{consents:[{matcher:{target:{selector:"input"},type:"checkbox"},toggleAction:{target:{selector:"label"},type:"click"},type:"X"}],type:"consent"},type:"ifcss"}],type:"list"},parent:{childFilter:{target:{selector:"input"}},selector:"[data-tracking-opt-in-overlay] > div > div"},target:{childFilter:{target:{selector:"input"}},selector:":scope > div"},type:"foreach"}],type:"list"},name:"DO_CONSENT"},{action:{target:{selector:"[data-tracking-opt-in-overlay] [data-tracking-opt-in-save]"},type:"click"},name:"SAVE_CONSENT"}]},springer:{detectors:[{presentMatcher:{parent:null,target:{selector:".cmp-app_gdpr"},type:"css"},showingMatcher:{parent:null,target:{displayFilter:!0,selector:".cmp-popup_popup"},type:"css"}}],methods:[{action:{actions:[{target:{selector:".cmp-intro_rejectAll"},type:"click"},{type:"wait",waitTime:250},{target:{selector:".cmp-purposes_purposeItem:not(.cmp-purposes_selectedPurpose)"},type:"click"}],type:"list"},name:"OPEN_OPTIONS"},{action:{consents:[{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Przechowywanie informacji na urządzeniu lub dostęp do nich",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Przechowywanie informacji na urządzeniu lub dostęp do nich",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"D"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór podstawowych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór podstawowych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"F"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Tworzenie profilu spersonalizowanych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Tworzenie profilu spersonalizowanych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"F"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór spersonalizowanych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór spersonalizowanych reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"E"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Tworzenie profilu spersonalizowanych treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Tworzenie profilu spersonalizowanych treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"E"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór spersonalizowanych treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Wybór spersonalizowanych treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"B"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Pomiar wydajności reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Pomiar wydajności reklam",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"B"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Pomiar wydajności treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Pomiar wydajności treści",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"B"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Stosowanie badań rynkowych w celu generowania opinii odbiorców",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Stosowanie badań rynkowych w celu generowania opinii odbiorców",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"X"},{matcher:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Opracowywanie i ulepszanie produktów",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch .cmp-switch_isSelected"},type:"css"},toggleAction:{parent:{selector:".cmp-purposes_detailHeader",textFilter:"Opracowywanie i ulepszanie produktów",childFilter:{target:{selector:".cmp-switch_switch"}}},target:{selector:".cmp-switch_switch:not(.cmp-switch_isSelected)"},type:"click"},type:"X"}],type:"consent"},name:"DO_CONSENT"},{action:{target:{selector:".cmp-details_save"},type:"click"},name:"SAVE_CONSENT"}]},wordpressgdpr:{detectors:[{presentMatcher:{parent:null,target:{selector:".wpgdprc-consent-bar"},type:"css"},showingMatcher:{parent:null,target:{displayFilter:!0,selector:".wpgdprc-consent-bar"},type:"css"}}],methods:[{action:{parent:null,target:{selector:".wpgdprc-consent-bar .wpgdprc-consent-bar__settings",textFilter:null},type:"click"},name:"OPEN_OPTIONS"},{action:{actions:[{target:{selector:".wpgdprc-consent-modal .wpgdprc-button",textFilter:"Eyeota"},type:"click"},{consents:[{description:"Eyeota Cookies",matcher:{parent:{selector:".wpgdprc-consent-modal__description",textFilter:"Eyeota"},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".wpgdprc-consent-modal__description",textFilter:"Eyeota"},target:{selector:"label"},type:"click"},type:"X"}],type:"consent"},{target:{selector:".wpgdprc-consent-modal .wpgdprc-button",textFilter:"Advertising"},type:"click"},{consents:[{description:"Advertising Cookies",matcher:{parent:{selector:".wpgdprc-consent-modal__description",textFilter:"Advertising"},target:{selector:"input"},type:"checkbox"},toggleAction:{parent:{selector:".wpgdprc-consent-modal__description",textFilter:"Advertising"},target:{selector:"label"},type:"click"},type:"F"}],type:"consent"}],type:"list"},name:"DO_CONSENT"},{action:{parent:null,target:{selector:".wpgdprc-button",textFilter:"Save my settings"},type:"click"},name:"SAVE_CONSENT"}]}},dn={autoconsent:ln,consentomatic:pn},un=Object.freeze({__proto__:null,autoconsent:ln,consentomatic:pn,default:dn}); +/*! Bundled license information: + + @ghostery/adblocker/dist/esm/codebooks/cosmetic-selector.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/codebooks/network-csp.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/codebooks/network-filter.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/codebooks/network-hostname.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/codebooks/network-redirect.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/codebooks/raw-network.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/codebooks/raw-cosmetic.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/compression.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/punycode.js: + (*! + * Copyright Mathias Bynens + * + * 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. + *) + + @ghostery/adblocker/dist/esm/data-view.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/config.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/events.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/fetch.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker-extended-selectors/dist/esm/types.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker-extended-selectors/dist/esm/parse.js: + (*! + * Based on parsel. Extended by Rémi Berson for Ghostery (2021). + * https://github.com/LeaVerou/parsel + * + * MIT License + * + * Copyright (c) 2020 Lea Verou + * + * 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. + *) + + @ghostery/adblocker-extended-selectors/dist/esm/eval.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker-extended-selectors/dist/esm/extended.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker-extended-selectors/dist/esm/index.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/tokens-buffer.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/utils.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/request.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/domains.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/html-filtering.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/filters/cosmetic.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/filters/dsl.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/filters/network.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/lists.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/resources.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/compact-set.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/optimizer.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/reverse-index.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/bucket/filters.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/bucket/cosmetic.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/bucket/network.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/map.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/metadata/categories.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/metadata/organizations.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/metadata/patterns.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/metadata.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/engine/engine.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker/dist/esm/encoding.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + (*! + * Copyright (c) 2008-2009 Bjoern Hoehrmann + * + * 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. + *) + + @ghostery/adblocker/dist/esm/index.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + + @ghostery/adblocker-content/dist/esm/index.js: + (*! + * Copyright (c) 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *) + */const hn=new class{constructor(e,t=null,o=null){if(this.id=c(),this.rules=[],this.foundCmp=null,this.state={cosmeticFiltersOn:!1,filterListReported:!1,lifecycle:"loading",prehideOn:!1,findCmpAttempts:0,detectedCmps:[],detectedPopups:[],selfTest:null},a.sendContentMessage=e,this.sendContentMessage=e,this.rules=[],this.updateState({lifecycle:"loading"}),this.addDynamicRules(),t)this.initialize(t,o);else{o&&this.parseDeclarativeRules(o);e({type:"init",url:window.location.href}),this.updateState({lifecycle:"waitingForInitResponse"})}this.domActions=new _(this)}initialize(e,t){const o=b(e);if(o.logs.lifecycle&&console.log("autoconsent init",window.location.href),this.config=o,o.enabled){if(t&&this.parseDeclarativeRules(t),e.enableFilterList){try{an&&an.length>0&&(this.filtersEngine=nn.deserialize(an))}catch(e){console.error("Error parsing filter list",e)}"loading"===document.readyState?window.addEventListener("DOMContentLoaded",(()=>{this.applyCosmeticFilters()})):this.applyCosmeticFilters()}if(this.rules=function(e,t){return e.filter((e=>(!t.disabledCmps||!t.disabledCmps.includes(e.name))&&(t.enableCosmeticRules||!e.isCosmetic)))}(this.rules,o),e.enablePrehide)if(document.documentElement)this.prehideElements();else{const e=()=>{window.removeEventListener("DOMContentLoaded",e),this.prehideElements()};window.addEventListener("DOMContentLoaded",e)}if("loading"===document.readyState){const e=()=>{window.removeEventListener("DOMContentLoaded",e),this.start()};window.addEventListener("DOMContentLoaded",e)}else this.start();this.updateState({lifecycle:"initialized"})}else o.logs.lifecycle&&console.log("autoconsent is disabled")}addDynamicRules(){v.forEach((e=>{this.rules.push(new e(this))}))}parseDeclarativeRules(e){e.consentomatic&&Object.keys(e.consentomatic).forEach((t=>{this.addConsentomaticCMP(t,e.consentomatic[t])})),e.autoconsent&&e.autoconsent.forEach((e=>{this.addDeclarativeCMP(e)}))}addDeclarativeCMP(e){this.rules.push(new u(e,this))}addConsentomaticCMP(e,t){this.rules.push(new h(`com_${e}`,t))}start(){!function(e,t=500){globalThis.requestIdleCallback?requestIdleCallback(e,{timeout:t}):setTimeout(e,0)}((()=>this._start()))}async _start(){const e=this.config.logs;e.lifecycle&&console.log(`Detecting CMPs on ${window.location.href}`),this.updateState({lifecycle:"started"});const t=await this.findCmp(this.config.detectRetries);if(this.updateState({detectedCmps:t.map((e=>e.name))}),0===t.length)return e.lifecycle&&console.log("no CMP found",location.href),this.config.enablePrehide&&this.undoPrehide(),this.filterListFallback();this.updateState({lifecycle:"cmpDetected"});const o=[],i=[];for(const e of t)e.isCosmetic?i.push(e):o.push(e);let n=!1,s=await this.detectPopups(o,(async e=>{n=await this.handlePopup(e)}));if(0===s.length&&(s=await this.detectPopups(i,(async e=>{n=await this.handlePopup(e)}))),0===s.length)return e.lifecycle&&console.log("no popup found"),this.config.enablePrehide&&this.undoPrehide(),!1;if(s.length>1){const t={msg:"Found multiple CMPs, check the detection rules.",cmps:s.map((e=>e.name))};e.errors&&console.warn(t.msg,t.cmps),this.sendContentMessage({type:"autoconsentError",details:t})}return n}async findCmp(e){const t=this.config.logs;this.updateState({findCmpAttempts:this.state.findCmpAttempts+1});const o=[];for(const e of this.rules)try{if(!e.checkRunContext())continue;await e.detectCmp()&&(t.lifecycle&&console.log(`Found CMP: ${e.name} ${window.location.href}`),this.sendContentMessage({type:"cmpDetected",url:location.href,cmp:e.name}),o.push(e))}catch(o){t.errors&&console.warn(`error detecting ${e.name}`,o)}return 0===o.length&&e>0?(await this.domActions.wait(500),this.findCmp(e-1)):o}async detectPopup(e){if(await this.waitForPopup(e).catch((t=>(this.config.logs.errors&&console.warn(`error waiting for a popup for ${e.name}`,t),!1))))return this.updateState({detectedPopups:this.state.detectedPopups.concat([e.name])}),this.sendContentMessage({type:"popupFound",cmp:e.name,url:location.href}),e;throw new Error("Popup is not shown")}async detectPopups(e,t){const o=e.map((e=>this.detectPopup(e)));await Promise.any(o).then((e=>{t(e)})).catch((()=>null));const i=await Promise.allSettled(o),n=[];for(const e of i)"fulfilled"===e.status&&n.push(e.value);return n}async handlePopup(e){return this.updateState({lifecycle:"openPopupDetected"}),this.config.enablePrehide&&!this.state.prehideOn&&this.prehideElements(),this.state.cosmeticFiltersOn&&this.undoCosmetics(),this.foundCmp=e,"optOut"===this.config.autoAction?await this.doOptOut():"optIn"===this.config.autoAction?await this.doOptIn():(this.config.logs.lifecycle&&console.log("waiting for opt-out signal...",location.href),!0)}async doOptOut(){const e=this.config.logs;let t;return this.updateState({lifecycle:"runningOptOut"}),this.foundCmp?(e.lifecycle&&console.log(`CMP ${this.foundCmp.name}: opt out on ${window.location.href}`),t=await this.foundCmp.optOut(),e.lifecycle&&console.log(`${this.foundCmp.name}: opt out result ${t}`)):(e.errors&&console.log("no CMP to opt out"),t=!1),this.config.enablePrehide&&this.undoPrehide(),this.sendContentMessage({type:"optOutResult",cmp:this.foundCmp?this.foundCmp.name:"none",result:t,scheduleSelfTest:this.foundCmp&&this.foundCmp.hasSelfTest,url:location.href}),t&&!this.foundCmp.isIntermediate?(this.sendContentMessage({type:"autoconsentDone",cmp:this.foundCmp.name,isCosmetic:this.foundCmp.isCosmetic,url:location.href}),this.updateState({lifecycle:"done"})):this.updateState({lifecycle:t?"optOutSucceeded":"optOutFailed"}),t}async doOptIn(){const e=this.config.logs;let t;return this.updateState({lifecycle:"runningOptIn"}),this.foundCmp?(e.lifecycle&&console.log(`CMP ${this.foundCmp.name}: opt in on ${window.location.href}`),t=await this.foundCmp.optIn(),e.lifecycle&&console.log(`${this.foundCmp.name}: opt in result ${t}`)):(e.errors&&console.log("no CMP to opt in"),t=!1),this.config.enablePrehide&&this.undoPrehide(),this.sendContentMessage({type:"optInResult",cmp:this.foundCmp?this.foundCmp.name:"none",result:t,scheduleSelfTest:!1,url:location.href}),t&&!this.foundCmp.isIntermediate?(this.sendContentMessage({type:"autoconsentDone",cmp:this.foundCmp.name,isCosmetic:this.foundCmp.isCosmetic,url:location.href}),this.updateState({lifecycle:"done"})):this.updateState({lifecycle:t?"optInSucceeded":"optInFailed"}),t}async doSelfTest(){const e=this.config.logs;let t;return this.foundCmp?(e.lifecycle&&console.log(`CMP ${this.foundCmp.name}: self-test on ${window.location.href}`),t=await this.foundCmp.test()):(e.errors&&console.log("no CMP to self test"),t=!1),this.sendContentMessage({type:"selfTestResult",cmp:this.foundCmp?this.foundCmp.name:"none",result:t,url:location.href}),this.updateState({selfTest:t}),t}async waitForPopup(e,t=5,o=500){const i=this.config.logs;i.lifecycle&&console.log("checking if popup is open...",e.name);const n=await e.detectPopup().catch((t=>(i.errors&&console.warn(`error detecting popup for ${e.name}`,t),!1)));return!n&&t>0?(await this.domActions.wait(o),this.waitForPopup(e,t-1,o)):(i.lifecycle&&console.log(e.name,"popup is "+(n?"open":"not open")),n)}prehideElements(){const e=this.config.logs,t=this.rules.filter((e=>e.prehideSelectors&&e.checkRunContext())).reduce(((e,t)=>[...e,...t.prehideSelectors]),["#didomi-popup,.didomi-popup-container,.didomi-popup-notice,.didomi-consent-popup-preferences,#didomi-notice,.didomi-popup-backdrop,.didomi-screen-medium"]);return this.updateState({prehideOn:!0}),setTimeout((()=>{this.config.enablePrehide&&this.state.prehideOn&&!["runningOptOut","runningOptIn"].includes(this.state.lifecycle)&&(e.lifecycle&&console.log("Process is taking too long, unhiding elements"),this.undoPrehide())}),this.config.prehideTimeout||2e3),this.domActions.prehide(t.join(","))}undoPrehide(){return this.updateState({prehideOn:!1}),this.domActions.undoPrehide()}async applyCosmeticFilters(e){if(!this.filtersEngine)return!1;const t=this.config?.logs;e||(e=cn(this.filtersEngine)),setTimeout((()=>{if(this.state.cosmeticFiltersOn&&!this.state.filterListReported){this.domActions.elementVisible(rn(e),"any")?(t?.lifecycle&&console.log("Prehide cosmetic filters matched",location.href),this.reportFilterlist()):t?.lifecycle&&console.log("Prehide cosmetic filters didn't match",location.href)}}),1e3),this.updateState({cosmeticFiltersOn:!0});try{this.cosmeticStyleSheet=await this.domActions.createOrUpdateStyleSheet(e,this.cosmeticStyleSheet),t?.lifecycle&&console.log("[cosmetics]",this.cosmeticStyleSheet,location.href),document.adoptedStyleSheets.push(this.cosmeticStyleSheet)}catch(e){return this.config.logs&&console.error("Error applying cosmetic filters",e),!1}return!0}undoCosmetics(){this.updateState({cosmeticFiltersOn:!1}),this.config.logs.lifecycle&&console.log("[undocosmetics]",this.cosmeticStyleSheet,location.href),this.domActions.removeStyleSheet(this.cosmeticStyleSheet)}reportFilterlist(){this.sendContentMessage({type:"cmpDetected",url:location.href,cmp:"filterList"}),this.sendContentMessage({type:"popupFound",cmp:"filterList",url:location.href}),this.updateState({filterListReported:!0})}filterListFallback(){if(!this.filtersEngine)return this.updateState({lifecycle:"nothingDetected"}),!1;const e=cn(this.filtersEngine),t=this.domActions.elementVisible(rn(e),"any"),o=this.config?.logs;return t?(this.applyCosmeticFilters(e),o?.lifecycle&&console.log("Keeping cosmetic filters",location.href),this.updateState({lifecycle:"cosmeticFiltersDetected"}),this.state.filterListReported||this.reportFilterlist(),this.sendContentMessage({type:"optOutResult",cmp:"filterList",result:!0,scheduleSelfTest:!1,url:location.href}),this.updateState({lifecycle:"done"}),this.sendContentMessage({type:"autoconsentDone",cmp:"filterList",isCosmetic:!0,url:location.href}),!0):(o?.lifecycle&&console.log("Cosmetic filters didn't work, removing them",location.href),this.undoCosmetics(),this.updateState({lifecycle:"nothingDetected"}),!1)}updateState(e){Object.assign(this.state,e),this.sendContentMessage({type:"report",instanceId:this.id,url:window.location.href,mainFrame:window.top===window.self,state:this.state})}async receiveMessageCallback(e){const t=this.config?.logs;switch(t?.messages&&console.log("received from background",e,window.location.href),e.type){case"initResp":this.initialize(e.config,e.rules);break;case"optIn":await this.doOptIn();break;case"optOut":await this.doOptOut();break;case"selfTest":await this.doSelfTest();break;case"evalResp":!function(e,t){const o=a.pending.get(e);o?(a.pending.delete(e),o.timer&&window.clearTimeout(o.timer),o.resolve(t)):console.warn("no eval #",e)}(e.id,e.result)}}}((e=>{AutoconsentAndroid.process(JSON.stringify(e))}),null,un);window.autoconsentMessageCallback=e=>{hn.receiveMessageCallback(e)}}(); diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle index 937214be5f02..6e5dc612d6db 100644 --- a/autofill/autofill-impl/build.gradle +++ b/autofill/autofill-impl/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation AndroidX.biometric implementation "net.zetetic:android-database-sqlcipher:_" + implementation "com.facebook.shimmer:shimmer:_" // Testing dependencies testImplementation project(':common-test') diff --git a/autofill/autofill-impl/src/main/AndroidManifest.xml b/autofill/autofill-impl/src/main/AndroidManifest.xml index 6f29e9796fbc..01a906eb9004 100644 --- a/autofill/autofill-impl/src/main/AndroidManifest.xml +++ b/autofill/autofill-impl/src/main/AndroidManifest.xml @@ -15,6 +15,10 @@ android:name=".email.incontext.EmailProtectionInContextSignupActivity" android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|navigation|keyboard" android:exported="false" /> + val timeSinceLastAuth = timeProvider.currentTimeMillis() - lastAuthTime @@ -68,16 +87,33 @@ class AutofillTimeBasedAuthorizationGracePeriod @Inject constructor( return false } } - Timber.v("No last auth time recorded or outside grace period; auth required") + if (inExtendedGracePeriod()) { + Timber.v("Within extended grace period; auth not required") + return false + } + + Timber.v("No last auth time recorded or outside grace period; auth required") return true } + private fun inExtendedGracePeriod(): Boolean { + val extendedRequest = extendedGraceTimeRequested + if (extendedRequest == null) { + return false + } else { + val timeSinceExtendedGrace = timeProvider.currentTimeMillis() - extendedRequest + return timeSinceExtendedGrace <= AUTH_GRACE_EXTENDED_PERIOD_MS + } + } + override fun invalidate() { lastSuccessfulAuthTime = null + removeRequestForExtendedGracePeriod() } companion object { private const val AUTH_GRACE_PERIOD_MS = 15_000 + private const val AUTH_GRACE_EXTENDED_PERIOD_MS = 180_000 } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementBucketing.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementBucketing.kt index 2ebb2684256b..35f42d1c3cbd 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementBucketing.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementBucketing.kt @@ -26,7 +26,7 @@ import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject interface AutofillEngagementBucketing { - fun bucketNumberOfSavedPasswords(savedPasswords: Int): String + fun bucketNumberOfCredentials(numberOfCredentials: Int): String companion object { const val NONE = "none" @@ -40,12 +40,12 @@ interface AutofillEngagementBucketing { @ContributesBinding(AppScope::class) class DefaultAutofillEngagementBucketing @Inject constructor() : AutofillEngagementBucketing { - override fun bucketNumberOfSavedPasswords(savedPasswords: Int): String { + override fun bucketNumberOfCredentials(numberOfCredentials: Int): String { return when { - savedPasswords == 0 -> NONE - savedPasswords < 4 -> FEW - savedPasswords < 11 -> SOME - savedPasswords < 50 -> MANY + numberOfCredentials == 0 -> NONE + numberOfCredentials < 4 -> FEW + numberOfCredentials < 11 -> SOME + numberOfCredentials < 50 -> MANY else -> LOTS } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt index 6c29ca6e9772..a5f730441fbf 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt @@ -97,7 +97,7 @@ class DefaultAutofillEngagementRepository @Inject constructor( val numberStoredPasswords = getNumberStoredPasswords() val togglePixel = if (autofillStore.autofillEnabled) AUTOFILL_TOGGLED_ON_SEARCH else AUTOFILL_TOGGLED_OFF_SEARCH - val bucket = engagementBucketing.bucketNumberOfSavedPasswords(numberStoredPasswords) + val bucket = engagementBucketing.bucketNumberOfCredentials(numberStoredPasswords) pixel.fire(togglePixel, mapOf("count_bucket" to bucket), type = Daily()) } @@ -113,7 +113,7 @@ class DefaultAutofillEngagementRepository @Inject constructor( if (autofilled && searched) { pixel.fire(AUTOFILL_ENGAGEMENT_ACTIVE_USER, type = Daily()) - val bucket = engagementBucketing.bucketNumberOfSavedPasswords(numberStoredPasswords) + val bucket = engagementBucketing.bucketNumberOfCredentials(numberStoredPasswords) pixel.fire(AUTOFILL_ENGAGEMENT_STACKED_LOGINS, mapOf("count_bucket" to bucket), type = Daily()) } 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 index 3ec09c52ba50..04a11c36fcd0 100644 --- 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 @@ -34,7 +34,10 @@ interface CsvCredentialConverter { sealed interface CsvCredentialImportResult : Parcelable { @Parcelize - data class Success(val numberCredentialsInSource: Int, val loginCredentialsToImport: List) : CsvCredentialImportResult + data class Success( + val numberCredentialsInSource: Int, + val loginCredentialsToImport: List, + ) : CsvCredentialImportResult @Parcelize data object Error : CsvCredentialImportResult @@ -80,15 +83,27 @@ class GooglePasswordManagerCsvCredentialConverter @Inject constructor( } } - 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 deduplicateAndCleanup(allCredentials: List): List { + return allCredentials + .distinct() + .filter { credentialValidator.isValid(it) } + .toLoginCredentials() + .filterNewCredentials() } - private suspend fun filterNewCredentials(credentials: List): List { - return existingCredentialMatchDetector.filterExistingCredentials(credentials) + private suspend fun List.toLoginCredentials(): List { + return this.map { + LoginCredentials( + domainTitle = it.title, + username = it.username, + password = it.password, + domain = domainNameNormalizer.normalize(it.url), + notes = it.notes, + ) + } + } + + private suspend fun List.filterNewCredentials(): List { + return existingCredentialMatchDetector.filterExistingCredentials(this) } } 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 index a84020b06cef..17f5037db84d 100644 --- 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 @@ -16,7 +16,6 @@ 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 @@ -33,7 +32,7 @@ interface CsvCredentialParser { suspend fun parseCsv(csv: String): ParseResult sealed interface ParseResult { - data class Success(val credentials: List) : ParseResult + data class Success(val credentials: List) : ParseResult data object Error : ParseResult } } @@ -61,7 +60,7 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor( * Format of the Google Password Manager CSV is: * name | url | username | password | note */ - private suspend fun convertToCredentials(csv: String): List { + private suspend fun convertToCredentials(csv: String): List { return withContext(dispatchers.io()) { val lines = mutableListOf() val iter = CsvReader.builder().build(csv).spliterator() @@ -81,8 +80,8 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor( } parseToCredential( - domainTitle = it.getField(0).blanksToNull(), - domain = it.getField(1).blanksToNull(), + title = it.getField(0).blanksToNull(), + url = it.getField(1).blanksToNull(), username = it.getField(2).blanksToNull(), password = it.getField(3).blanksToNull(), notes = it.getField(4).blanksToNull(), @@ -92,15 +91,15 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor( } private fun parseToCredential( - domainTitle: String?, - domain: String?, + title: String?, + url: String?, username: String?, password: String?, notes: String?, - ): LoginCredentials { - return LoginCredentials( - domainTitle = domainTitle, - domain = domain, + ): GoogleCsvLoginCredential { + return GoogleCsvLoginCredential( + title = title, + url = url, username = username, password = password, notes = notes, 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 index 86fc4fd60a03..06d41a57efd8 100644 --- 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 @@ -16,25 +16,24 @@ 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 + suspend fun normalize(unnormalizedUrl: String?): String? } @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) + override suspend fun normalize(unnormalizedUrl: String?): String? { + return if (unnormalizedUrl == null) { + null + } else { + urlMatcher.cleanRawUrl(unnormalizedUrl) } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt new file mode 100644 index 000000000000..e7386b06504d --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt @@ -0,0 +1,28 @@ +/* + * 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 + +/** + * Data class representing the login credentials imported from a Google CSV file. + */ +data class GoogleCsvLoginCredential( + val url: String? = null, + val username: String? = null, + val password: String? = null, + val title: String? = null, + val notes: String? = null, +) 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 index 5677cda8d040..064cee3df1a2 100644 --- 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 @@ -16,21 +16,22 @@ 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 + fun isValid(loginCredentials: GoogleCsvLoginCredential): Boolean } @ContributesBinding(AppScope::class) class DefaultImportedCredentialValidator @Inject constructor() : ImportedCredentialValidator { - override fun isValid(loginCredentials: LoginCredentials): Boolean { + override fun isValid(loginCredentials: GoogleCsvLoginCredential): Boolean { with(loginCredentials) { - if (domain?.startsWith(APP_PASSWORD_PREFIX) == true) return false + if (url?.startsWith(APP_PASSWORD_PREFIX) == true) { + return false + } if (allFieldsEmpty()) { return false @@ -40,11 +41,11 @@ class DefaultImportedCredentialValidator @Inject constructor() : ImportedCredent } } - private fun LoginCredentials.allFieldsEmpty(): Boolean { - return domain.isNullOrBlank() && + private fun GoogleCsvLoginCredential.allFieldsEmpty(): Boolean { + return url.isNullOrBlank() && username.isNullOrBlank() && password.isNullOrBlank() && - domainTitle.isNullOrBlank() && + title.isNullOrBlank() && notes.isNullOrBlank() } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/ImportGooglePasswordBlobConsumer.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/ImportGooglePasswordBlobConsumer.kt new file mode 100644 index 000000000000..6efade960643 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/ImportGooglePasswordBlobConsumer.kt @@ -0,0 +1,99 @@ +/* + * 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.blob + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.impl.importing.blob.GooglePasswordBlobConsumer.Callback +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.encode + +interface GooglePasswordBlobConsumer { + suspend fun configureWebViewForBlobDownload( + webView: WebView, + callback: Callback, + ) + + suspend fun postMessageToConvertBlobToDataUri(url: String) + + interface Callback { + suspend fun onCsvAvailable(csv: String) + suspend fun onCsvError() + } +} + +@ContributesBinding(FragmentScope::class) +class ImportGooglePasswordBlobConsumer @Inject constructor( + private val webViewBlobDownloader: WebViewBlobDownloader, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : GooglePasswordBlobConsumer { + + // access to the flow which uses this be guarded against where these features aren't available + @SuppressLint("RequiresFeature", "AddWebMessageListenerUsage") + override suspend fun configureWebViewForBlobDownload( + webView: WebView, + callback: Callback, + ) { + withContext(dispatchers.main()) { + webViewBlobDownloader.addBlobDownloadSupport(webView) + + WebViewCompat.addWebMessageListener( + webView, + "ddgBlobDownloadObj", + setOf("*"), + ) { _, message, sourceOrigin, _, replyProxy -> + val data = message.data ?: return@addWebMessageListener + appCoroutineScope.launch(dispatchers.io()) { + processReceivedWebMessage(data, message, sourceOrigin, replyProxy, callback) + } + } + } + } + + private suspend fun processReceivedWebMessage( + data: String, + message: WebMessageCompat, + sourceOrigin: Uri, + replyProxy: JavaScriptReplyProxy, + callback: Callback, + ) { + if (data.startsWith("data:")) { + kotlin.runCatching { + callback.onCsvAvailable(data) + }.onFailure { callback.onCsvError() } + } else if (message.data?.startsWith("Ping:") == true) { + val locationRef = message.data.toString().encode().md5().toString() + webViewBlobDownloader.storeReplyProxy(sourceOrigin.toString(), replyProxy, locationRef) + } + } + + override suspend fun postMessageToConvertBlobToDataUri(url: String) { + webViewBlobDownloader.convertBlobToDataUri(url) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/WebViewBlobDownloader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/WebViewBlobDownloader.kt new file mode 100644 index 000000000000..df019cc808d1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/WebViewBlobDownloader.kt @@ -0,0 +1,171 @@ +/* + * 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.blob + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebViewCompat +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext + +/** + * This interface provides the ability to add modern blob download support to a WebView. + */ +interface WebViewBlobDownloader { + + /** + * Configures a web view to support blob downloads, including in iframes. + */ + suspend fun addBlobDownloadSupport(webView: WebView) + + /** + * Requests the WebView to convert a blob URL to a data URI. + */ + suspend fun convertBlobToDataUri(blobUrl: String) + + /** + * Stores a reply proxy for a given location. + */ + suspend fun storeReplyProxy( + originUrl: String, + replyProxy: JavaScriptReplyProxy, + locationHref: String?, + ) + + /** + * Clears any stored JavaScript reply proxies. + */ + fun clearReplyProxies() +} + +@ContributesBinding(FragmentScope::class) +class WebViewBlobDownloaderModernImpl @Inject constructor( + private val webViewCapabilityChecker: WebViewCapabilityChecker, + private val dispatchers: DispatcherProvider, +) : WebViewBlobDownloader { + + private val fixedReplyProxyMap = mutableMapOf>() + + @SuppressLint("RequiresFeature") + override suspend fun addBlobDownloadSupport(webView: WebView) { + withContext(dispatchers.main()) { + if (isBlobDownloadWebViewFeatureEnabled()) { + WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + } + } + } + + @SuppressLint("RequiresFeature") + override suspend fun convertBlobToDataUri(blobUrl: String) { + withContext(dispatchers.io()) { + for ((key, proxies) in fixedReplyProxyMap) { + if (sameOrigin(blobUrl.removePrefix("blob:"), key)) { + withContext(dispatchers.main()) { + for (replyProxy in proxies.values) { + replyProxy.postMessage(blobUrl) + } + } + return@withContext + } + } + } + } + + override suspend fun storeReplyProxy( + originUrl: String, + replyProxy: JavaScriptReplyProxy, + locationHref: String?, + ) { + val frameProxies = fixedReplyProxyMap[originUrl]?.toMutableMap() ?: mutableMapOf() + // if location.href is not passed, we fall back to origin + val safeLocationHref = locationHref ?: originUrl + frameProxies[safeLocationHref] = replyProxy + fixedReplyProxyMap[originUrl] = frameProxies + } + + private fun sameOrigin( + firstUrl: String, + secondUrl: String, + ): Boolean { + return kotlin.runCatching { + val firstUri = Uri.parse(firstUrl) + val secondUri = Uri.parse(secondUrl) + + firstUri.host == secondUri.host && firstUri.scheme == secondUri.scheme && firstUri.port == secondUri.port + }.getOrNull() ?: return false + } + + override fun clearReplyProxies() { + fixedReplyProxyMap.clear() + } + + private suspend fun isBlobDownloadWebViewFeatureEnabled(): Boolean { + return webViewCapabilityChecker.isSupported(WebViewCapability.WebMessageListener) && + webViewCapabilityChecker.isSupported(WebViewCapability.DocumentStartJavaScript) + } + + companion object { + private val script = """ + window.__url_to_blob_collection = {}; + + const original_createObjectURL = URL.createObjectURL; + + URL.createObjectURL = function () { + const blob = arguments[0]; + const url = original_createObjectURL.call(this, ...arguments); + if (blob instanceof Blob) { + __url_to_blob_collection[url] = blob; + } + return url; + } + + function blobToBase64DataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = function() { + resolve(reader.result); + } + reader.onerror = function() { + reject(new Error('Failed to read Blob object')); + } + reader.readAsDataURL(blob); + }); + } + + const pingMessage = 'Ping:' + window.location.href + ddgBlobDownloadObj.postMessage(pingMessage) + + ddgBlobDownloadObj.onmessage = function(event) { + if (event.data.startsWith('blob:')) { + const blob = window.__url_to_blob_collection[event.data]; + if (blob) { + blobToBase64DataUrl(blob).then((dataUrl) => { + ddgBlobDownloadObj.postMessage(dataUrl); + }); + } + } + } + """.trimIndent() + } +} 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 index 48039ef97554..60956ec71787 100644 --- 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 @@ -33,7 +33,14 @@ interface AutofillImportPasswordConfigStore { data class AutofillImportPasswordSettings( val canImportFromGooglePasswords: Boolean, val launchUrlGooglePasswords: String, + val canInjectJavascript: Boolean, val javascriptConfigGooglePasswords: String, + val urlMappings: List, +) + +data class UrlMapping( + val key: String, + val url: String, ) @ContributesBinding(AppScope::class) @@ -43,8 +50,8 @@ class AutofillImportPasswordConfigStoreImpl @Inject constructor( private val moshi: Moshi, ) : AutofillImportPasswordConfigStore { - private val jsonAdapter: JsonAdapter by lazy { - moshi.adapter(CanImportFromGooglePasswordManagerConfig::class.java) + private val jsonAdapter: JsonAdapter by lazy { + moshi.adapter(ImportConfigJson::class.java) } override suspend fun getConfig(): AutofillImportPasswordSettings { @@ -54,24 +61,48 @@ class AutofillImportPasswordConfigStoreImpl @Inject constructor( 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, + launchUrlGooglePasswords = config?.launchUrl ?: LAUNCH_URL_DEFAULT, + canInjectJavascript = config?.canInjectJavascript ?: CAN_INJECT_JAVASCRIPT_DEFAULT, + javascriptConfigGooglePasswords = config?.javascriptConfig?.toString() ?: JAVASCRIPT_CONFIG_DEFAULT, + urlMappings = config?.urlMappings.convertFromJsonModel(), ) } } companion object { internal const val JAVASCRIPT_CONFIG_DEFAULT = "\"{}\"" + internal const val CAN_INJECT_JAVASCRIPT_DEFAULT = true + internal const val LAUNCH_URL_DEFAULT = "https://passwords.google.com/options?ep=1" + + // order is important; first match wins so keep the most specific to start of the list + internal val URL_MAPPINGS_DEFAULT = listOf( + UrlMapping(key = "webflow-passphrase-encryption", url = "https://passwords.google.com/error/sync-passphrase"), + UrlMapping(key = "webflow-pre-login", url = "https://passwords.google.com/intro"), + UrlMapping(key = "webflow-export", url = "https://passwords.google.com/options?ep=1"), + UrlMapping(key = "webflow-authenticate", url = "https://accounts.google.com/"), + UrlMapping(key = "webflow-post-login-landing", url = "https://passwords.google.com"), + ) } - private data class CanImportFromGooglePasswordManagerConfig( + private data class ImportConfigJson( val launchUrl: String? = null, + val canInjectJavascript: Boolean = CAN_INJECT_JAVASCRIPT_DEFAULT, val javascriptConfig: JSONObject? = null, + val urlMappings: List? = null, ) + + private data class UrlMappingJson( + val key: String, + val url: String, + ) + + private fun List?.convertFromJsonModel(): List { + return this?.let { jsonList -> + jsonList.map { UrlMapping(key = it.key, url = it.url) } + } ?: URL_MAPPINGS_DEFAULT + } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt new file mode 100644 index 000000000000..ed5ee83bc693 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt @@ -0,0 +1,38 @@ +/* + * 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.webflow + +import android.os.Parcelable +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason +import kotlinx.parcelize.Parcelize + +sealed interface ImportGooglePasswordResult : Parcelable { + + @Parcelize + data object Success : ImportGooglePasswordResult + + @Parcelize + data class UserCancelled(val stage: String) : ImportGooglePasswordResult + + @Parcelize + data class Error(val reason: UserCannotImportReason) : ImportGooglePasswordResult + + companion object { + const val RESULT_KEY = "importResult" + const val RESULT_KEY_DETAILS = "importResultDetails" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapper.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapper.kt new file mode 100644 index 000000000000..99b0a5f1d625 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapper.kt @@ -0,0 +1,43 @@ +/* + * 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.webflow + +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import timber.log.Timber + +interface ImportGooglePasswordUrlToStageMapper { + suspend fun getStage(url: String?): String +} + +@ContributesBinding(FragmentScope::class) +class ImportGooglePasswordUrlToStageMapperImpl @Inject constructor( + private val importPasswordConfigStore: AutofillImportPasswordConfigStore, +) : ImportGooglePasswordUrlToStageMapper { + + override suspend fun getStage(url: String?): String { + val config = importPasswordConfigStore.getConfig() + val stage = config.urlMappings.firstOrNull { url?.startsWith(it.url) == true }?.key ?: UNKNOWN + return stage.also { Timber.d("Mapped as stage $it for $url") } + } + + companion object { + const val UNKNOWN = "webflow-unknown" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt new file mode 100644 index 000000000000..97838bcddec1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 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.webflow + +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.commit +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.ActivityImportGooglePasswordsWebflowBinding +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY_DETAILS +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.UserCancelled +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(AutofillImportViaGooglePasswordManagerScreen::class) +class ImportGooglePasswordsWebFlowActivity : DuckDuckGoActivity() { + + val binding: ActivityImportGooglePasswordsWebflowBinding by viewBinding() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + configureResultListeners() + launchImportFragment() + } + + private fun launchImportFragment() { + supportFragmentManager.commit { + replace(R.id.fragment_container, ImportGooglePasswordsWebFlowFragment()) + } + } + + private fun configureResultListeners() { + supportFragmentManager.setFragmentResultListener(RESULT_KEY, this) { _, result -> + exitWithResult(result) + } + } + + private fun exitWithResult(resultBundle: Bundle) { + setResult(RESULT_OK, Intent().putExtras(resultBundle)) + finish() + } + + fun exitUserCancelled(stage: String) { + val result = Bundle().apply { + putParcelable(RESULT_KEY_DETAILS, UserCancelled(stage)) + } + exitWithResult(result) + } +} + +object ImportGooglePassword { + data object AutofillImportViaGooglePasswordManagerScreen : ActivityParams { + private fun readResolve(): Any = AutofillImportViaGooglePasswordManagerScreen + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt new file mode 100644 index 000000000000..7ccae5a67eb6 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt @@ -0,0 +1,357 @@ +/* + * 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.webflow + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.webkit.WebViewCompat +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillCapabilityChecker +import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin +import com.duckduckgo.autofill.api.BrowserAutofill +import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.FragmentImportGooglePasswordsWebflowBinding +import com.duckduckgo.autofill.impl.importing.blob.GooglePasswordBlobConsumer +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY_DETAILS +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.Initializing +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.LoadStartPage +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.NavigatingBack +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedCannotImport +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedImportFlow +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.WebContentShowing +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowWebViewClient.NewPageCallback +import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillCallback +import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillEventListener +import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionInContextSignupFlowListener +import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionUserPromptListener +import com.duckduckgo.common.ui.DuckDuckGoFragment +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.FragmentViewModelFactory +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.user.agent.api.UserAgentProvider +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +@InjectWith(FragmentScope::class) +class ImportGooglePasswordsWebFlowFragment : + DuckDuckGoFragment(R.layout.fragment_import_google_passwords_webflow), + NewPageCallback, + NoOpAutofillCallback, + NoOpEmailProtectionInContextSignupFlowListener, + NoOpEmailProtectionUserPromptListener, + NoOpAutofillEventListener, + GooglePasswordBlobConsumer.Callback { + + @Inject + lateinit var userAgentProvider: UserAgentProvider + + @Inject + lateinit var dispatchers: DispatcherProvider + + @Inject + lateinit var pixel: Pixel + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + @Inject + lateinit var autofillCapabilityChecker: AutofillCapabilityChecker + + @Inject + lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory + + @Inject + lateinit var browserAutofill: BrowserAutofill + + @Inject + lateinit var autofillFragmentResultListeners: PluginPoint + + @Inject + lateinit var passwordBlobConsumer: GooglePasswordBlobConsumer + + @Inject + lateinit var passwordImporterScriptLoader: PasswordImporterScriptLoader + + @Inject + lateinit var browserAutofillConfigurator: BrowserAutofill.Configurator + + @Inject + lateinit var importPasswordConfig: AutofillImportPasswordConfigStore + + private var binding: FragmentImportGooglePasswordsWebflowBinding? = null + + private val viewModel by lazy { + ViewModelProvider(requireActivity(), viewModelFactory)[ImportGooglePasswordsWebFlowViewModel::class.java] + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + binding = FragmentImportGooglePasswordsWebflowBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + initialiseToolbar() + configureWebView() + configureBackButtonHandler() + observeViewState() + viewModel.onViewCreated() + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun loadFirstWebpage(url: String) { + lifecycleScope.launch(dispatchers.main()) { + binding?.webView?.let { + it.loadUrl(url) + viewModel.firstPageLoading() + } + } + } + + private fun observeViewState() { + viewModel.viewState + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { viewState -> + when (viewState) { + is UserFinishedImportFlow -> exitFlowAsSuccess() + is UserCancelledImportFlow -> exitFlowAsCancellation(viewState.stage) + is UserFinishedCannotImport -> exitFlowAsImpossibleToImport(viewState.reason) + is NavigatingBack -> binding?.webView?.goBack() + is LoadStartPage -> loadFirstWebpage(viewState.initialLaunchUrl) + is WebContentShowing, Initializing -> { + // no-op + } + } + } + .launchIn(lifecycleScope) + } + + private fun exitFlowAsCancellation(stage: String) { + (activity as ImportGooglePasswordsWebFlowActivity).exitUserCancelled(stage) + } + + private fun exitFlowAsSuccess() { + val resultBundle = Bundle().also { + it.putParcelable(RESULT_KEY_DETAILS, ImportGooglePasswordResult.Success) + } + setFragmentResult(RESULT_KEY, resultBundle) + } + + private fun exitFlowAsImpossibleToImport(reason: UserCannotImportReason) { + val resultBundle = Bundle().also { + it.putParcelable(RESULT_KEY_DETAILS, ImportGooglePasswordResult.Error(reason)) + } + setFragmentResult(RESULT_KEY, resultBundle) + } + + private fun configureBackButtonHandler() { + activity?.let { + it.onBackPressedDispatcher.addCallback( + it, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + viewModel.onBackButtonPressed(url = binding?.webView?.url, canGoBack = binding?.webView?.canGoBack() ?: false) + } + }, + ) + } + } + + private fun initialiseToolbar() { + with(getToolbar()) { + title = getString(R.string.autofillImportGooglePasswordsWebFlowTitle) + setNavigationIconAsCross() + setNavigationOnClickListener { viewModel.onCloseButtonPressed(binding?.webView?.url) } + } + } + + private fun Toolbar.setNavigationIconAsCross() { + setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun configureWebView() { + binding?.webView?.let { webView -> + webView.webViewClient = ImportGooglePasswordsWebFlowWebViewClient(this) + + webView.settings.apply { + userAgentString = userAgentProvider.userAgent() + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + setSupportMultipleWindows(true) + databaseEnabled = false + setSupportZoom(true) + } + + configureDownloadInterceptor(webView) + configureAutofill(webView) + + lifecycleScope.launch { + passwordBlobConsumer.configureWebViewForBlobDownload(webView, this@ImportGooglePasswordsWebFlowFragment) + configurePasswordImportJavascript(webView) + } + } + } + + private fun configureAutofill(it: WebView) { + lifecycleScope.launch { + browserAutofill.addJsInterface( + it, + this@ImportGooglePasswordsWebFlowFragment, + this@ImportGooglePasswordsWebFlowFragment, + this@ImportGooglePasswordsWebFlowFragment, + CUSTOM_FLOW_TAB_ID, + ) + } + + autofillFragmentResultListeners.getPlugins().forEach { plugin -> + setFragmentResultListener(plugin.resultKey(CUSTOM_FLOW_TAB_ID)) { _, result -> + context?.let { ctx -> + plugin.processResult( + result = result, + context = ctx, + tabId = CUSTOM_FLOW_TAB_ID, + fragment = this@ImportGooglePasswordsWebFlowFragment, + autofillCallback = this@ImportGooglePasswordsWebFlowFragment, + ) + } + } + } + } + + private fun configureDownloadInterceptor(it: WebView) { + it.setDownloadListener { url, _, _, _, _ -> + if (url.startsWith("blob:")) { + lifecycleScope.launch { + passwordBlobConsumer.postMessageToConvertBlobToDataUri(url) + } + } + } + } + + @SuppressLint("RequiresFeature") + private suspend fun configurePasswordImportJavascript(webView: WebView) { + if (importPasswordConfig.getConfig().canInjectJavascript) { + val script = passwordImporterScriptLoader.getScript() + WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + } + } + + private fun getToolbar() = (activity as ImportGooglePasswordsWebFlowActivity).binding.includeToolbar.toolbar + + override fun onPageStarted(url: String?) { + binding?.let { + browserAutofillConfigurator.configureAutofillForCurrentPage(it.webView, url) + } + } + + override suspend fun onCredentialsAvailableToInject( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + ) { + withContext(dispatchers.main()) { + val url = binding?.webView?.url ?: return@withContext + if (url != originalUrl) { + Timber.w("WebView url has changed since autofill request; bailing") + return@withContext + } + + val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog( + url, + credentials, + triggerType, + CUSTOM_FLOW_TAB_ID, + ) + dialog.show(childFragmentManager, SELECT_CREDENTIALS_FRAGMENT_TAG) + } + } + + override suspend fun onCsvAvailable(csv: String) { + viewModel.onCsvAvailable(csv) + } + + override suspend fun onCsvError() { + viewModel.onCsvError() + } + + override fun onShareCredentialsForAutofill( + originalUrl: String, + selectedCredentials: LoginCredentials, + ) { + if (binding?.webView?.url != originalUrl) { + Timber.w("WebView url has changed since autofill request; bailing") + return + } + browserAutofill.injectCredentials(selectedCredentials) + } + + override fun onNoCredentialsChosenForAutofill(originalUrl: String) { + if (binding?.webView?.url != originalUrl) { + Timber.w("WebView url has changed since autofill request; bailing") + return + } + browserAutofill.injectCredentials(null) + } + + companion object { + private const val CUSTOM_FLOW_TAB_ID = "import-passwords-webflow" + private const val SELECT_CREDENTIALS_FRAGMENT_TAG = "autofillSelectCredentialsDialog" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt new file mode 100644 index 000000000000..88074d142cad --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2023 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.webflow + +import android.os.Parcelable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.autofill.impl.importing.CredentialImporter +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.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.ErrorParsingCsv +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.Initializing +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesViewModel(FragmentScope::class) +class ImportGooglePasswordsWebFlowViewModel @Inject constructor( + private val dispatchers: DispatcherProvider, + private val credentialImporter: CredentialImporter, + private val csvCredentialConverter: CsvCredentialConverter, + private val autofillImportConfigStore: AutofillImportPasswordConfigStore, + private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper, +) : ViewModel() { + + private val _viewState = MutableStateFlow(Initializing) + val viewState: StateFlow = _viewState + + fun onViewCreated() { + viewModelScope.launch(dispatchers.io()) { + _viewState.value = ViewState.LoadStartPage(autofillImportConfigStore.getConfig().launchUrlGooglePasswords) + } + } + + suspend fun onCsvAvailable(csv: String) { + when (val parseResult = csvCredentialConverter.readCsv(csv)) { + is CsvCredentialImportResult.Success -> onCsvParsed(parseResult) + is CsvCredentialImportResult.Error -> onCsvError() + } + } + + private suspend fun onCsvParsed(parseResult: CsvCredentialImportResult.Success) { + credentialImporter.import(parseResult.loginCredentialsToImport, parseResult.numberCredentialsInSource) + _viewState.value = ViewState.UserFinishedImportFlow + } + + fun onCsvError() { + Timber.w("Error decoding CSV") + _viewState.value = ViewState.UserFinishedCannotImport(ErrorParsingCsv) + } + + fun onCloseButtonPressed(url: String?) { + terminateFlowAsCancellation(url ?: "unknown") + } + + fun onBackButtonPressed( + url: String?, + canGoBack: Boolean, + ) { + // if WebView can't go back, then we're at the first stage or something's gone wrong. Either way, time to cancel out of the screen. + if (!canGoBack) { + terminateFlowAsCancellation(url ?: "unknown") + return + } + + _viewState.value = ViewState.NavigatingBack + } + + private fun terminateFlowAsCancellation(url: String) { + viewModelScope.launch { + _viewState.value = UserCancelledImportFlow(urlToStageMapper.getStage(url)) + } + } + + fun firstPageLoading() { + _viewState.value = ViewState.WebContentShowing + } + + sealed interface ViewState { + data object Initializing : ViewState + data object WebContentShowing : ViewState + data class LoadStartPage(val initialLaunchUrl: String) : ViewState + data class UserCancelledImportFlow(val stage: String) : ViewState + data object UserFinishedImportFlow : ViewState + data class UserFinishedCannotImport(val reason: UserCannotImportReason) : ViewState + data object NavigatingBack : ViewState + } + + sealed interface UserCannotImportReason : Parcelable { + @Parcelize + data object ErrorParsingCsv : UserCannotImportReason + } + + sealed interface BackButtonAction { + data object NavigateBack : BackButtonAction + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt new file mode 100644 index 000000000000..98d558a4d3a1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 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.webflow + +import android.graphics.Bitmap +import android.webkit.WebView +import android.webkit.WebViewClient +import javax.inject.Inject + +class ImportGooglePasswordsWebFlowWebViewClient @Inject constructor( + private val callback: NewPageCallback, +) : WebViewClient() { + + interface NewPageCallback { + fun onPageStarted(url: String?) {} + fun onPageFinished(url: String?) {} + } + + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap?, + ) { + callback.onPageStarted(url) + } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + callback.onPageFinished(url) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt new file mode 100644 index 000000000000..be04d3ffa89e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt @@ -0,0 +1,103 @@ +/* + * 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.webflow + +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import java.io.BufferedReader +import javax.inject.Inject +import kotlinx.coroutines.withContext + +interface PasswordImporterScriptLoader { + suspend fun getScript(): String +} + +@ContributesBinding(FragmentScope::class) +class PasswordImporterCssScriptLoader @Inject constructor( + private val dispatchers: DispatcherProvider, + private val configStore: AutofillImportPasswordConfigStore, +) : PasswordImporterScriptLoader { + + private lateinit var contentScopeJS: String + + override suspend fun getScript(): String { + return withContext(dispatchers.io()) { + getContentScopeJS() + .replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson(loadSettingsJson())) + .replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson()) + .replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson()) + } + } + + /** + * This enables the password import hints feature in C-S-S. + * These settings are for enabling it; the check for whether it should be enabled or not is done elsewhere. + */ + private fun getContentScopeJson(settingsJson: String): String { + return """{ + "features":{ + "autofillPasswordImport" : { + "state": "enabled", + "exceptions": [], + "settings": $settingsJson + } + }, + "unprotectedTemporary":[] + } + + """.trimMargin() + } + + private suspend fun loadSettingsJson(): String { + return configStore.getConfig().javascriptConfigGooglePasswords + } + + private fun getUserPreferencesJson(): String { + return """ + { + "platform":{ + "name":"android" + }, + "messageCallback": '', + "javascriptInterface": '' + } + """.trimMargin() + } + + private fun getUnprotectedDomainsJson(): String = "[]" + + private fun getContentScopeJS(): String { + if (!this::contentScopeJS.isInitialized) { + contentScopeJS = loadJs("autofillPasswordImport.js") + } + return contentScopeJS + } + + companion object { + private const val CONTENT_SCOPE_PLACEHOLDER = "\$CONTENT_SCOPE$" + private const val USER_UNPROTECTED_DOMAINS_PLACEHOLDER = "\$USER_UNPROTECTED_DOMAINS$" + private const val USER_PREFERENCES_PLACEHOLDER = "\$USER_PREFERENCES$" + } + + private fun loadJs(resourceName: String): String = readResource(resourceName).use { it?.readText() }.orEmpty() + + private fun readResource(resourceName: String): BufferedReader? { + return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt new file mode 100644 index 000000000000..8c9a35f141f7 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt @@ -0,0 +1,109 @@ +/* + * 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.webflow.autofill + +import com.duckduckgo.autofill.api.AutofillEventListener +import com.duckduckgo.autofill.api.Callback +import com.duckduckgo.autofill.api.EmailProtectionInContextSignupFlowListener +import com.duckduckgo.autofill.api.EmailProtectionUserPromptListener +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType + +interface NoOpAutofillCallback : Callback { + override suspend fun onCredentialsAvailableToInject( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + ) { + } + + override suspend fun onCredentialsAvailableToSave( + currentUrl: String, + credentials: LoginCredentials, + ) { + } + + override suspend fun onGeneratedPasswordAvailableToUse( + originalUrl: String, + username: String?, + generatedPassword: String, + ) { + } + + override fun noCredentialsAvailable(originalUrl: String) { + } + + override fun onCredentialsSaved(savedCredentials: LoginCredentials) { + } +} + +interface NoOpAutofillEventListener : AutofillEventListener { + override fun onAcceptGeneratedPassword(originalUrl: String) { + } + + override fun onRejectGeneratedPassword(originalUrl: String) { + } + + override fun onUseEmailProtectionPersonalAddress( + originalUrl: String, + duckAddress: String, + ) { + } + + override fun onUseEmailProtectionPrivateAlias( + originalUrl: String, + duckAddress: String, + ) { + } + + override fun onSelectedToSignUpForInContextEmailProtection() { + } + + override fun onEndOfEmailProtectionInContextSignupFlow() { + } + + override fun onShareCredentialsForAutofill( + originalUrl: String, + selectedCredentials: LoginCredentials, + ) { + } + + override fun onNoCredentialsChosenForAutofill(originalUrl: String) { + } + + override fun onSavedCredentials(credentials: LoginCredentials) { + } + + override fun onUpdatedCredentials(credentials: LoginCredentials) { + } + + override fun onAutofillStateChange() { + } +} + +interface NoOpEmailProtectionInContextSignupFlowListener : EmailProtectionInContextSignupFlowListener { + override fun closeInContextSignup() { + } +} + +interface NoOpEmailProtectionUserPromptListener : EmailProtectionUserPromptListener { + override fun showNativeInContextEmailProtectionSignupPrompt() { + } + + override fun showNativeChooseEmailAddressPrompt() { + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt index 9b46fa6d9b86..fbe453193599 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt @@ -24,9 +24,7 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_ONBOARDED_USER import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_STACKED_LOGINS import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_COPIED_DESKTOP_LINK -import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER -import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_SHARED_DESKTOP_LINK import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_USER_JOURNEY_RESTARTED @@ -43,6 +41,8 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAK import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_CONFIRMED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISPLAYED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_TOOLTIP_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ADDRESS import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ALIAS @@ -142,8 +142,17 @@ enum class AutofillPixelNames(override val pixelName: String) : Pixel.PixelName AUTOFILL_TOGGLED_ON_SEARCH("m_autofill_toggled_on"), AUTOFILL_TOGGLED_OFF_SEARCH("m_autofill_toggled_off"), - AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON("m_autofill_logins_import_no_passwords"), - AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU("m_autofill_logins_import"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED("autofill_import_google_passwords_import_button_tapped"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN("autofill_import_google_passwords_import_button_shown"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_OVERFLOW_MENU("autofill_import_google_passwords_overflow_menu_tapped"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_DISPLAYED("autofill_import_google_passwords_preimport_prompt_displayed"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_CONFIRMED("autofill_import_google_passwords_preimport_prompt_confirmed"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_ERROR_PARSING("autofill_import_google_passwords_result_parsing"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED("autofill_import_google_passwords_result_user_cancelled"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_SUCCESS("autofill_import_google_passwords_result_success"), + + AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON("m_autofill_logins_import_no_passwords"), + AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU("m_autofill_logins_import"), AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER("m_autofill_logins_import_get_desktop"), AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP("m_autofill_logins_import_sync"), AUTOFILL_IMPORT_PASSWORDS_USER_TOOK_NO_ACTION("m_autofill_logins_import_no-action"), @@ -177,8 +186,8 @@ object AutofillPixelsRequiringDataCleaning : PixelParamRemovalPlugin { AUTOFILL_ENGAGEMENT_ONBOARDED_USER.pixelName to PixelParameter.removeAtb(), AUTOFILL_ENGAGEMENT_STACKED_LOGINS.pixelName to PixelParameter.removeAtb(), - AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON.pixelName to PixelParameter.removeAtb(), - AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU.pixelName to PixelParameter.removeAtb(), + AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON.pixelName to PixelParameter.removeAtb(), + AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU.pixelName to PixelParameter.removeAtb(), AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER.pixelName to PixelParameter.removeAtb(), AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP.pixelName to PixelParameter.removeAtb(), AUTOFILL_IMPORT_PASSWORDS_USER_TOOK_NO_ACTION.pixelName to PixelParameter.removeAtb(), diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt index 151aed68cee7..4f7466709cd6 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt @@ -20,8 +20,12 @@ import android.util.Patterns import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DocumentStartJavaScript +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.WebMessageListener import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserOverflow import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserSnackbar @@ -40,6 +44,7 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_DELETE_LOGIN import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_DISABLED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_ENABLED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_MANAGEMENT_SCREEN_OPENED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_MANUALLY_SAVE_CREDENTIAL import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_CONFIRMATION_PROMPT_CONFIRMED @@ -84,7 +89,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsVie import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.DuckAddressStatus.NotManageable import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.DuckAddressStatus.SettingActivationStatus import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchDeleteAllPasswordsConfirmation -import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswords +import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswordsFromGooglePasswordManager import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchReportAutofillBreakageConfirmation import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchResetNeverSaveListConfirmation import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.PromptUserToAuthenticateMassDeletion @@ -133,6 +138,8 @@ class AutofillSettingsViewModel @Inject constructor( private val autofillBreakageReportSender: AutofillBreakageReportSender, private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore, private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules, + private val autofillFeature: AutofillFeature, + private val webViewCapabilityChecker: WebViewCapabilityChecker, ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState()) @@ -160,6 +167,9 @@ class AutofillSettingsViewModel @Inject constructor( private var combineJob: Job? = null + // we only want to send this once for this 'session' of being in the management screen + private var importGooglePasswordButtonShownPixelSent = false + fun onCopyUsername(username: String?) { username?.let { clipboardInteractor.copyToClipboard(it, isSensitive = false) } pixel.fire(AutofillPixelNames.AUTOFILL_COPY_USERNAME) @@ -431,6 +441,14 @@ class AutofillSettingsViewModel @Inject constructor( _neverSavedSitesViewState.value = NeverSavedSitesViewState(showOptionToReset = count > 0) } } + + viewModelScope.launch(dispatchers.io()) { + val gpmImport = autofillFeature.self().isEnabled() && autofillFeature.canImportFromGooglePasswordManager().isEnabled() + val webViewWebMessageSupport = webViewCapabilityChecker.isSupported(WebMessageListener) + val webViewDocumentStartJavascript = webViewCapabilityChecker.isSupported(DocumentStartJavaScript) + val canImport = gpmImport && webViewWebMessageSupport && webViewDocumentStartJavascript + _viewState.value = _viewState.value.copy(canImportFromGooglePasswords = canImport) + } } private suspend fun isBreakageReportingAllowed(): Boolean { @@ -689,8 +707,10 @@ class AutofillSettingsViewModel @Inject constructor( } } - fun onImportPasswords() { - addCommand(LaunchImportPasswords) + fun onImportPasswordsFromGooglePasswordManager() { + viewModelScope.launch(dispatchers.io()) { + addCommand(LaunchImportPasswordsFromGooglePasswordManager) + } } fun onReportBreakageClicked() { @@ -702,7 +722,10 @@ class AutofillSettingsViewModel @Inject constructor( } } - fun updateCurrentSite(currentUrl: String?, privacyProtectionEnabled: Boolean?) { + fun updateCurrentSite( + currentUrl: String?, + privacyProtectionEnabled: Boolean?, + ) { val updatedReportBreakageState = _viewState.value.reportBreakageState.copy( currentUrl = currentUrl, privacyProtectionEnabled = privacyProtectionEnabled, @@ -771,6 +794,13 @@ class AutofillSettingsViewModel @Inject constructor( } } + fun recordImportGooglePasswordButtonShown() { + if (!importGooglePasswordButtonShownPixelSent) { + importGooglePasswordButtonShownPixelSent = true + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN) + } + } + data class ViewState( val autofillEnabled: Boolean = true, val showAutofillEnabledToggle: Boolean = true, @@ -779,6 +809,7 @@ class AutofillSettingsViewModel @Inject constructor( val credentialSearchQuery: String = "", val reportBreakageState: ReportBreakageState = ReportBreakageState(), val canShowPromo: Boolean = false, + val canImportFromGooglePasswords: Boolean = false, ) data class ReportBreakageState( @@ -854,7 +885,7 @@ class AutofillSettingsViewModel @Inject constructor( data object LaunchResetNeverSaveListConfirmation : ListModeCommand() data class LaunchDeleteAllPasswordsConfirmation(val numberToDelete: Int) : ListModeCommand() data class PromptUserToAuthenticateMassDeletion(val authConfiguration: AuthConfiguration) : ListModeCommand() - data object LaunchImportPasswords : ListModeCommand() + data object LaunchImportPasswordsFromGooglePasswordManager : ListModeCommand() data class LaunchReportAutofillBreakageConfirmation(val eTldPlusOne: String) : ListModeCommand() data object ShowUserReportSentMessage : ListModeCommand() data object ReevalutePromotions : ListModeCommand() diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/ImportPasswordsPixelSender.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/ImportPasswordsPixelSender.kt new file mode 100644 index 000000000000..c7f951697e70 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/ImportPasswordsPixelSender.kt @@ -0,0 +1,110 @@ +/* + * 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.ui.credential.management.importpassword + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementBucketing +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.ErrorParsingCsv +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_OVERFLOW_MENU +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_CONFIRMED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_DISPLAYED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_ERROR_PARSING +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_SUCCESS +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface ImportPasswordsPixelSender { + fun onImportPasswordsDialogDisplayed() + fun onImportPasswordsDialogImportButtonClicked() + fun onUserCancelledImportPasswordsDialog() + fun onUserCancelledImportWebFlow(stage: String) + fun onImportSuccessful(savedCredentials: Int, numberSkipped: Int) + fun onImportFailed(reason: UserCannotImportReason) + fun onImportPasswordsButtonTapped() + fun onImportPasswordsOverflowMenuTapped() + fun onImportPasswordsViaDesktopSyncButtonTapped() + fun onImportPasswordsViaDesktopSyncOverflowMenuTapped() +} + +@ContributesBinding(FragmentScope::class) +class ImportPasswordsPixelSenderImpl @Inject constructor( + private val pixel: Pixel, + private val engagementBucketing: AutofillEngagementBucketing, +) : ImportPasswordsPixelSender { + + override fun onImportPasswordsDialogDisplayed() { + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_DISPLAYED) + } + + override fun onImportPasswordsDialogImportButtonClicked() { + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_CONFIRMED) + } + + override fun onUserCancelledImportPasswordsDialog() { + val params = mapOf(CANCELLATION_STAGE_KEY to PRE_IMPORT_DIALOG_STAGE) + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED, params) + } + + override fun onUserCancelledImportWebFlow(stage: String) { + val params = mapOf(CANCELLATION_STAGE_KEY to stage) + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED, params) + } + + override fun onImportSuccessful(savedCredentials: Int, numberSkipped: Int) { + val savedCredentialsBucketed = engagementBucketing.bucketNumberOfCredentials(savedCredentials) + val skippedCredentialsBucketed = engagementBucketing.bucketNumberOfCredentials(numberSkipped) + val params = mapOf( + "saved_credentials" to savedCredentialsBucketed, + "skipped_credentials" to skippedCredentialsBucketed, + ) + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_SUCCESS, params) + } + + override fun onImportFailed(reason: UserCannotImportReason) { + val pixelName = when (reason) { + ErrorParsingCsv -> AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_ERROR_PARSING + } + pixel.fire(pixelName) + } + + override fun onImportPasswordsButtonTapped() { + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED) + } + + override fun onImportPasswordsOverflowMenuTapped() { + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_OVERFLOW_MENU) + } + + override fun onImportPasswordsViaDesktopSyncButtonTapped() { + pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON) + } + + override fun onImportPasswordsViaDesktopSyncOverflowMenuTapped() { + pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU) + } + + companion object { + private const val CANCELLATION_STAGE_KEY = "stage" + private const val PRE_IMPORT_DIALOG_STAGE = "pre-import-dialog" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialog.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialog.kt new file mode 100644 index 000000000000..7b8605dd489f --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialog.kt @@ -0,0 +1,273 @@ +/* + * 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.ui.credential.management.importpassword.google + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.ContentImportFromGooglePasswordDialogBinding +import com.duckduckgo.autofill.impl.deviceauth.AutofillAuthorizationGracePeriod +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePassword +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult +import com.duckduckgo.autofill.impl.ui.credential.dialog.animateClosed +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.ImportError +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.ImportSuccess +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.Importing +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.PreImport +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewState +import com.duckduckgo.common.utils.FragmentViewModelFactory +import com.duckduckgo.common.utils.extensions.html +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.android.support.AndroidSupportInjection +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +@InjectWith(FragmentScope::class) +class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { + + @Inject + lateinit var importPasswordsPixelSender: ImportPasswordsPixelSender + + /** + * To capture all the ways the BottomSheet can be dismissed, we might end up with onCancel being called when we don't want it + * This flag is set to true when taking an action which dismisses the dialog, but should not be treated as a cancellation. + */ + private var ignoreCancellationEvents = false + + override fun getTheme(): Int = R.style.AutofillBottomSheetDialogTheme + + @Inject + lateinit var faviconManager: FaviconManager + + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + @Inject + lateinit var authorizationGracePeriod: AutofillAuthorizationGracePeriod + + private var _binding: ContentImportFromGooglePasswordDialogBinding? = null + + private val binding get() = _binding!! + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + private val viewModel by bindViewModel() + + private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + lifecycleScope.launch { + activityResult.data?.let { data -> + processImportFlowResult(data) + } + } + } + } + + private fun ImportFromGooglePasswordsDialog.processImportFlowResult(data: Intent) { + (IntentCompat.getParcelableExtra(data, ImportGooglePasswordResult.RESULT_KEY_DETAILS, ImportGooglePasswordResult::class.java)).let { + when (it) { + is ImportGooglePasswordResult.Success -> viewModel.onImportFlowFinishedSuccessfully() + is ImportGooglePasswordResult.Error -> viewModel.onImportFlowFinishedWithError(it.reason) + is ImportGooglePasswordResult.UserCancelled -> viewModel.onImportFlowCancelledByUser(it.stage) + else -> {} + } + } + } + + private fun switchDialogShowImportInProgressView() { + binding.prePostViewSwitcher.displayedChild = 1 + binding.postflow.inProgressFinishedViewSwitcher.displayedChild = 0 + } + + private fun switchDialogShowImportResultsView() { + binding.prePostViewSwitcher.displayedChild = 1 + binding.postflow.inProgressFinishedViewSwitcher.displayedChild = 1 + } + + private fun switchDialogShowPreImportView() { + binding.prePostViewSwitcher.displayedChild = 0 + } + + private fun processSuccessResult(result: CredentialImporter.ImportResult.Finished) { + binding.postflow.importFinished.errorNotImported.visibility = View.GONE + binding.postflow.appIcon.setImageDrawable( + ContextCompat.getDrawable( + binding.root.context, + R.drawable.ic_success_128, + ), + ) + binding.postflow.dialogTitle.text = getString(R.string.importPasswordsProcessingResultDialogTitleUponSuccess) + + with(binding.postflow.importFinished.resultsImported) { + val output = getString(R.string.importPasswordsProcessingResultDialogResultPasswordsImported, result.savedCredentials) + setPrimaryText(output.html(binding.root.context)) + } + + with(binding.postflow.importFinished.duplicatesNotImported) { + val output = getString(R.string.importPasswordsProcessingResultDialogResultDuplicatesSkipped, result.numberSkipped) + setPrimaryText(output.html(binding.root.context)) + visibility = if (result.numberSkipped > 0) View.VISIBLE else View.GONE + } + + switchDialogShowImportResultsView() + } + + private fun processErrorResult() { + binding.postflow.importFinished.resultsImported.visibility = View.GONE + binding.postflow.importFinished.duplicatesNotImported.visibility = View.GONE + binding.postflow.importFinished.errorNotImported.visibility = View.VISIBLE + + binding.postflow.dialogTitle.text = getString(R.string.importPasswordsProcessingResultDialogTitleBeforeSuccess) + binding.postflow.appIcon.setImageDrawable( + ContextCompat.getDrawable( + binding.root.context, + R.drawable.ic_passwords_import_128, + ), + ) + + switchDialogShowImportResultsView() + } + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState != null) { + // If being created after a configuration change, dismiss the dialog as the WebView will be re-created too + dismiss() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + importPasswordsPixelSender.onImportPasswordsDialogDisplayed() + + _binding = ContentImportFromGooglePasswordDialogBinding.inflate(inflater, container, false) + configureViews(binding) + observeViewModel() + return binding.root + } + + private fun observeViewModel() { + viewModel.viewState.flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { viewState -> renderViewState(viewState) } + .launchIn(lifecycleScope) + } + + private fun renderViewState(viewState: ViewState) { + when (viewState.viewMode) { + is PreImport -> switchDialogShowPreImportView() + is ImportError -> processErrorResult() + is ImportSuccess -> processSuccessResult(viewState.viewMode.importResult) + is Importing -> switchDialogShowImportInProgressView() + } + } + + override fun onDestroyView() { + _binding = null + authorizationGracePeriod.removeRequestForExtendedGracePeriod() + super.onDestroyView() + } + + private fun configureViews(binding: ContentImportFromGooglePasswordDialogBinding) { + (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED + configureCloseButton(binding) + + with(binding.preflow.importGcmButton) { + setOnClickListener { onImportGcmButtonClicked() } + } + + with(binding.postflow.importFinished.primaryCtaButton) { + setOnClickListener { + dismiss() + } + } + } + + private fun onImportGcmButtonClicked() { + authorizationGracePeriod.requestExtendedGracePeriod() + + val intent = globalActivityStarter.startIntent( + requireContext(), + ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen, + ) + importGooglePasswordsFlowLauncher.launch(intent) + + importPasswordsPixelSender.onImportPasswordsDialogImportButtonClicked() + + // we don't want the eventual dismissal of this dialog to count as a cancellation + ignoreCancellationEvents = true + } + + override fun onCancel(dialog: DialogInterface) { + if (ignoreCancellationEvents) { + Timber.v("onCancel: Ignoring cancellation event") + return + } + + importPasswordsPixelSender.onUserCancelledImportPasswordsDialog() + + dismiss() + } + + private fun configureCloseButton(binding: ContentImportFromGooglePasswordDialogBinding) { + binding.closeButton.setOnClickListener { (dialog as BottomSheetDialog).animateClosed() } + } + + private inline fun bindViewModel() = lazy { ViewModelProvider(this, viewModelFactory)[V::class.java] } + + companion object { + + fun instance(): ImportFromGooglePasswordsDialog { + val fragment = ImportFromGooglePasswordsDialog() + return fragment + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModel.kt new file mode 100644 index 000000000000..c1a35353f0ba --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModel.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.ui.credential.management.importpassword.google + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.Importing +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.PreImport +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +@ContributesViewModel(FragmentScope::class) +class ImportFromGooglePasswordsDialogViewModel @Inject constructor( + private val credentialImporter: CredentialImporter, + private val dispatchers: DispatcherProvider, + private val importPasswordsPixelSender: ImportPasswordsPixelSender, +) : ViewModel() { + + fun onImportFlowFinishedSuccessfully() { + viewModelScope.launch(dispatchers.main()) { + observeImportJob() + } + } + + private suspend fun observeImportJob() { + credentialImporter.getImportStatus().collect { + when (it) { + is ImportResult.InProgress -> { + Timber.d("Import in progress") + _viewState.value = ViewState(viewMode = Importing) + } + + is ImportResult.Finished -> { + Timber.d("Import finished: ${it.savedCredentials} imported. ${it.numberSkipped} skipped.") + fireImportSuccessPixel(savedCredentials = it.savedCredentials, numberSkipped = it.numberSkipped) + _viewState.value = ViewState(viewMode = ViewMode.ImportSuccess(it)) + } + } + } + } + + fun onImportFlowFinishedWithError(reason: UserCannotImportReason) { + fireImportFailedPixel(reason) + _viewState.value = ViewState(viewMode = ViewMode.ImportError) + } + + fun onImportFlowCancelledByUser(stage: String) { + importPasswordsPixelSender.onUserCancelledImportWebFlow(stage) + } + + private fun fireImportSuccessPixel(savedCredentials: Int, numberSkipped: Int) { + importPasswordsPixelSender.onImportSuccessful(savedCredentials = savedCredentials, numberSkipped = numberSkipped) + } + + private fun fireImportFailedPixel(reason: UserCannotImportReason) { + importPasswordsPixelSender.onImportFailed(reason) + } + + private val _viewState = MutableStateFlow(ViewState()) + val viewState: StateFlow = _viewState + + data class ViewState(val viewMode: ViewMode = PreImport) + + sealed interface ViewMode { + data object PreImport : ViewMode + data object Importing : ViewMode + data class ImportSuccess(val importResult: ImportResult.Finished) : ViewMode + data object ImportError : ViewMode + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt index 120d8acbd9a4..8c0b38d0482a 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt @@ -89,7 +89,7 @@ class AutofillSurveyImpl @Inject constructor( .appendQueryParameter(SurveyParams.MODEL, appBuildConfig.model) .appendQueryParameter(SurveyParams.SOURCE, IN_APP) .appendQueryParameter(SurveyParams.LAST_ACTIVE_DATE, appDaysUsedRepository.getLastActiveDay()) - .appendQueryParameter(SurveyParams.NUMBER_PASSWORDS, passwordBucketing.bucketNumberOfSavedPasswords(passwordsSaved)) + .appendQueryParameter(SurveyParams.NUMBER_PASSWORDS, passwordBucketing.bucketNumberOfCredentials(passwordsSaved)) urlBuilder.build().toString() } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt index 620118de9b5e..3f4469ae074b 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt @@ -38,7 +38,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.RecyclerView import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.favicon.FaviconManager -import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin @@ -47,8 +46,6 @@ import com.duckduckgo.autofill.impl.databinding.FragmentAutofillManagementListMo import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthConfiguration import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthResult.Success -import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON -import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementActivity import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementRecyclerAdapter import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementRecyclerAdapter.ContextMenuAction.CopyPassword @@ -57,13 +54,16 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementR import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementRecyclerAdapter.ContextMenuAction.Edit import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchDeleteAllPasswordsConfirmation -import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswords +import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswordsFromGooglePasswordManager import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchReportAutofillBreakageConfirmation import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchResetNeverSaveListConfirmation import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.PromptUserToAuthenticateMassDeletion import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.ReevalutePromotions import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.ShowUserReportSentMessage +import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ViewState import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordActivityParams +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialog import com.duckduckgo.autofill.impl.ui.credential.management.sorting.CredentialGrouper import com.duckduckgo.autofill.impl.ui.credential.management.sorting.InitialExtractor import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionListBuilder @@ -127,7 +127,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill lateinit var screenPromotionPlugins: PluginPoint @Inject - lateinit var pixel: Pixel + lateinit var importPasswordsPixelSender: ImportPasswordsPixelSender val viewModel by lazy { ViewModelProvider(requireActivity(), viewModelFactory)[AutofillSettingsViewModel::class.java] @@ -143,7 +143,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill private var searchMenuItem: MenuItem? = null private var resetNeverSavedSitesMenuItem: MenuItem? = null private var deleteAllPasswordsMenuItem: MenuItem? = null - private var importPasswordsMenuItem: MenuItem? = null + private var syncDesktopPasswordsMenuItem: MenuItem? = null + private var importGooglePasswordsMenuItem: MenuItem? = null private val globalAutofillToggleListener = CompoundButton.OnCheckedChangeListener { _, isChecked -> if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return@OnCheckedChangeListener @@ -241,9 +242,14 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } private fun configureImportPasswordsButton() { - binding.emptyStateLayout.importPasswordsButton.setOnClickListener { - viewModel.onImportPasswords() - pixel.fire(AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON) + binding.emptyStateLayout.importPasswordsFromGoogleButton.setOnClickListener { + viewModel.onImportPasswordsFromGooglePasswordManager() + importPasswordsPixelSender.onImportPasswordsButtonTapped() + } + + binding.emptyStateLayout.importPasswordsViaDesktopSyncButton.setOnClickListener { + launchImportPasswordsFromDesktopSyncScreen() + importPasswordsPixelSender.onImportPasswordsViaDesktopSyncButtonTapped() } } @@ -258,7 +264,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill searchMenuItem = menu.findItem(R.id.searchLogins) resetNeverSavedSitesMenuItem = menu.findItem(R.id.resetNeverSavedSites) deleteAllPasswordsMenuItem = menu.findItem(R.id.deleteAllPasswords) - importPasswordsMenuItem = menu.findItem(R.id.importPasswords) + syncDesktopPasswordsMenuItem = menu.findItem(R.id.syncDesktopPasswords) + importGooglePasswordsMenuItem = menu.findItem(R.id.importGooglePasswords) initializeSearchBar() } @@ -268,7 +275,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill searchMenuItem?.isVisible = loginsSaved deleteAllPasswordsMenuItem?.isVisible = loginsSaved resetNeverSavedSitesMenuItem?.isVisible = viewModel.neverSavedSitesViewState.value.showOptionToReset - importPasswordsMenuItem?.isVisible = loginsSaved + syncDesktopPasswordsMenuItem?.isVisible = loginsSaved + importGooglePasswordsMenuItem?.isVisible = loginsSaved && viewModel.viewState.value.canImportFromGooglePasswords } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { @@ -288,9 +296,15 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill true } - R.id.importPasswords -> { - viewModel.onImportPasswords() - pixel.fire(AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU) + R.id.importGooglePasswords -> { + viewModel.onImportPasswordsFromGooglePasswordManager() + importPasswordsPixelSender.onImportPasswordsOverflowMenuTapped() + true + } + + R.id.syncDesktopPasswords -> { + launchImportPasswordsFromDesktopSyncScreen() + importPasswordsPixelSender.onImportPasswordsViaDesktopSyncOverflowMenuTapped() true } @@ -336,7 +350,12 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill viewModel.viewState.collect { state -> binding.enabledToggle.quietlySetIsChecked(state.autofillEnabled, globalAutofillToggleListener) state.logins?.let { - credentialsListUpdated(it, state.credentialSearchQuery, state.reportBreakageState.allowBreakageReporting) + credentialsListUpdated( + credentials = it, + credentialSearchQuery = state.credentialSearchQuery, + allowBreakageReporting = state.reportBreakageState.allowBreakageReporting, + canShowImportGooglePasswordsButton = state.canImportFromGooglePasswords, + ) parentActivity()?.invalidateOptionsMenu() } @@ -364,9 +383,28 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collect { + // we can just invalidate the menu as [onPrepareMenu] will handle the new visibility for importing passwords menu item + parentActivity()?.invalidateOptionsMenu() + + configureImportPasswordsButtonVisibility(it) + } + } + } + viewModel.onViewCreated() } + private fun configureImportPasswordsButtonVisibility(state: ViewState) { + if (state.canImportFromGooglePasswords) { + binding.emptyStateLayout.importPasswordsFromGoogleButton.show() + } else { + binding.emptyStateLayout.importPasswordsFromGoogleButton.gone() + } + } + private fun observeListModeViewModelCommands() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(State.STARTED) { @@ -382,7 +420,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill LaunchResetNeverSaveListConfirmation -> launchResetNeverSavedSitesConfirmation() is LaunchDeleteAllPasswordsConfirmation -> launchDeleteAllLoginsConfirmationDialog(command.numberToDelete) is PromptUserToAuthenticateMassDeletion -> promptUserToAuthenticateMassDeletion(command.authConfiguration) - is LaunchImportPasswords -> launchImportPasswordsScreen() + is LaunchImportPasswordsFromGooglePasswordManager -> launchImportPasswordsScreen() is LaunchReportAutofillBreakageConfirmation -> launchReportBreakageConfirmation(command.eTldPlusOne) is ShowUserReportSentMessage -> showUserReportSentMessage() is ReevalutePromotions -> configurePromotionsContainer() @@ -395,6 +433,13 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } private fun launchImportPasswordsScreen() { + context?.let { + val dialog = ImportFromGooglePasswordsDialog.instance() + dialog.show(parentFragmentManager, IMPORT_FROM_GPM_DIALOG_TAG) + } + } + + private fun launchImportPasswordsFromDesktopSyncScreen() { context?.let { globalActivityStarter.start(it, ImportPasswordActivityParams) } @@ -438,9 +483,10 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill credentials: List, credentialSearchQuery: String, allowBreakageReporting: Boolean, + canShowImportGooglePasswordsButton: Boolean, ) { if (credentials.isEmpty() && credentialSearchQuery.isEmpty()) { - showEmptyCredentialsPlaceholders() + showEmptyCredentialsPlaceholders(canShowImportGooglePasswordsButton) } else if (credentials.isEmpty()) { showNoResultsPlaceholders(credentialSearchQuery) } else { @@ -454,10 +500,12 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill adapter.showNoMatchingSearchResults(query) } - private fun showEmptyCredentialsPlaceholders() { + private fun showEmptyCredentialsPlaceholders(canShowImportGooglePasswordsButton: Boolean) { binding.emptyStateLayout.emptyStateContainer.show() - binding.logins.gone() + if (canShowImportGooglePasswordsButton) { + viewModel.recordImportGooglePasswordButtonShown() + } } private suspend fun renderCredentialList( @@ -602,7 +650,11 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } companion object { - fun instance(currentUrl: String? = null, privacyProtectionEnabled: Boolean?, source: AutofillSettingsLaunchSource? = null) = + fun instance( + currentUrl: String? = null, + privacyProtectionEnabled: Boolean?, + source: AutofillSettingsLaunchSource? = null, + ) = AutofillManagementListMode().apply { arguments = Bundle().apply { putString(ARG_CURRENT_URL, currentUrl) @@ -621,6 +673,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill private const val ARG_PRIVACY_PROTECTION_STATUS = "ARG_PRIVACY_PROTECTION_STATUS" private const val ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE = "ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE" private const val LEARN_MORE_LINK = "https://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/" + private const val IMPORT_FROM_GPM_DIALOG_TAG = "IMPORT_FROM_GPM_DIALOG_TAG" } } diff --git a/autofill/autofill-impl/src/main/res/drawable/autofill_gpm_export_instruction.xml b/autofill/autofill-impl/src/main/res/drawable/autofill_gpm_export_instruction.xml new file mode 100644 index 000000000000..a23100c2710f --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/autofill_gpm_export_instruction.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/autofill_rounded_border_import_background.xml b/autofill/autofill-impl/src/main/res/drawable/autofill_rounded_border_import_background.xml new file mode 100644 index 000000000000..b7a9c542829f --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/autofill_rounded_border_import_background.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_check_recolorable_24.xml b/autofill/autofill-impl/src/main/res/drawable/ic_check_recolorable_24.xml new file mode 100644 index 000000000000..3e79b3d20fbe --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_check_recolorable_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_cross_recolorable_red_24.xml b/autofill/autofill-impl/src/main/res/drawable/ic_cross_recolorable_red_24.xml new file mode 100644 index 000000000000..432454a57240 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_cross_recolorable_red_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_passwords_import_128.xml b/autofill/autofill-impl/src/main/res/drawable/ic_passwords_import_128.xml new file mode 100644 index 000000000000..bcc09c00bf5e --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_passwords_import_128.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_success_128.xml b/autofill/autofill-impl/src/main/res/drawable/ic_success_128.xml new file mode 100644 index 000000000000..722ef58f645f --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_success_128.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/layout/activity_import_google_passwords_webflow.xml b/autofill/autofill-impl/src/main/res/layout/activity_import_google_passwords_webflow.xml new file mode 100644 index 000000000000..ee8f7196ef37 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/activity_import_google_passwords_webflow.xml @@ -0,0 +1,40 @@ + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/autofill_management_credential_list_empty_state.xml b/autofill/autofill-impl/src/main/res/layout/autofill_management_credential_list_empty_state.xml index c234aed93668..e9dc191c4343 100644 --- a/autofill/autofill-impl/src/main/res/layout/autofill_management_credential_list_empty_state.xml +++ b/autofill/autofill-impl/src/main/res/layout/autofill_management_credential_list_empty_state.xml @@ -66,20 +66,32 @@ android:layout_marginTop="@dimen/keyline_2" android:layout_marginStart="@dimen/keyline_6" android:layout_marginEnd="@dimen/keyline_6" + android:paddingBottom="@dimen/keyline_5" app:layout_constraintWidth_max="300dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/emptyPlaceholderTitle" android:text="@string/credentialManagementNoLoginsSavedSubtitle" /> + + + android:text="@string/autofillSyncDesktopPasswordEmptyStateButtonTitle" /> diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_from_google_password_dialog.xml b/autofill/autofill-impl/src/main/res/layout/content_import_from_google_password_dialog.xml new file mode 100644 index 000000000000..17e894b84d25 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_from_google_password_dialog.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow.xml b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow.xml new file mode 100644 index 000000000000..94e5109a2fa4 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_in_progress.xml b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_in_progress.xml new file mode 100644 index 000000000000..c2307128fc73 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_in_progress.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_result.xml b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_result.xml new file mode 100644 index 000000000000..2e48cbafdf4a --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_result.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_google_password_pre_flow.xml b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_pre_flow.xml new file mode 100644 index 000000000000..b5cc4d9e9de9 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_pre_flow.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/fragment_import_google_passwords_webflow.xml b/autofill/autofill-impl/src/main/res/layout/fragment_import_google_passwords_webflow.xml new file mode 100644 index 000000000000..fd1bb915bce6 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/fragment_import_google_passwords_webflow.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/menu/autofill_list_mode_menu.xml b/autofill/autofill-impl/src/main/res/menu/autofill_list_mode_menu.xml index d8b20f9b45df..ca4aecf0ee4d 100644 --- a/autofill/autofill-impl/src/main/res/menu/autofill_list_mode_menu.xml +++ b/autofill/autofill-impl/src/main/res/menu/autofill_list_mode_menu.xml @@ -40,15 +40,21 @@ app:showAsAction="never" /> + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml index c76cd611c22b..d49aec2a88b6 100644 --- a/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml @@ -46,13 +46,13 @@ Password URL del sito Web Note - Cancella + Elimina Salva modifiche Copia nome utente Copia password Modifica - Cancella + Elimina Suggerimenti @@ -62,7 +62,7 @@ Nessun risultato per \"%1$s\" Annulla - Cancella + Elimina Help us improve! diff --git a/autofill/autofill-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index 633f147275b8..882500b8984b 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -17,4 +17,28 @@ Passwords + + Import Google Passwords + %1$d passwords imported from Google + %1$d passwords imported from CSV + + Import Passwords From Google + Import Passwords From Google + + Sync DuckDuckGo Passwords + Sync DuckDuckGo Passwords + + Import Your Google Passwords + Google may ask you to sign in or enter your password to confirm. + Open Google Passwords + Choose a CSV file + Import from Desktop Browser + + Import to DuckDuckGo + Import Complete + Got It + Password import failed + Skipped (duplicate or invalid): %d]]> + Passwords imported: %d]]> + \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/AutofillTimeBasedAuthorizationGracePeriodTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/AutofillTimeBasedAuthorizationGracePeriodTest.kt index 7a386da11c21..05796db0e288 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/AutofillTimeBasedAuthorizationGracePeriodTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/AutofillTimeBasedAuthorizationGracePeriodTest.kt @@ -55,6 +55,39 @@ class AutofillTimeBasedAuthorizationGracePeriodTest { assertTrue(testee.isAuthRequired()) } + @Test + fun whenLastSuccessfulAuthWasBeforeGracePeriodButWithinExtendedAuthTimeThenAuthNotRequired() { + recordAuthorizationInDistantPast() + timeProvider.reset() + testee.requestExtendedGracePeriod() + assertFalse(testee.isAuthRequired()) + } + + @Test + fun whenNoPreviousAuthButWithinExtendedAuthTimeThenAuthNotRequired() { + testee.requestExtendedGracePeriod() + assertFalse(testee.isAuthRequired()) + } + + @Test + fun whenExtendedAuthTimeRequestedButTooLongAgoThenAuthRequired() { + configureExtendedAuthRequestedInDistantPast() + timeProvider.reset() + assertTrue(testee.isAuthRequired()) + } + + @Test + fun whenExtendedAuthTimeRequestedAndThenRemovedThenAuthRequired() { + testee.requestExtendedGracePeriod() + testee.removeRequestForExtendedGracePeriod() + assertTrue(testee.isAuthRequired()) + } + + private fun configureExtendedAuthRequestedInDistantPast() { + whenever(timeProvider.currentTimeMillis()).thenReturn(0) + testee.requestExtendedGracePeriod() + } + private fun recordAuthorizationInDistantPast() { whenever(timeProvider.currentTimeMillis()).thenReturn(0) testee.recordSuccessfulAuthorization() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/store/DefaultAutofillEngagementBucketingTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/store/DefaultAutofillEngagementBucketingTest.kt index 5db749d3cae7..d465c6d5d0f8 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/store/DefaultAutofillEngagementBucketingTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/store/DefaultAutofillEngagementBucketingTest.kt @@ -13,31 +13,31 @@ class DefaultAutofillEngagementBucketingTest { @Test fun whenZeroSavedPasswordsThenBucketIsNone() { - assertEquals(NONE, testee.bucketNumberOfSavedPasswords(0)) + assertEquals(NONE, testee.bucketNumberOfCredentials(0)) } @Test fun whenBetweenOneAndThreeSavedPasswordThenBucketIsFew() { - assertEquals(FEW, testee.bucketNumberOfSavedPasswords(1)) - assertEquals(FEW, testee.bucketNumberOfSavedPasswords(2)) - assertEquals(FEW, testee.bucketNumberOfSavedPasswords(3)) + assertEquals(FEW, testee.bucketNumberOfCredentials(1)) + assertEquals(FEW, testee.bucketNumberOfCredentials(2)) + assertEquals(FEW, testee.bucketNumberOfCredentials(3)) } @Test fun whenBetweenFourAndTenSavedPasswordThenBucketIsSome() { - assertEquals(SOME, testee.bucketNumberOfSavedPasswords(4)) - assertEquals(SOME, testee.bucketNumberOfSavedPasswords(10)) + assertEquals(SOME, testee.bucketNumberOfCredentials(4)) + assertEquals(SOME, testee.bucketNumberOfCredentials(10)) } @Test fun whenBetweenElevenAndFortyNineSavedPasswordThenBucketIsMany() { - assertEquals(MANY, testee.bucketNumberOfSavedPasswords(11)) - assertEquals(MANY, testee.bucketNumberOfSavedPasswords(49)) + assertEquals(MANY, testee.bucketNumberOfCredentials(11)) + assertEquals(MANY, testee.bucketNumberOfCredentials(49)) } @Test fun whenFiftyOrOverThenBucketIsMany() { - assertEquals(LOTS, testee.bucketNumberOfSavedPasswords(50)) - assertEquals(LOTS, testee.bucketNumberOfSavedPasswords(Int.MAX_VALUE)) + assertEquals(LOTS, testee.bucketNumberOfCredentials(50)) + assertEquals(LOTS, testee.bucketNumberOfCredentials(Int.MAX_VALUE)) } } 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 index 7ef23f71300f..4a7bd3be0640 100644 --- 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 @@ -1,12 +1,10 @@ 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 @@ -16,56 +14,32 @@ class DefaultDomainNameNormalizerTest { private val testee = DefaultDomainNameNormalizer(AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl())) @Test - fun whenEmptyInputThenEmptyOutput() = runTest { - val input = emptyList() - val output = testee.normalizeDomains(input) - assertTrue(output.isEmpty()) + fun whenInputIsEmptyStringThenEmptyOutput() = runTest { + val output = testee.normalize("") + assertEquals("", output) } @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()) + val output = testee.normalize("example.com") + assertEquals("example.com", output) } @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()) + val output = testee.normalize("https://example.com/foo/bar") + assertEquals("example.com", output) } @Test fun whenInputDomainIsNullThenNormalizedToNullDomain() = runTest { - val input = listOf(creds(domain = null)) - val output = testee.normalizeDomains(input) - assertEquals(1, output.size) - assertEquals(null, output.first().domain) + val output = testee.normalize(null) + assertEquals(null, output) } @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, - ) + val output = testee.normalize("unnormalizable") + assertEquals("unnormalizable", output) } } 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 index 801ac953ae17..53ab876a2309 100644 --- 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 @@ -1,6 +1,5 @@ package com.duckduckgo.autofill.impl.importing -import com.duckduckgo.autofill.api.domain.app.LoginCredentials import org.junit.Assert.* import org.junit.Test @@ -31,13 +30,13 @@ class DefaultImportedCredentialValidatorTest { @Test fun whenDomainMissingThenIsValid() { - val missingDomain = fullyPopulatedCredentials().copy(domain = null) + val missingDomain = fullyPopulatedCredentials().copy(url = null) assertTrue(testee.isValid(missingDomain)) } @Test fun whenTitleIsMissingThenIsValid() { - val missingTitle = fullyPopulatedCredentials().copy(domainTitle = null) + val missingTitle = fullyPopulatedCredentials().copy(title = null) assertTrue(testee.isValid(missingTitle)) } @@ -58,12 +57,12 @@ class DefaultImportedCredentialValidatorTest { @Test fun whenDomainOnlyFieldPopulatedThenIsValid() { - assertTrue(testee.isValid(emptyCredentials().copy(domain = "example.com"))) + assertTrue(testee.isValid(emptyCredentials().copy(url = "example.com"))) } @Test fun whenTitleIsOnlyFieldPopulatedThenIsValid() { - assertTrue(testee.isValid(emptyCredentials().copy(domainTitle = "title"))) + assertTrue(testee.isValid(emptyCredentials().copy(title = "title"))) } @Test @@ -73,26 +72,26 @@ class DefaultImportedCredentialValidatorTest { @Test fun whenDomainIsAppPasswordThenIsNotValid() { - val appPassword = fullyPopulatedCredentials().copy(domain = "android://Jz-U_hg==@com.netflix.mediaclient/") + val appPassword = fullyPopulatedCredentials().copy(url = "android://Jz-U_hg==@com.netflix.mediaclient/") assertFalse(testee.isValid(appPassword)) } - private fun fullyPopulatedCredentials(): LoginCredentials { - return LoginCredentials( + private fun fullyPopulatedCredentials(): GoogleCsvLoginCredential { + return GoogleCsvLoginCredential( username = "username", password = "password", - domain = "example.com", - domainTitle = "example title", + url = "example.com", + title = "example title", notes = "notes", ) } - private fun emptyCredentials(): LoginCredentials { - return LoginCredentials( + private fun emptyCredentials(): GoogleCsvLoginCredential { + return GoogleCsvLoginCredential( username = null, password = null, - domain = null, - domainTitle = null, + url = null, + title = null, notes = null, ) } 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 index 9039c77c52d3..da3491f2b21b 100644 --- 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 @@ -21,11 +21,11 @@ class GooglePasswordManagerCsvCredentialConverterTest { private val parser: CsvCredentialParser = mock() private val fileReader: CsvFileReader = mock() private val passthroughValidator = object : ImportedCredentialValidator { - override fun isValid(loginCredentials: LoginCredentials): Boolean = true + override fun isValid(loginCredentials: GoogleCsvLoginCredential): Boolean = true } private val passthroughDomainNormalizer = object : DomainNameNormalizer { - override suspend fun normalizeDomains(unnormalized: List): List { - return unnormalized + override suspend fun normalize(unnormalizedUrl: String?): String? { + return unnormalizedUrl } } private val blobDecoder: GooglePasswordBlobDecoder = mock() @@ -65,21 +65,27 @@ class GooglePasswordManagerCsvCredentialConverterTest { assertEquals(1, result.loginCredentialsToImport.size) } - private suspend fun configureParseResult(passwords: List): CsvCredentialImportResult.Success { + @Test + fun whenFailureToParseThen() = runTest { + whenever(parser.parseCsv(any())).thenThrow(RuntimeException()) + testee.readCsv("") as CsvCredentialImportResult.Error + } + + 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", + url: String? = "example.com", username: String? = "username", password: String? = "password", notes: String? = "notes", - domainTitle: String? = "example title", - ): LoginCredentials { - return LoginCredentials( - domainTitle = domainTitle, - domain = domain, + title: String? = "example title", + ): GoogleCsvLoginCredential { + return GoogleCsvLoginCredential( + title = title, + url = url, 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 index d48ccbe6e9cd..917135a2f9c0 100644 --- 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 @@ -1,6 +1,5 @@ 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 @@ -74,9 +73,9 @@ class GooglePasswordManagerCsvCredentialParserTest { 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", + val expected = GoogleCsvLoginCredential( + url = "https://example.com", + title = "example.com", username = "user", password = "password, a comma it has", notes = "notes", @@ -127,7 +126,7 @@ class GooglePasswordManagerCsvCredentialParserTest { val csv = "gpm_import_missing_title".readFile() with(testee.parseCsv(csv) as Success) { assertEquals(1, credentials.size) - credentials.first().verifyMatches(creds1.copy(domainTitle = null)) + credentials.first().verifyMatches(creds1.copy(title = null)) } } @@ -136,32 +135,32 @@ class GooglePasswordManagerCsvCredentialParserTest { val csv = "gpm_import_missing_domain".readFile() with(testee.parseCsv(csv) as Success) { assertEquals(1, credentials.size) - credentials.first().verifyMatches(creds1.copy(domain = null)) + credentials.first().verifyMatches(creds1.copy(url = null)) } } - private fun LoginCredentials.verifyMatchesCreds1() = verifyMatches(creds1) - private fun LoginCredentials.verifyMatchesCreds2() = verifyMatches(creds2) + private fun GoogleCsvLoginCredential.verifyMatchesCreds1() = verifyMatches(creds1) + private fun GoogleCsvLoginCredential.verifyMatchesCreds2() = verifyMatches(creds2) - private fun LoginCredentials.verifyMatches(expected: LoginCredentials) { - assertEquals(expected.domainTitle, domainTitle) - assertEquals(expected.domain, domain) + private fun GoogleCsvLoginCredential.verifyMatches(expected: GoogleCsvLoginCredential) { + assertEquals(expected.title, title) + assertEquals(expected.url, url) assertEquals(expected.username, username) assertEquals(expected.password, password) assertEquals(expected.notes, notes) } - private val creds1 = LoginCredentials( - domain = "https://example.com", - domainTitle = "example.com", + private val creds1 = GoogleCsvLoginCredential( + url = "https://example.com", + title = "example.com", username = "user", password = "password", notes = "note", ) - private val creds2 = LoginCredentials( - domain = "https://example.net", - domainTitle = "example.net", + private val creds2 = GoogleCsvLoginCredential( + url = "https://example.net", + title = "example.net", username = "user2", password = "password2", notes = "note2", 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 index 5264b3e54335..c387f9501cea 100644 --- 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 @@ -47,30 +47,63 @@ class AutofillImportPasswordConfigStoreImplTest { @Test fun whenLaunchUrlNotSpecifiedInConfigThenDefaultUsed() = runTest { - configureFeature(config = Config()) + configureFeature(config = Config(urlMappings = listOf(UrlMapping("key", "https://example.com")))) assertEquals(LAUNCH_URL_DEFAULT, testee.getConfig().launchUrlGooglePasswords) } @Test fun whenLaunchUrlSpecifiedInConfigThenOverridesDefault() = runTest { - configureFeature(config = Config(launchUrl = "https://example.com")) + configureFeature(config = Config(launchUrl = "https://example.com", urlMappings = listOf(UrlMapping("key", "https://example.com")))) assertEquals("https://example.com", testee.getConfig().launchUrlGooglePasswords) } @Test fun whenJavascriptConfigNotSpecifiedInConfigThenDefaultUsed() = runTest { - configureFeature(config = Config()) + configureFeature(config = Config(urlMappings = listOf(UrlMapping("key", "https://example.com")))) assertEquals(JAVASCRIPT_CONFIG_DEFAULT, testee.getConfig().javascriptConfigGooglePasswords) } @Test fun whenJavascriptConfigSpecifiedInConfigThenOverridesDefault() = runTest { - configureFeature(config = Config(javascriptConfig = JavaScriptConfig(key = "value", domains = listOf("foo, bar")))) + configureFeature( + config = Config( + javascriptConfig = JavaScriptConfig(key = "value", domains = listOf("foo, bar")), + urlMappings = listOf(UrlMapping("key", "https://example.com")), + ), + ) assertEquals("""{"domains":["foo, bar"],"key":"value"}""", testee.getConfig().javascriptConfigGooglePasswords) } + @Test + fun whenUrlMappingsSpecifiedInConfigOverridesDefault() = runTest { + configureFeature(config = Config(urlMappings = listOf(UrlMapping("key", "https://example.com")))) + testee.getConfig().urlMappings.apply { + assertEquals(1, size) + assertEquals("key", get(0).key) + assertEquals("https://example.com", get(0).url) + } + } + + @Test + fun whenUrlMappingsNotSpecifiedInConfigThenDefaultsUsed() = runTest { + configureFeature(config = Config(urlMappings = null)) + assertEquals(5, testee.getConfig().urlMappings.size) + } + + @Test + fun whenUrlMappingsNotSpecifiedInConfigThenCorrectOrderOfDefaultsReturned() = runTest { + configureFeature(config = Config(urlMappings = null)) + testee.getConfig().urlMappings.apply { + assertEquals("webflow-passphrase-encryption", get(0).key) + assertEquals("webflow-pre-login", get(1).key) + assertEquals("webflow-export", get(2).key) + assertEquals("webflow-authenticate", get(3).key) + assertEquals("webflow-post-login-landing", get(4).key) + } + } + @SuppressLint("DenyListedApi") - private fun configureFeature(enabled: Boolean = true, config: Config = Config()) { + private fun configureFeature(enabled: Boolean = true, config: Config = Config(urlMappings = listOf(UrlMapping("key", "https://example.com")))) { autofillFeature.canImportFromGooglePasswordManager().setRawStoredState( State( remoteEnableState = enabled, @@ -78,11 +111,13 @@ class AutofillImportPasswordConfigStoreImplTest { ), ) } - private data class Config( val launchUrl: String? = null, + val canInjectJavascript: Boolean = true, val javascriptConfig: JavaScriptConfig? = null, + val urlMappings: List?, ) + private data class JavaScriptConfig( val key: String, val domains: List, diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapperImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapperImplTest.kt new file mode 100644 index 000000000000..5b2fafd74482 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapperImplTest.kt @@ -0,0 +1,69 @@ +package com.duckduckgo.autofill.impl.importing.gpm.webflow + +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordSettings +import com.duckduckgo.autofill.impl.importing.gpm.feature.UrlMapping +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordUrlToStageMapperImpl.Companion.UNKNOWN +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ImportGooglePasswordUrlToStageMapperImplTest { + + private val importPasswordConfigStore: AutofillImportPasswordConfigStore = mock() + + private val testee = ImportGooglePasswordUrlToStageMapperImpl(importPasswordConfigStore = importPasswordConfigStore) + + @Before + fun setup() = runTest { + whenever(importPasswordConfigStore.getConfig()).thenReturn(config()) + } + + @Test + fun whenUrlIsEmptyStringThenStageIsUnknown() = runTest { + assertEquals(UNKNOWN, testee.getStage("")) + } + + @Test + fun whenUrlIsNullThenStageIsUnknown() = runTest { + assertEquals(UNKNOWN, testee.getStage(null)) + } + + @Test + fun whenUrlStartsWithKnownMappingThenValueReturned() = runTest { + listOf(UrlMapping("key", "https://example.com")).configureMappings() + assertEquals("key", testee.getStage("https://example.com")) + } + + @Test + fun whenUrlMatchesMultipleThenFirstValueReturned() = runTest { + listOf( + UrlMapping("key1", "https://example.com"), + UrlMapping("key2", "https://example.com"), + ).configureMappings() + assertEquals("key1", testee.getStage("https://example.com")) + } + + @Test + fun whenUrlHasDifferentPrefixThenNotAMatch() = runTest { + listOf(UrlMapping("key", "https://example.com")).configureMappings() + assertEquals(UNKNOWN, testee.getStage("example.com")) + } + + private suspend fun List.configureMappings() { + whenever(importPasswordConfigStore.getConfig()).thenReturn(config().copy(urlMappings = this)) + } + + private fun config(): AutofillImportPasswordSettings { + return AutofillImportPasswordSettings( + launchUrlGooglePasswords = "https://example.com", + canImportFromGooglePasswords = true, + canInjectJavascript = true, + javascriptConfigGooglePasswords = "{}", + urlMappings = emptyList(), + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt new file mode 100644 index 000000000000..a92bba2aa106 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt @@ -0,0 +1,150 @@ +package com.duckduckgo.autofill.impl.importing.gpm.webflow + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Error +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Success +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordSettings +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.LoadStartPage +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.NavigatingBack +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedCannotImport +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedImportFlow +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 +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class ImportGooglePasswordsWebFlowViewModelTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val credentialImporter: CredentialImporter = mock() + private val csvCredentialConverter: CsvCredentialConverter = mock() + private val autofillImportConfigStore: AutofillImportPasswordConfigStore = mock() + private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper = mock() + + private val testee = ImportGooglePasswordsWebFlowViewModel( + dispatchers = coroutineTestRule.testDispatcherProvider, + credentialImporter = credentialImporter, + csvCredentialConverter = csvCredentialConverter, + autofillImportConfigStore = autofillImportConfigStore, + urlToStageMapper = urlToStageMapper, + ) + + @Test + fun whenOnViewCreatedThenLoadStartPageState() = runTest { + configureFeature(launchUrlGooglePasswords = "https://example.com") + testee.onViewCreated() + testee.viewState.test { + assertEquals(LoadStartPage("https://example.com"), awaitItem()) + } + } + + @Test + fun whenCsvParseErrorThenUserFinishedCannotImport() = runTest { + configureCsvParseError() + testee.viewState.test { + awaitItem() as UserFinishedCannotImport + } + } + + @Test + fun whenCsvParseSuccessNoCredentialsThenUserFinishedImportFlow() = runTest { + configureCsvSuccess(loginCredentialsToImport = emptyList()) + testee.viewState.test { + awaitItem() as UserFinishedImportFlow + } + } + + @Test + fun whenCsvParseSuccessWithCredentialsThenUserFinishedImportFlow() = runTest { + configureCsvSuccess(loginCredentialsToImport = listOf(creds())) + testee.viewState.test { + awaitItem() as UserFinishedImportFlow + } + } + + @Test + fun whenBackButtonPressedAndCannotGoBackThenUserCancelledImportFlowState() = runTest { + whenever(urlToStageMapper.getStage(any())).thenReturn("stage") + testee.onBackButtonPressed(url = "https://example.com", canGoBack = false) + testee.viewState.test { + awaitItem() as UserCancelledImportFlow + } + } + + @Test + fun whenBackButtonPressedAndCanGoBackThenNavigatingBackState() = runTest { + testee.onBackButtonPressed(url = "https://example.com", canGoBack = true) + testee.viewState.test { + awaitItem() as NavigatingBack + } + } + + @Test + fun whenCloseButtonPressedThenUserCancelledImportFlowState() = runTest { + val expectedStage = "stage" + whenever(urlToStageMapper.getStage(any())).thenReturn(expectedStage) + testee.onCloseButtonPressed("https://example.com") + testee.viewState.test { + assertEquals(expectedStage, (awaitItem() as UserCancelledImportFlow).stage) + } + } + + private suspend fun configureFeature( + canImportFromGooglePasswords: Boolean = true, + launchUrlGooglePasswords: String = "https://example.com", + javascriptConfigGooglePasswords: String = "\"{}\"", + ) { + whenever(autofillImportConfigStore.getConfig()).thenReturn( + AutofillImportPasswordSettings( + canImportFromGooglePasswords = canImportFromGooglePasswords, + launchUrlGooglePasswords = launchUrlGooglePasswords, + javascriptConfigGooglePasswords = javascriptConfigGooglePasswords, + canInjectJavascript = true, + urlMappings = emptyList(), + ), + ) + } + + private suspend fun configureCsvParseError() { + whenever(csvCredentialConverter.readCsv(any())).thenReturn(Error) + testee.onCsvAvailable("") + } + + private suspend fun configureCsvSuccess( + loginCredentialsToImport: List = emptyList(), + numberCredentialsInSource: Int = loginCredentialsToImport.size, + ) { + whenever(csvCredentialConverter.readCsv(any())).thenReturn(Success(numberCredentialsInSource, loginCredentialsToImport)) + testee.onCsvAvailable("") + } + + 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/ui/credential/management/AutofillSettingsViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt index 3c7b7accf969..54b906e09b98 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt @@ -16,11 +16,16 @@ package com.duckduckgo.autofill.impl.ui.credential.management +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DocumentStartJavaScript +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.WebMessageListener import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserOverflow import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserSnackbar @@ -78,6 +83,8 @@ import com.duckduckgo.autofill.impl.ui.credential.management.viewing.duckaddress import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import kotlin.reflect.KClass import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -99,6 +106,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") @RunWith(AndroidJUnit4::class) class AutofillSettingsViewModelTest { @@ -121,6 +129,8 @@ class AutofillSettingsViewModelTest { private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules = mock() private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore = mock() private val urlMatcher = AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl()) + private val webViewCapabilityChecker: WebViewCapabilityChecker = mock() + private val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) private val testee = AutofillSettingsViewModel( autofillStore = mockStore, @@ -140,6 +150,8 @@ class AutofillSettingsViewModelTest { autofillBreakageReportSender = autofillBreakageReportSender, autofillBreakageReportDataStore = autofillBreakageReportDataStore, autofillBreakageReportCanShowRules = autofillBreakageReportCanShowRules, + webViewCapabilityChecker = webViewCapabilityChecker, + autofillFeature = autofillFeature, ) @Before @@ -151,6 +163,10 @@ class AutofillSettingsViewModelTest { whenever(mockStore.getCredentialCount()).thenReturn(flowOf(0)) whenever(neverSavedSiteRepository.neverSaveListCount()).thenReturn(emptyFlow()) whenever(deviceAuthenticator.isAuthenticationRequiredForAutofill()).thenReturn(true) + whenever(webViewCapabilityChecker.isSupported(WebMessageListener)).thenReturn(true) + whenever(webViewCapabilityChecker.isSupported(DocumentStartJavaScript)).thenReturn(true) + autofillFeature.self().setRawStoredState(State(enable = true)) + autofillFeature.canImportFromGooglePasswordManager().setRawStoredState(State(enable = true)) } } @@ -922,6 +938,45 @@ class AutofillSettingsViewModelTest { verify(pixel).fire(AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISMISSED) } + @Test + fun whenImportGooglePasswordsIsEnabledThenViewStateReflectsThat() = runTest { + testee.onViewCreated() + testee.viewState.test { + assertTrue(awaitItem().canImportFromGooglePasswords) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenImportGooglePasswordsFeatureFlagDisabledThenViewStateReflectsThat() = runTest { + autofillFeature.canImportFromGooglePasswordManager().setRawStoredState(State(enable = false)) + testee.onViewCreated() + testee.viewState.test { + assertFalse(awaitItem().canImportFromGooglePasswords) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenImportGooglePasswordsFeatureDisabledDueToWebMessageListenerNotSupportedThenViewStateReflectsThat() = runTest { + whenever(webViewCapabilityChecker.isSupported(WebMessageListener)).thenReturn(false) + testee.onViewCreated() + testee.viewState.test { + assertFalse(awaitItem().canImportFromGooglePasswords) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenImportGooglePasswordsFeatureDisabledDueToDocumentStartJavascriptNotSupportedThenViewStateReflectsThat() = runTest { + whenever(webViewCapabilityChecker.isSupported(DocumentStartJavaScript)).thenReturn(false) + testee.onViewCreated() + testee.viewState.test { + assertFalse(awaitItem().canImportFromGooglePasswords) + cancelAndIgnoreRemainingEvents() + } + } + private fun List.verifyHasCommandToShowDeleteAllConfirmation(expectedNumberOfCredentialsToDelete: Int) { val confirmationCommand = this.firstOrNull { it is LaunchDeleteAllPasswordsConfirmation } assertNotNull(confirmationCommand) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModelTest.kt new file mode 100644 index 000000000000..87bf32ab469e --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModelTest.kt @@ -0,0 +1,136 @@ +package com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google + +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +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.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.ErrorParsingCsv +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.ImportSuccess +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.Importing +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.PreImport +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewState +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +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.mock +import org.mockito.kotlin.whenever + +class ImportFromGooglePasswordsDialogViewModelTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule(StandardTestDispatcher()) + + private val importPasswordsPixelSender: ImportPasswordsPixelSender = mock() + + private val credentialImporter: CredentialImporter = mock() + private val testee = ImportFromGooglePasswordsDialogViewModel( + credentialImporter = credentialImporter, + dispatchers = coroutineTestRule.testDispatcherProvider, + importPasswordsPixelSender = importPasswordsPixelSender, + ) + + @Before + fun setup() = runTest { + whenever(credentialImporter.getImportStatus()).thenReturn(emptyFlow()) + } + + @Test + fun whenParsingErrorOnImportThenViewModeUpdatedToError() = runTest { + testee.onImportFlowFinishedWithError(ErrorParsingCsv) + testee.viewState.test { + assertTrue(awaitItem().viewMode is ViewMode.ImportError) + } + } + + @Test + fun whenSuccessfulImportThenViewModeUpdatedToInProgress() = runTest { + configureImportInProgress() + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + awaitImportInProgress() + } + } + + @Test + fun whenSuccessfulImportFlowThenImportFinishesNothingImportedThenViewModeUpdatedToResults() = runTest { + configureImportFinished(savedCredentials = 0, numberSkipped = 0) + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + awaitImportSuccess() + } + } + + @Test + fun whenSuccessfulImportFlowThenImportFinishesCredentialsImportedNoDuplicatesThenViewModeUpdatedToResults() = runTest { + configureImportFinished(savedCredentials = 10, numberSkipped = 0) + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + val result = awaitImportSuccess() + assertEquals(10, result.importResult.savedCredentials) + assertEquals(0, result.importResult.numberSkipped) + } + } + + @Test + fun whenSuccessfulImportFlowThenImportFinishesOnlyDuplicatesThenViewModeUpdatedToResults() = runTest { + configureImportFinished(savedCredentials = 0, numberSkipped = 2) + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + val result = awaitImportSuccess() + assertEquals(0, result.importResult.savedCredentials) + assertEquals(2, result.importResult.numberSkipped) + } + } + + @Test + fun whenSuccessfulImportNoUpdatesThenThenViewModeFirstInitialisedToPreImport() = runTest { + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + awaitItem().assertIsPreImport() + } + } + + private fun configureImportInProgress() { + whenever(credentialImporter.getImportStatus()).thenReturn(listOf(InProgress).asFlow()) + } + + private fun configureImportFinished( + savedCredentials: Int, + numberSkipped: Int, + ) { + whenever(credentialImporter.getImportStatus()).thenReturn( + listOf( + InProgress, + Finished(savedCredentials = savedCredentials, numberSkipped = numberSkipped), + ).asFlow(), + ) + } + + private suspend fun TurbineTestContext.awaitImportSuccess(): ImportSuccess { + awaitItem().assertIsPreImport() + awaitItem().assertIsImporting() + return awaitItem().viewMode as ImportSuccess + } + + private suspend fun TurbineTestContext.awaitImportInProgress(): Importing { + awaitItem().assertIsPreImport() + return awaitItem().viewMode as Importing + } + + private fun ViewState.assertIsPreImport() { + assertTrue(viewMode is PreImport) + } + + private fun ViewState.assertIsImporting() { + assertTrue(viewMode is Importing) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt index d60cee2bb47a..2e2ddef2b65e 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt @@ -93,7 +93,7 @@ class AutofillSurveyImplTest { @Test fun whenSurveyLaunchedThenSavedPasswordQueryParamAdded() = runTest { - whenever(passwordBucketing.bucketNumberOfSavedPasswords(any())).thenReturn("fromBucketing") + whenever(passwordBucketing.bucketNumberOfCredentials(any())).thenReturn("fromBucketing") val survey = getAvailableSurvey() val savedPasswordsBucket = survey.url.toUri().getQueryParameter("saved_passwords") assertEquals("fromBucketing", savedPasswordsBucket) 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 d3914bfd444d..d39e5856570f 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 @@ -23,6 +23,7 @@ import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.IntentCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.lifecycleScope @@ -43,6 +44,12 @@ import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.In 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.importing.gpm.webflow.ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY_DETAILS +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Error +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Success +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.UserCancelled import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -152,7 +159,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } is CsvCredentialImportResult.Error -> { - "Failed to import passwords due to an error".showSnackbar() + FAILED_IMPORT_GENERIC_ERROR.showSnackbar() } } } @@ -160,6 +167,21 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + logcat { "onActivityResult for Google Password Manager import flow. resultCode=${result.resultCode}" } + + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { + when (IntentCompat.getParcelableExtra(it, RESULT_KEY_DETAILS, ImportGooglePasswordResult::class.java)) { + is Success -> observePasswordInputUpdates() + is Error -> FAILED_IMPORT_GENERIC_ERROR.showSnackbar() + is UserCancelled, null -> { + } + } + } + } + } + private fun observePasswordInputUpdates() { passwordImportWatcher += lifecycleScope.launch { credentialImporter.getImportStatus().collect { @@ -266,6 +288,10 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, googlePasswordsUrl)) } } + binding.importPasswordsLaunchGooglePasswordCustomFlow.setClickListener { + val intent = globalActivityStarter.startIntent(this, AutofillImportViaGooglePasswordManagerScreen) + importGooglePasswordsFlowLauncher.launch(intent) + } binding.importPasswordsImportCsv.setClickListener { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { @@ -570,6 +596,8 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { return Intent(context, AutofillInternalSettingsActivity::class.java) } + private const val FAILED_IMPORT_GENERIC_ERROR = "Failed to import passwords due to an error" + private val sampleUrlList = listOf( "fill.dev", "duckduckgo.com", 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 1646042b7d70..e6dd61c948f7 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 @@ -92,6 +92,11 @@ android:layout_height="wrap_content" app:primaryText="@string/autofillDevSettingsImportPasswordsTitle" /> + Import Passwords Launch Google Passwords (normal tab) + Launch Google Passwords (import flow) Import CSV + %1$d passwords imported from Google Maximum number of days since install OK diff --git a/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSitePrompt.kt b/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSitePrompt.kt new file mode 100644 index 000000000000..2c431067dcd3 --- /dev/null +++ b/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSitePrompt.kt @@ -0,0 +1,38 @@ +/* + * 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.brokensite.api + +import android.net.Uri + +interface BrokenSitePrompt { + + suspend fun userDismissedPrompt() + + suspend fun userAcceptedPrompt() + + suspend fun isFeatureEnabled(): Boolean + + fun pageRefreshed(url: Uri) + + fun resetRefreshCount() + + fun getUserRefreshesCount(): Int + + suspend fun shouldShowBrokenSitePrompt(url: String): Boolean + + suspend fun ctaShown() +} diff --git a/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt b/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt index e56e47bfbfaa..3d8313409f3a 100644 --- a/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt +++ b/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt @@ -41,4 +41,4 @@ data class BrokenSite( val jsPerformance: List?, ) -enum class ReportFlow { DASHBOARD, MENU } +enum class ReportFlow { DASHBOARD, MENU, PROMPT } diff --git a/broken-site/broken-site-impl/build.gradle b/broken-site/broken-site-impl/build.gradle index 2241531fbc4b..3a1fdef688c1 100644 --- a/broken-site/broken-site-impl/build.gradle +++ b/broken-site/broken-site-impl/build.gradle @@ -33,11 +33,13 @@ android { } dependencies { + testImplementation project(':feature-toggles-test') anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') implementation project(path: ':broken-site-api') implementation project(path: ':browser-api') implementation project(path: ':di') + implementation project(path: ':common-ui') implementation project(path: ':common-utils') implementation project(path: ':statistics-api') implementation project(path: ':app-build-config-api') @@ -74,6 +76,8 @@ dependencies { implementation Square.retrofit2.converter.moshi implementation Square.okHttp3.okHttp + implementation AndroidX.dataStore.preferences + // Testing dependencies testImplementation "org.mockito.kotlin:mockito-kotlin:_" testImplementation Testing.junit4 diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePomptDataStore.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePomptDataStore.kt new file mode 100644 index 000000000000..2d354f69a3c8 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePomptDataStore.kt @@ -0,0 +1,144 @@ +/* + * 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.brokensite.impl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.COOL_DOWN_DAYS +import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.DISMISS_STREAK +import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.DISMISS_STREAK_RESET_DAYS +import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.MAX_DISMISS_STREAK +import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.NEXT_SHOWN_DATE +import com.duckduckgo.brokensite.impl.di.BrokenSitePrompt +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface BrokenSitePomptDataStore { + suspend fun setMaxDismissStreak(maxDismissStreak: Int) + suspend fun getMaxDismissStreak(): Int + + suspend fun setDismissStreakResetDays(days: Int) + suspend fun getDismissStreakResetDays(): Int + + suspend fun setCoolDownDays(days: Long) + suspend fun getCoolDownDays(): Long + suspend fun setDismissStreak(streak: Int) + suspend fun getDismissStreak(): Int + suspend fun setNextShownDate(nextShownDate: LocalDateTime?) + suspend fun getNextShownDate(): LocalDateTime? +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class SharedPreferencesDuckPlayerDataStore @Inject constructor( + @BrokenSitePrompt private val store: DataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : BrokenSitePomptDataStore { + + private object Keys { + val MAX_DISMISS_STREAK = intPreferencesKey(name = "MAX_DISMISS_STREAK") + val DISMISS_STREAK_RESET_DAYS = intPreferencesKey(name = "DISMISS_STREAK_RESET_DAYS") + val COOL_DOWN_DAYS = longPreferencesKey(name = "COOL_DOWN_DAYS") + val DISMISS_STREAK = intPreferencesKey(name = "DISMISS_STREAK") + val NEXT_SHOWN_DATE = stringPreferencesKey(name = "NEXT_SHOWN_DATE") + } + + private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val maxDismissStreak: Flow = store.data + .map { prefs -> + prefs[MAX_DISMISS_STREAK] ?: 3 + } + .distinctUntilChanged() + + private val dismissStreakResetDays: Flow = store.data + .map { prefs -> + prefs[DISMISS_STREAK_RESET_DAYS] ?: 30 + } + .distinctUntilChanged() + + private val coolDownDays: Flow = store.data + .map { prefs -> + prefs[COOL_DOWN_DAYS] ?: 7 + } + + private val dismissStreak: Flow = store.data + .map { prefs -> + prefs[DISMISS_STREAK] ?: 0 + } + .distinctUntilChanged() + + private val nextShownDate: Flow = store.data + .map { prefs -> + prefs[NEXT_SHOWN_DATE] + } + .distinctUntilChanged() + + override suspend fun setMaxDismissStreak(maxDismissStreak: Int) { + store.edit { prefs -> prefs[MAX_DISMISS_STREAK] = maxDismissStreak } + } + + override suspend fun getMaxDismissStreak(): Int = maxDismissStreak.first() + + override suspend fun setDismissStreakResetDays(days: Int) { + store.edit { prefs -> prefs[DISMISS_STREAK_RESET_DAYS] = days } + } + + override suspend fun getDismissStreakResetDays(): Int = dismissStreakResetDays.first() + + override suspend fun setCoolDownDays(days: Long) { + store.edit { prefs -> prefs[COOL_DOWN_DAYS] = days } + } + + override suspend fun getCoolDownDays(): Long = coolDownDays.first() + + override suspend fun setDismissStreak(streak: Int) { + store.edit { prefs -> prefs[DISMISS_STREAK] = streak } + } + + override suspend fun setNextShownDate(nextShownDate: LocalDateTime?) { + store.edit { prefs -> + + nextShownDate?.let { + prefs[NEXT_SHOWN_DATE] = formatter.format(nextShownDate) + } ?: run { + prefs.remove(NEXT_SHOWN_DATE) + } + } + } + + override suspend fun getDismissStreak(): Int { + return dismissStreak.first() + } + + override suspend fun getNextShownDate(): LocalDateTime? { + return nextShownDate.first()?.let { LocalDateTime.parse(it, formatter) } + } +} diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptInMemoryStore.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptInMemoryStore.kt new file mode 100644 index 000000000000..6ab6bea14240 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptInMemoryStore.kt @@ -0,0 +1,72 @@ +/* + * 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.brokensite.impl + +import android.net.Uri +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.time.LocalDateTime +import javax.inject.Inject + +interface BrokenSitePromptInMemoryStore { + fun resetRefreshCount() + fun addRefresh(url: Uri, localDateTime: LocalDateTime) + fun getAndUpdateUserRefreshesBetween( + t1: LocalDateTime, + t2: LocalDateTime, + ): Int +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealBrokenSitePromptInMemoryStore @Inject constructor() : BrokenSitePromptInMemoryStore { + private var refreshes: LastRefreshedUrl? = null + + override fun resetRefreshCount() { + this.refreshes = null + } + + override fun addRefresh( + url: Uri, + localDateTime: LocalDateTime, + ) { + refreshes.let { + refreshes = if (it == null || it.url != url) { + LastRefreshedUrl(url, mutableListOf(localDateTime)) + } else { + it.copy(time = it.time.plus(localDateTime)) + } + } + } + + override fun getAndUpdateUserRefreshesBetween( + t1: LocalDateTime, + t2: LocalDateTime, + ): Int { + return refreshes?.let { + val time = it.time.filter { time -> time.isAfter(t1) && time.isBefore(t2) } + refreshes = it.copy(time = time) + time.size + } ?: 0 + } +} + +data class LastRefreshedUrl( + val url: Uri, + val time: List, +) diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptRCFeature.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptRCFeature.kt new file mode 100644 index 000000000000..e4c171829c88 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptRCFeature.kt @@ -0,0 +1,75 @@ +/* + * 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.brokensite.impl + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.FeatureSettings +import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue +import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "brokenSitePrompt", + settingsStore = BrokenSitePromptRCFeatureStore::class, +) +interface BrokenSitePromptRCFeature { + @InternalAlwaysEnabled + @DefaultValue(false) + fun self(): Toggle +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +@RemoteFeatureStoreNamed(BrokenSitePromptRCFeature::class) +class BrokenSitePromptRCFeatureStore @Inject constructor( + private val repository: BrokenSiteReportRepository, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : FeatureSettings.Store { + + private val jsonAdapter by lazy { buildJsonAdapter() } + + override fun store(jsonString: String) { + appCoroutineScope.launch { + jsonAdapter.fromJson(jsonString)?.let { + repository.setBrokenSitePromptRCSettings(it.maxDismissStreak, it.dismissStreakResetDays, it.coolDownDays) + } + } + } + + private fun buildJsonAdapter(): JsonAdapter { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + return moshi.adapter(BrokenSitePromptSettings::class.java) + } + + data class BrokenSitePromptSettings( + val maxDismissStreak: Int, + val dismissStreakResetDays: Int, + val coolDownDays: Int, + ) +} diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt index 473ef80e90c4..986de052c561 100644 --- a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt @@ -16,6 +16,7 @@ package com.duckduckgo.brokensite.impl +import android.net.Uri import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.brokensite.store.BrokenSiteDatabase import com.duckduckgo.brokensite.store.BrokenSiteLastSentReportEntity @@ -36,12 +37,36 @@ interface BrokenSiteReportRepository { fun setLastSentDay(hostname: String) fun cleanupOldEntries() + + suspend fun setMaxDismissStreak(maxDismissStreak: Int) + suspend fun getMaxDismissStreak(): Int + + suspend fun setDismissStreakResetDays(days: Int) + suspend fun getDismissStreakResetDays(): Int + + suspend fun setCoolDownDays(days: Long) + suspend fun getCoolDownDays(): Long + + suspend fun setBrokenSitePromptRCSettings(maxDismissStreak: Int, dismissStreakResetDays: Int, coolDownDays: Int) + + suspend fun setNextShownDate(nextShownDate: LocalDateTime?) + suspend fun getNextShownDate(): LocalDateTime? + + suspend fun incrementDismissStreak() + suspend fun getDismissStreak(): Int + suspend fun resetDismissStreak() + + fun resetRefreshCount() + fun addRefresh(url: Uri, localDateTime: LocalDateTime) + fun getAndUpdateUserRefreshesBetween(t1: LocalDateTime, t2: LocalDateTime): Int } -class RealBrokenSiteReportRepository constructor( +class RealBrokenSiteReportRepository( private val database: BrokenSiteDatabase, @AppCoroutineScope private val coroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, + private val brokenSitePromptDataStore: BrokenSitePomptDataStore, + private val brokenSitePromptInMemoryStore: BrokenSitePromptInMemoryStore, ) : BrokenSiteReportRepository { override suspend fun getLastSentDay(hostname: String): String? { @@ -74,6 +99,70 @@ class RealBrokenSiteReportRepository constructor( } } + override suspend fun setMaxDismissStreak(maxDismissStreak: Int) { + brokenSitePromptDataStore.setMaxDismissStreak(maxDismissStreak) + } + + override suspend fun getMaxDismissStreak(): Int = + brokenSitePromptDataStore.getMaxDismissStreak() + + override suspend fun setDismissStreakResetDays(days: Int) { + brokenSitePromptDataStore.setDismissStreakResetDays(days) + } + + override suspend fun getDismissStreakResetDays(): Int = + brokenSitePromptDataStore.getDismissStreakResetDays() + + override suspend fun setCoolDownDays(days: Long) { + brokenSitePromptDataStore.setCoolDownDays(days) + } + + override suspend fun getCoolDownDays(): Long = + brokenSitePromptDataStore.getCoolDownDays() + + override suspend fun setBrokenSitePromptRCSettings( + maxDismissStreak: Int, + dismissStreakResetDays: Int, + coolDownDays: Int, + ) { + setMaxDismissStreak(maxDismissStreak) + setDismissStreakResetDays(dismissStreakResetDays) + setCoolDownDays(coolDownDays.toLong()) + } + + override suspend fun resetDismissStreak() { + brokenSitePromptDataStore.setDismissStreak(0) + } + + override suspend fun setNextShownDate(nextShownDate: LocalDateTime?) { + brokenSitePromptDataStore.setNextShownDate(nextShownDate) + } + + override suspend fun getDismissStreak(): Int { + return brokenSitePromptDataStore.getDismissStreak() + } + + override suspend fun getNextShownDate(): LocalDateTime? { + return brokenSitePromptDataStore.getNextShownDate() + } + + override suspend fun incrementDismissStreak() { + val dismissCount = getDismissStreak() + brokenSitePromptDataStore.setDismissStreak(dismissCount + 1) + } + + override fun resetRefreshCount() { + brokenSitePromptInMemoryStore.resetRefreshCount() + } + + override fun addRefresh(url: Uri, localDateTime: LocalDateTime) { + brokenSitePromptInMemoryStore.addRefresh(url, localDateTime) + } + + override fun getAndUpdateUserRefreshesBetween(t1: LocalDateTime, t2: LocalDateTime): Int { + return brokenSitePromptInMemoryStore.getAndUpdateUserRefreshesBetween(t1, t2) + } + private fun convertToShortDate(dateString: String): String { val inputFormatter = DateTimeFormatter.ISO_INSTANT val outputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/RealBrokenSitePrompt.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/RealBrokenSitePrompt.kt new file mode 100644 index 000000000000..fa2e2c1040e1 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/RealBrokenSitePrompt.kt @@ -0,0 +1,104 @@ +/* + * 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.brokensite.impl + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.brokensite.api.BrokenSitePrompt +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal const val REFRESH_COUNT_WINDOW = 20L + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal const val REFRESH_COUNT_LIMIT = 3 + +@ContributesBinding(AppScope::class) +class RealBrokenSitePrompt @Inject constructor( + private val brokenSiteReportRepository: BrokenSiteReportRepository, + private val brokenSitePromptRCFeature: BrokenSitePromptRCFeature, + private val currentTimeProvider: CurrentTimeProvider, + private val duckGoUrlDetector: DuckDuckGoUrlDetector, +) : BrokenSitePrompt { + + private val _featureEnabled by lazy { brokenSitePromptRCFeature.self().isEnabled() } + + override suspend fun userDismissedPrompt() { + if (!_featureEnabled) return + if (brokenSiteReportRepository.getDismissStreak() >= brokenSiteReportRepository.getMaxDismissStreak() - 1) { + brokenSiteReportRepository.resetDismissStreak() + val nextShownDate = brokenSiteReportRepository.getNextShownDate() + val newNextShownDate = currentTimeProvider.localDateTimeNow().plusDays(brokenSiteReportRepository.getDismissStreakResetDays().toLong()) + + if (nextShownDate == null || newNextShownDate.isAfter(nextShownDate)) { + brokenSiteReportRepository.setNextShownDate(newNextShownDate) + } + } else { + brokenSiteReportRepository.incrementDismissStreak() + } + } + + override suspend fun userAcceptedPrompt() { + if (!_featureEnabled) return + + brokenSiteReportRepository.resetDismissStreak() + } + + override suspend fun isFeatureEnabled(): Boolean { + return _featureEnabled + } + + override fun pageRefreshed( + url: Uri, + ) { + brokenSiteReportRepository.addRefresh(url, currentTimeProvider.localDateTimeNow()) + } + + override fun resetRefreshCount() { + brokenSiteReportRepository.resetRefreshCount() + } + + override fun getUserRefreshesCount(): Int { + return brokenSiteReportRepository.getAndUpdateUserRefreshesBetween( + currentTimeProvider.localDateTimeNow().minusSeconds(REFRESH_COUNT_WINDOW), + currentTimeProvider.localDateTimeNow(), + ).also { + if (it >= REFRESH_COUNT_LIMIT) { + brokenSiteReportRepository.resetRefreshCount() + } + } + } + + override suspend fun shouldShowBrokenSitePrompt(url: String): Boolean { + return isFeatureEnabled() && + getUserRefreshesCount() >= REFRESH_COUNT_LIMIT && + brokenSiteReportRepository.getNextShownDate()?.isBefore(currentTimeProvider.localDateTimeNow()) ?: true && + !duckGoUrlDetector.isDuckDuckGoUrl(url) + } + + override suspend fun ctaShown() { + val nextShownDate = brokenSiteReportRepository.getNextShownDate() + val newNextShownDate = currentTimeProvider.localDateTimeNow().plusDays(brokenSiteReportRepository.getCoolDownDays()) + if (nextShownDate == null || newNextShownDate.isAfter(nextShownDate)) { + brokenSiteReportRepository.setNextShownDate(newNextShownDate) + } + } +} diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt index bf116e4f4bbe..272ba7cea339 100644 --- a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt @@ -19,6 +19,8 @@ package com.duckduckgo.brokensite.impl.di import android.content.Context import androidx.room.Room import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.brokensite.impl.BrokenSitePomptDataStore +import com.duckduckgo.brokensite.impl.BrokenSitePromptInMemoryStore import com.duckduckgo.brokensite.impl.BrokenSiteReportRepository import com.duckduckgo.brokensite.impl.RealBrokenSiteReportRepository import com.duckduckgo.brokensite.store.ALL_MIGRATIONS @@ -41,8 +43,10 @@ class BrokenSiteModule { database: BrokenSiteDatabase, @AppCoroutineScope coroutineScope: CoroutineScope, dispatcherProvider: DispatcherProvider, + brokenSitePromptDataStore: BrokenSitePomptDataStore, + brokenSitePromptInMemoryStore: BrokenSitePromptInMemoryStore, ): BrokenSiteReportRepository { - return RealBrokenSiteReportRepository(database, coroutineScope, dispatcherProvider) + return RealBrokenSiteReportRepository(database, coroutineScope, dispatcherProvider, brokenSitePromptDataStore, brokenSitePromptInMemoryStore) } @Provides diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSitePromptDataStoreModule.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSitePromptDataStoreModule.kt new file mode 100644 index 000000000000..af44e2fdbede --- /dev/null +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSitePromptDataStoreModule.kt @@ -0,0 +1,43 @@ +/* + * 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.brokensite.impl.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +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 BrokenSitePromptDataStoreModule { + + private val Context.brokenSitePromptDataStore: DataStore by preferencesDataStore( + name = "broken_site_prompt", + ) + + @Provides + @BrokenSitePrompt + fun provideBrokenSitePromptDataStore(context: Context): DataStore = context.brokenSitePromptDataStore +} + +@Qualifier +internal annotation class BrokenSitePrompt diff --git a/broken-site/broken-site-impl/src/main/res/drawable/top_banner.xml b/broken-site/broken-site-impl/src/main/res/drawable/top_banner.xml new file mode 100644 index 000000000000..80cd21e813c9 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/drawable/top_banner.xml @@ -0,0 +1,35 @@ + + + + + + + + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/layout/prompt_broken_site.xml b/broken-site/broken-site-impl/src/main/res/layout/prompt_broken_site.xml new file mode 100644 index 000000000000..7c958df67e23 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/layout/prompt_broken_site.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + diff --git a/broken-site/broken-site-impl/src/main/res/values-bg/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-bg/strings-broken-site.xml new file mode 100644 index 000000000000..44ec35b2bb63 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-bg/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Сайтът не работи ли? Уведомете ни. + Това ни помага да подобрим браузъра DuckDuckGo. + Отказване + Подаване на сигнал за повреден сайт + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-cs/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-cs/strings-broken-site.xml new file mode 100644 index 000000000000..071ad7201c61 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-cs/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Stránka nefunguje? Dej nám vědět. + Pomůžeš nám prohlížeč DuckDuckGo vylepšit. + Odmítnout + Nahlásit nefunkční webové stránky + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-da/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-da/strings-broken-site.xml new file mode 100644 index 000000000000..f8d822ba70fc --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-da/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Virker webstedet ikke? Fortæl os det. + Det hjælper os med at forbedre DuckDuckGo-browseren. + Afvis + Rapporter ødelagt websted + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-de/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-de/strings-broken-site.xml new file mode 100644 index 000000000000..9e67263e6e2c --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-de/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Webseite funktioniert nicht? Lass es uns wissen. + Das hilft uns, den DuckDuckGo-Browser zu verbessern. + Verwerfen + Fehlerhafte Seite melden + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-el/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-el/strings-broken-site.xml new file mode 100644 index 000000000000..5e19911dba1d --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-el/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Δεν λειτουργεί ο ιστότοπος; Ενημερώστε μας. + Αυτό μας βοηθά να βελτιώνουμε το πρόγραμμα περιήγησης DuckDuckGo. + Απόρριψη + Αναφορά ιστότοπου που δεν λειτουργεί + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-es/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-es/strings-broken-site.xml new file mode 100644 index 000000000000..bfb8c7b4e2f7 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-es/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + ¿El sitio no funciona? Háznoslo saber. + Esto nos ayuda a mejorar el navegador DuckDuckGo. + Descartar + Informar de sitio web dañado + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-et/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-et/strings-broken-site.xml new file mode 100644 index 000000000000..83ada1402f80 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-et/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Kas sait ei tööta? Anna meile teada. + See aitab meil DuckDuckGo brauserit täiustada. + Loobu + Teata mittetoimivast saidist + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-fi/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-fi/strings-broken-site.xml new file mode 100644 index 000000000000..ea3a1202d1cf --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-fi/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Eikö sivusto toimi? Kerro meille. + Tämä auttaa meitä parantamaan DuckDuckGo-selainta. + Hylkää + Ilmoita viallisesta sivustosta + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-fr/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-fr/strings-broken-site.xml new file mode 100644 index 000000000000..336d653094b0 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-fr/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Le site ne fonctionne pas ? Faites-le nous savoir. + Cela nous aide à améliorer le navigateur DuckDuckGo. + Ignorer + Signaler un problème de site + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-hr/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-hr/strings-broken-site.xml new file mode 100644 index 000000000000..402e60b4e361 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-hr/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Mrežna lokacija ne funkcionira? Javi nam. + Ovo nam pomaže da poboljšamo preglednik DuckDuckGo. + Odbaci + Prijavi neispravno web-mjesto + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-hu/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-hu/strings-broken-site.xml new file mode 100644 index 000000000000..e930f2a60713 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-hu/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Nem működik a webhely? Jelezd nekünk. + Ez segít minket a DuckDuckGo böngésző tökéletesítésében. + Elutasítás + Hibás weboldal jelentése + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-it/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-it/strings-broken-site.xml new file mode 100644 index 000000000000..a9f8cdde2aba --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-it/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Il sito non funziona? Comunicacelo. + Questo ci aiuta a migliorare il browser DuckDuckGo. + Ignora + Segnala sito danneggiato + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-lt/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-lt/strings-broken-site.xml new file mode 100644 index 000000000000..2e8ce838f925 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-lt/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Svetainė neveikia? Pranešk mums. + Tai padeda mums tobulinti „DuckDuckGo“ naršyklę. + Atmesti + Pranešti apie sugadintą svetainę + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-lv/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-lv/strings-broken-site.xml new file mode 100644 index 000000000000..f149dcad570e --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-lv/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Vai vietne nedarbojas? Ziņo mums. + Tas mums palīdz uzlabot DuckDuckGo pārlūkprogrammu. + Nerādīt + Ziņot par bojātu vietni + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-nb/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-nb/strings-broken-site.xml new file mode 100644 index 000000000000..8e2c7722c4a3 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-nb/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Fungerer ikke nettstedet? Gi oss beskjed. + Dette hjelper oss med å forbedre DuckDuckGo-nettleseren. + Avvis + Rapporter nettstedfeil + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-nl/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-nl/strings-broken-site.xml new file mode 100644 index 000000000000..cc4a658de4c3 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-nl/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Werkt de site niet? Laat het ons weten. + Hiermee kunnen we de DuckDuckGo-browser verbeteren. + Negeren + Defecte website melden + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-pl/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-pl/strings-broken-site.xml new file mode 100644 index 000000000000..446602ad0eca --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-pl/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Witryna nie działa? Daj nam znać. + To pomaga nam ulepszyć przeglądarkę DuckDuckGo. + Odrzuć + Zgłoś uszkodzoną witrynę + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-pt/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-pt/strings-broken-site.xml new file mode 100644 index 000000000000..ff4b7c3940aa --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-pt/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + O site não funciona? Informa-nos. + Isto ajuda-nos a melhorar o navegador DuckDuckGo. + Ignorar + Denunciar site danificado + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-ro/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-ro/strings-broken-site.xml new file mode 100644 index 000000000000..038a31e9a52b --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-ro/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Site-ul nu funcționează? Spune-ne. + Acest lucru ne ajută să îmbunătățim browserul DuckDuckGo. + Renunță + Raportează Site-ul Defect + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-ru/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-ru/strings-broken-site.xml new file mode 100644 index 000000000000..0d3ab2f72600 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-ru/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Сайт не работает? Дайте нам знать. + Ваш ответ пойдет на пользу браузеру DuckDuckGo. + Отклонить + Сообщить о неработающем сайте + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-sk/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-sk/strings-broken-site.xml new file mode 100644 index 000000000000..9a02ecc741ef --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-sk/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Stránka nefunguje? Oznámte nám to. + Pomáha nám to vylepšiť prehliadač DuckDuckGo. + Odmietnuť + Nahlásiť nefunkčný web + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-sl/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-sl/strings-broken-site.xml new file mode 100644 index 000000000000..f273790425b4 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-sl/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Stran ne deluje? Sporočite nam. + To nam pomaga izboljšati brskalnik DuckDuckGo. + Opusti + Prijavite poškodovano spletno mesto + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-sv/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-sv/strings-broken-site.xml new file mode 100644 index 000000000000..c8d917b1667d --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-sv/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Fungerar inte webbplatsen? Meddela oss. + Det hjälper oss att förbättra DuckDuckGo-webbläsaren. + Avvisa + Rapportera skadad webbplats + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values-tr/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values-tr/strings-broken-site.xml new file mode 100644 index 000000000000..5fee12e3395b --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values-tr/strings-broken-site.xml @@ -0,0 +1,24 @@ + + + + + + Site çalışmıyor mu? Bize bildirin. + Bu, DuckDuckGo tarayıcısını geliştirmemize yardımcı olur. + Reddet + Hatalı Siteyi Bildirin + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/main/res/values/strings-broken-site.xml b/broken-site/broken-site-impl/src/main/res/values/strings-broken-site.xml new file mode 100644 index 000000000000..278f63386751 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/res/values/strings-broken-site.xml @@ -0,0 +1,23 @@ + + + + + Site not working? Let us know. + This helps us improve the DuckDuckGo browser. + Dismiss + Report Broken Site + \ No newline at end of file diff --git a/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSitePromptTest.kt b/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSitePromptTest.kt new file mode 100644 index 000000000000..96388231ff5c --- /dev/null +++ b/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSitePromptTest.kt @@ -0,0 +1,184 @@ +package com.duckduckgo.brokensite.impl + +import android.annotation.SuppressLint +import android.net.Uri +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import java.time.LocalDateTime +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.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +class RealBrokenSitePromptTest { + + private val mockBrokenSiteReportRepository: BrokenSiteReportRepository = mock() + private val fakeBrokenSitePromptRCFeature: BrokenSitePromptRCFeature = FakeFeatureToggleFactory.create(BrokenSitePromptRCFeature::class.java) + private val mockCurrentTimeProvider: CurrentTimeProvider = mock() + private val mockDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() + + private val testee = RealBrokenSitePrompt( + brokenSiteReportRepository = mockBrokenSiteReportRepository, + brokenSitePromptRCFeature = fakeBrokenSitePromptRCFeature, + currentTimeProvider = mockCurrentTimeProvider, + duckGoUrlDetector = mockDuckGoUrlDetector, + ) + + @Before + fun setup() = runTest { + whenever(mockBrokenSiteReportRepository.getCoolDownDays()).thenReturn(7) + whenever(mockBrokenSiteReportRepository.getMaxDismissStreak()).thenReturn(3) + fakeBrokenSitePromptRCFeature.self().setRawStoredState(State(true)) + } + + @Test + fun whenUserDismissedPromptAndNoNextShownDateThenIncrementDismissStreakAndDoNotUpdateNextShownDate() = runTest { + whenever(mockBrokenSiteReportRepository.getNextShownDate()).thenReturn(null) + whenever(mockBrokenSiteReportRepository.getMaxDismissStreak()).thenReturn(3) + whenever(mockBrokenSiteReportRepository.getDismissStreak()).thenReturn(0) + + testee.userDismissedPrompt() + + verify(mockBrokenSiteReportRepository, never()).setNextShownDate(any()) + verify(mockBrokenSiteReportRepository).incrementDismissStreak() + } + + @Test + fun whenUserDismissedPromptMaxDismissStreakTimesAndNextShownDateEarlierThanDismissStreakDaysThenResetDismissStreakAndUpdateNextShownDate() = + runTest { + whenever(mockBrokenSiteReportRepository.getNextShownDate()).thenReturn(LocalDateTime.now().plusDays(5)) + whenever(mockBrokenSiteReportRepository.getDismissStreak()).thenReturn(2) + whenever(mockBrokenSiteReportRepository.getDismissStreakResetDays()).thenReturn(30) + whenever(mockCurrentTimeProvider.localDateTimeNow()).thenReturn(LocalDateTime.now()) + + testee.userDismissedPrompt() + + val argumentCaptor = argumentCaptor() + verify(mockBrokenSiteReportRepository).setNextShownDate(argumentCaptor.capture()) + assertEquals(LocalDateTime.now().plusDays(30).toLocalDate(), argumentCaptor.firstValue.toLocalDate()) + verify(mockBrokenSiteReportRepository).resetDismissStreak() + } + + @Test + fun whenUserDismissedPromptMaxDismissStreakTimesAndNextShownDateLaterThanDismissStreakDaysThenResetDismissStreakAndDoNotUpdateNextShownDate() = + runTest { + whenever(mockBrokenSiteReportRepository.getNextShownDate()).thenReturn(LocalDateTime.now().plusDays(11)) + whenever(mockBrokenSiteReportRepository.getDismissStreak()).thenReturn(2) + whenever(mockBrokenSiteReportRepository.getMaxDismissStreak()).thenReturn(3) + whenever(mockBrokenSiteReportRepository.getDismissStreakResetDays()).thenReturn(2) + whenever(mockCurrentTimeProvider.localDateTimeNow()).thenReturn(LocalDateTime.now()) + + testee.userDismissedPrompt() + + verify(mockBrokenSiteReportRepository, never()).setNextShownDate(any()) + verify(mockBrokenSiteReportRepository).resetDismissStreak() + } + + @Test + fun whenUserDismissPromptLessThanMaxDismissStreakTimesAndNextShownDateEarlierThanCooldownDaysThenIncrementDismissStreakAndNotSetNextShownDate() = + runTest { + whenever(mockBrokenSiteReportRepository.getNextShownDate()).thenReturn(LocalDateTime.now().plusDays(5)) + whenever(mockBrokenSiteReportRepository.getDismissStreak()).thenReturn(0) + + testee.userDismissedPrompt() + + verify(mockBrokenSiteReportRepository, never()).setNextShownDate(any()) + verify(mockBrokenSiteReportRepository).incrementDismissStreak() + } + + @Test + fun whenUserDismissedPromptAndFeatureDisabledThenDoNothing() = runTest { + fakeBrokenSitePromptRCFeature.self().setRawStoredState(State(false)) + + testee.userDismissedPrompt() + + verify(mockBrokenSiteReportRepository, never()).setNextShownDate(any()) + verify(mockBrokenSiteReportRepository, never()).incrementDismissStreak() + } + + @Test + fun whenUserAcceptedPromptThenResetDismissStreakAndSetNextShownDateToNull() = runTest { + testee.userAcceptedPrompt() + + verify(mockBrokenSiteReportRepository).resetDismissStreak() + } + + @Test + fun whenUserAcceptedPromptAndFeatureDisabledThenDoNothing() = runTest { + fakeBrokenSitePromptRCFeature.self().setRawStoredState(State(false)) + + testee.userAcceptedPrompt() + + verify(mockBrokenSiteReportRepository, never()).resetDismissStreak() + verify(mockBrokenSiteReportRepository, never()).setNextShownDate(any()) + } + + @Test + fun whenIncrementRefreshCountThenAddRefreshCalled() { + val now = LocalDateTime.now() + val url: Uri = org.mockito.kotlin.mock() + + whenever(mockCurrentTimeProvider.localDateTimeNow()).thenReturn(now) + testee.pageRefreshed(url) + + verify(mockBrokenSiteReportRepository).addRefresh(url, now) + } + + @Test + fun whenResetRefreshCountThenResetRefreshCountCalled() { + testee.resetRefreshCount() + + verify(mockBrokenSiteReportRepository).resetRefreshCount() + } + + @Test + fun whenGetUserRefreshesCountThenGetAndUpdateUserRefreshesBetweenCalled() { + val now = LocalDateTime.now() + whenever(mockCurrentTimeProvider.localDateTimeNow()).thenReturn(now) + + testee.getUserRefreshesCount() + + verify(mockBrokenSiteReportRepository).getAndUpdateUserRefreshesBetween(now.minusSeconds(REFRESH_COUNT_WINDOW), now) + } + + @Test + fun whenFeatureEnabledAndUserRefreshesCountIsThreeOrMoreThenShouldShowBrokenSitePromptReturnsTrue() = runTest { + whenever(mockCurrentTimeProvider.localDateTimeNow()).thenReturn(LocalDateTime.now()) + whenever(mockBrokenSiteReportRepository.getAndUpdateUserRefreshesBetween(any(), any())).thenReturn(REFRESH_COUNT_LIMIT) + + val result = testee.shouldShowBrokenSitePrompt("https://example.com") + + assertTrue(result) + } + + @Test + fun whenFeatureEnabledAndUserRefreshesCountIsLessThanThreeThenShouldShowBrokenSitePromptReturnsFalse() = runTest { + whenever(mockCurrentTimeProvider.localDateTimeNow()).thenReturn(LocalDateTime.now()) + whenever(mockBrokenSiteReportRepository.getAndUpdateUserRefreshesBetween(any(), any())).thenReturn(2) + + val result = testee.shouldShowBrokenSitePrompt("https://example.com") + + assertFalse(result) + } + + @Test + fun whenFeatureDisabledThenShouldShowBrokenSitePromptReturnsFalse() = runTest { + whenever(mockCurrentTimeProvider.localDateTimeNow()).thenReturn(LocalDateTime.now()) + fakeBrokenSitePromptRCFeature.self().setRawStoredState(State(false)) + + val result = testee.shouldShowBrokenSitePrompt("https://example.com") + + assertFalse(result) + } +} diff --git a/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSiteReportRepositoryTest.kt b/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSiteReportRepositoryTest.kt index 84b7220505c4..d788fd3fb355 100644 --- a/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSiteReportRepositoryTest.kt +++ b/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSiteReportRepositoryTest.kt @@ -16,10 +16,12 @@ package com.duckduckgo.brokensite.impl +import android.net.Uri import com.duckduckgo.brokensite.store.BrokenSiteDao import com.duckduckgo.brokensite.store.BrokenSiteDatabase import com.duckduckgo.brokensite.store.BrokenSiteLastSentReportEntity import com.duckduckgo.common.test.CoroutineTestRule +import java.time.LocalDateTime import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -38,6 +40,8 @@ class RealBrokenSiteReportRepositoryTest { private val mockDatabase: BrokenSiteDatabase = mock() private val mockBrokenSiteDao: BrokenSiteDao = mock() + private val mockDataStore: BrokenSitePomptDataStore = mock() + private val mockInMemoryStore: BrokenSitePromptInMemoryStore = mock() lateinit var testee: RealBrokenSiteReportRepository @Before @@ -48,6 +52,8 @@ class RealBrokenSiteReportRepositoryTest { database = mockDatabase, coroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, + brokenSitePromptDataStore = mockDataStore, + brokenSitePromptInMemoryStore = mockInMemoryStore, ) } @@ -109,4 +115,96 @@ class RealBrokenSiteReportRepositoryTest { verify(mockDatabase.brokenSiteDao()).deleteAllExpiredReports(any()) } + + @Test + fun whenSetMaxDismissStreakCalledThenSetMaxDismissStreakIsCalled() = runTest { + val maxDismissStreak = 3 + + testee.setMaxDismissStreak(maxDismissStreak) + + verify(mockDataStore).setMaxDismissStreak(maxDismissStreak) + } + + @Test + fun whenDismissStreakResetDaysCalledThenDismissStreakResetDaysIsCalled() = runTest { + val days = 30 + + testee.setDismissStreakResetDays(days) + + verify(mockDataStore).setDismissStreakResetDays(days) + } + + @Test + fun whenCoolDownDaysCalledThenCoolDownDaysIsCalled() = runTest { + val days = 7L + + testee.setCoolDownDays(days) + + verify(mockDataStore).setCoolDownDays(days) + } + + @Test + fun whenResetDismissStreakCalledThenDismissStreakIsSetToZero() = runTest { + testee.resetDismissStreak() + + verify(mockDataStore).setDismissStreak(0) + } + + @Test + fun whenSetNextShownDateCalledThenNextShownDateIsSet() = runTest { + val nextShownDate = LocalDateTime.now() + + testee.setNextShownDate(nextShownDate) + + verify(mockDataStore).setNextShownDate(nextShownDate) + } + + @Test + fun whenGetDismissStreakCalledThenReturnDismissStreak() = runTest { + val dismissStreak = 5 + whenever(mockDataStore.getDismissStreak()).thenReturn(dismissStreak) + + val result = testee.getDismissStreak() + + assertEquals(dismissStreak, result) + } + + @Test + fun whenGetNextShownDateCalledThenReturnNextShownDate() = runTest { + val nextShownDate = LocalDateTime.now() + whenever(mockDataStore.getNextShownDate()).thenReturn(nextShownDate) + + val result = testee.getNextShownDate() + + assertEquals(nextShownDate, result) + } + + @Test + fun whenResetRefreshCountCalledThenResetRefreshCountIsCalled() = runTest { + testee.resetRefreshCount() + + verify(mockInMemoryStore).resetRefreshCount() + } + + @Test + fun whenAddRefreshCalledThenAddRefreshIsCalled() = runTest { + val localDateTime = LocalDateTime.now() + val url: Uri = mock() + + testee.addRefresh(url, localDateTime) + + verify(mockInMemoryStore).addRefresh(url, localDateTime) + } + + @Test + fun whenGetAndUpdateUserRefreshesBetweenCalledThenReturnRefreshCount() = runTest { + val t1 = LocalDateTime.now().minusDays(1) + val t2 = LocalDateTime.now() + val refreshCount = 5 + whenever(mockInMemoryStore.getAndUpdateUserRefreshesBetween(t1, t2)).thenReturn(refreshCount) + + val result = testee.getAndUpdateUserRefreshesBetween(t1, t2) + + assertEquals(refreshCount, result) + } } diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabEntitiy.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabEntitiy.kt index 0aa047f4677e..677797119841 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabEntitiy.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabEntitiy.kt @@ -20,6 +20,9 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import androidx.room.TypeConverter +import com.duckduckgo.common.utils.formatters.time.DatabaseDateFormatter +import java.time.LocalDateTime @Entity( tableName = "tabs", @@ -37,16 +40,25 @@ import androidx.room.PrimaryKey ], ) data class TabEntity( - @PrimaryKey var tabId: String, - var url: String? = null, - var title: String? = null, - var skipHome: Boolean = false, - var viewed: Boolean = true, - var position: Int, - var tabPreviewFile: String? = null, - var sourceTabId: String? = null, - var deletable: Boolean = false, + @PrimaryKey val tabId: String, + val url: String? = null, + val title: String? = null, + val skipHome: Boolean = false, + val viewed: Boolean = true, + val position: Int = 0, + val tabPreviewFile: String? = null, + val sourceTabId: String? = null, + val deletable: Boolean = false, + val lastAccessTime: LocalDateTime? = null, ) val TabEntity.isBlank: Boolean get() = title == null && url == null + +class LocalDateTimeTypeConverter { + @TypeConverter + fun convertForDb(date: LocalDateTime): String = DatabaseDateFormatter.timestamp(date) + + @TypeConverter + fun convertFromDb(value: String?): LocalDateTime? = value?.let { LocalDateTime.parse(it) } +} 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 0e7e877c8020..41b0eff8d37e 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 @@ -71,6 +71,8 @@ interface TabRepository { suspend fun updateTabPosition(from: Int, to: Int) + suspend fun updateTabLastAccess(tabId: String) + /** * @return record if it exists, otherwise a new one */ @@ -114,4 +116,15 @@ interface TabRepository { suspend fun setIsUserNew(isUserNew: Boolean) suspend fun setTabLayoutType(layoutType: LayoutType) + + fun getOpenTabCount(): Int + + /** + * Returns the number of tabs, given a range of days within which the tab was last accessed. + * + * @param accessOlderThan the minimum number of days (exclusive) since the tab was last accessed + * @param accessNotMoreThan the maximum number of days (inclusive) since the tab was last accessed (optional) + * @return the number of tabs that are inactive + */ + fun countTabsAccessedWithinRange(accessOlderThan: Long, accessNotMoreThan: Long? = null): Int } diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteNav.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteNav.kt index 06ab1b0ea2ac..2c9dc477df7b 100644 --- a/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteNav.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/brokensite/BrokenSiteNav.kt @@ -44,7 +44,7 @@ data class BrokenSiteData( val openerContext: BrokenSiteOpenerContext?, val jsPerformance: DoubleArray?, ) { - enum class ReportFlow { MENU, DASHBOARD } + enum class ReportFlow { MENU, DASHBOARD, PROMPT } companion object { fun fromSite(site: Site?, reportFlow: ReportFlow): BrokenSiteData { diff --git a/common/common-test/build.gradle b/common/common-test/build.gradle index 755ba72bccb8..cc1099219493 100644 --- a/common/common-test/build.gradle +++ b/common/common-test/build.gradle @@ -14,6 +14,7 @@ dependencies { // Dependencies for this Module implementation project(path: ':common-utils') + implementation AndroidX.lifecycle.liveDataKtx implementation "io.reactivex.rxjava2:rxjava:_" implementation "io.reactivex.rxjava2:rxandroid:_" implementation "com.squareup.moshi:moshi-kotlin:_" diff --git a/common/common-test/src/main/java/com/duckduckgo/common/test/LiveDataTestExtensions.kt b/common/common-test/src/main/java/com/duckduckgo/common/test/LiveDataTestExtensions.kt new file mode 100644 index 000000000000..81f4d9f298b1 --- /dev/null +++ b/common/common-test/src/main/java/com/duckduckgo/common/test/LiveDataTestExtensions.kt @@ -0,0 +1,35 @@ +/* + * 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 + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +// https://stackoverflow.com/a/44991770/73479 +fun LiveData.blockingObserve(): T? { + var value: T? = null + val latch = CountDownLatch(1) + val innerObserver = Observer { + value = it + latch.countDown() + } + observeForever(innerObserver) + latch.await(2, TimeUnit.SECONDS) + return value +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/Component.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/Component.kt index 15801bef6c26..ce224e22ea39 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/Component.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/Component.kt @@ -44,5 +44,6 @@ enum class Component { SECTION_HEADER_LIST_ITEM, SINGLE_LINE_LIST_ITEM, TWO_LINE_LIST_ITEM, + SETTINGS_LIST_ITEM, SECTION_DIVIDER, } diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/ComponentViewHolder.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/ComponentViewHolder.kt index 7fce077d5bee..10816aec5641 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/ComponentViewHolder.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/ComponentViewHolder.kt @@ -384,6 +384,9 @@ sealed class ComponentViewHolder(val view: View) : RecyclerView.ViewHolder(view) } } + class SettingsListItemComponentViewHolder(parent: ViewGroup) : + ComponentViewHolder(inflate(parent, R.layout.component_settings)) + companion object { fun create( parent: ViewGroup, @@ -408,6 +411,7 @@ sealed class ComponentViewHolder(val view: View) : RecyclerView.ViewHolder(view) Component.SECTION_DIVIDER -> DividerComponentViewHolder(parent) Component.CARD -> CardComponentViewHolder(parent) Component.EXPANDABLE_LAYOUT -> ExpandableComponentViewHolder(parent) + Component.SETTINGS_LIST_ITEM -> SettingsListItemComponentViewHolder(parent) else -> { TODO() } diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/listitems/ComponentListItemsElementsFragment.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/listitems/ComponentListItemsElementsFragment.kt index e7797aab898f..e64636191f07 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/listitems/ComponentListItemsElementsFragment.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/listitems/ComponentListItemsElementsFragment.kt @@ -20,12 +20,13 @@ import com.duckduckgo.common.ui.themepreview.ui.component.Component import com.duckduckgo.common.ui.themepreview.ui.component.Component.MENU_ITEM import com.duckduckgo.common.ui.themepreview.ui.component.Component.POPUP_MENU_ITEM import com.duckduckgo.common.ui.themepreview.ui.component.Component.SECTION_HEADER_LIST_ITEM +import com.duckduckgo.common.ui.themepreview.ui.component.Component.SETTINGS_LIST_ITEM import com.duckduckgo.common.ui.themepreview.ui.component.Component.SINGLE_LINE_LIST_ITEM import com.duckduckgo.common.ui.themepreview.ui.component.Component.TWO_LINE_LIST_ITEM import com.duckduckgo.common.ui.themepreview.ui.component.ComponentFragment class ComponentListItemsElementsFragment : ComponentFragment() { override fun getComponents(): List { - return listOf(SECTION_HEADER_LIST_ITEM, SINGLE_LINE_LIST_ITEM, TWO_LINE_LIST_ITEM, MENU_ITEM, POPUP_MENU_ITEM) + return listOf(SECTION_HEADER_LIST_ITEM, SINGLE_LINE_LIST_ITEM, TWO_LINE_LIST_ITEM, SETTINGS_LIST_ITEM, MENU_ITEM, POPUP_MENU_ITEM) } } diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/StatusIndicator.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/StatusIndicator.kt new file mode 100644 index 000000000000..48b4be2b75a0 --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/StatusIndicator.kt @@ -0,0 +1,44 @@ +/* + * 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.ui.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.mobile.android.databinding.ViewStatusIndicatorBinding + +class StatusIndicator @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binding: ViewStatusIndicatorBinding by viewBinding() + + fun setStatus(isOn: Boolean) { + if (isOn) { + binding.icon.isEnabled = true + // TODO copy changes + binding.label.text = "On" + } else { + binding.icon.isEnabled = false + // TODO copy changes + binding.label.text = "Off" + } + } +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/SettingsListItem.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/SettingsListItem.kt new file mode 100644 index 000000000000..b1dbff16a977 --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/SettingsListItem.kt @@ -0,0 +1,88 @@ +/* + * 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.ui.view.listitem + +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import com.duckduckgo.common.ui.view.StatusIndicator +import com.duckduckgo.common.ui.view.gone +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.mobile.android.R +import com.duckduckgo.mobile.android.databinding.ViewSettingsListItemBinding + +class SettingsListItem @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.oneLineListItemStyle, +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binding: ViewSettingsListItemBinding by viewBinding() + + val primaryText: DaxTextView + get() = binding.primaryText + val leadingIcon: ImageView + get() = binding.leadingIcon + val betaPill: ImageView + get() = binding.betaPill + val statusIndicator: StatusIndicator + get() = binding.statusIndicator + + init { + context.obtainStyledAttributes( + attrs, + R.styleable.SettingsListItem, + 0, + R.style.Widget_DuckDuckGo_OneLineListItem, + ).apply { + + primaryText.text = getString(R.styleable.SettingsListItem_primaryText) + + val leadingIconRes = getResourceId(R.styleable.SettingsListItem_leadingIcon, 0) + if (leadingIconRes != 0) { + leadingIcon.setImageResource(leadingIconRes) + leadingIcon.show() + } else { + leadingIcon.gone() + } + + setPillVisible(getBoolean(R.styleable.SettingsListItem_showBetaPill, false)) + + val isOn = getBoolean(R.styleable.SettingsListItem_isOn, false) + statusIndicator.setStatus(isOn) + + recycle() + } + } + + /** Sets the item click listener */ + fun setClickListener(onClick: () -> Unit) { + binding.root.setOnClickListener { onClick() } + } + + fun setStatus(isOn: Boolean) { + statusIndicator.setStatus(isOn) + } + + private fun setPillVisible(isVisible: Boolean) { + betaPill.isVisible = isVisible + } +} diff --git a/common/common-ui/src/main/res/color/status_indicator_color_selector.xml b/common/common-ui/src/main/res/color/status_indicator_color_selector.xml new file mode 100644 index 000000000000..f6a2a866b685 --- /dev/null +++ b/common/common-ui/src/main/res/color/status_indicator_color_selector.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/common/common-ui/src/main/res/drawable/ic_accessibility_color_24.xml b/common/common-ui/src/main/res/drawable/ic_accessibility_color_24.xml new file mode 100644 index 000000000000..9405c25d9027 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_accessibility_color_24.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_add_widget_color_24.xml b/common/common-ui/src/main/res/drawable/ic_add_widget_color_24.xml new file mode 100644 index 000000000000..75cdf66e5150 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_add_widget_color_24.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_address_bar_position_bottom_color_24.xml b/common/common-ui/src/main/res/drawable/ic_address_bar_position_bottom_color_24.xml new file mode 100644 index 000000000000..ad58fc26bb22 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_address_bar_position_bottom_color_24.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_appearance_color_24.xml b/common/common-ui/src/main/res/drawable/ic_appearance_color_24.xml new file mode 100644 index 000000000000..79dd8196d0ff --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_appearance_color_24.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_cookie_color_24.xml b/common/common-ui/src/main/res/drawable/ic_cookie_color_24.xml new file mode 100644 index 000000000000..e5441f5635d5 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_cookie_color_24.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_default_browser_mobile_color_24.xml b/common/common-ui/src/main/res/drawable/ic_default_browser_mobile_color_24.xml new file mode 100644 index 000000000000..aa545c377c5e --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_default_browser_mobile_color_24.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_downloads_color_24.xml b/common/common-ui/src/main/res/drawable/ic_downloads_color_24.xml new file mode 100644 index 000000000000..6344bdabf7d3 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_downloads_color_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_email_protection_color_24.xml b/common/common-ui/src/main/res/drawable/ic_email_protection_color_24.xml new file mode 100644 index 000000000000..c6f600620067 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_email_protection_color_24.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_find_search_color_24.xml b/common/common-ui/src/main/res/drawable/ic_find_search_color_24.xml new file mode 100644 index 000000000000..328a7934ea77 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_find_search_color_24.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_fire_color_24.xml b/common/common-ui/src/main/res/drawable/ic_fire_color_24.xml new file mode 100644 index 000000000000..0a6f8168869d --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_fire_color_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_heart_gray_color_24.xml b/common/common-ui/src/main/res/drawable/ic_heart_gray_color_24.xml new file mode 100644 index 000000000000..9cdc407a618e --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_heart_gray_color_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_homescreen_lock_color_24.xml b/common/common-ui/src/main/res/drawable/ic_homescreen_lock_color_24.xml new file mode 100644 index 000000000000..76c8c58e9af4 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_homescreen_lock_color_24.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_identity_blocked_pir_color_24.xml b/common/common-ui/src/main/res/drawable/ic_identity_blocked_pir_color_24.xml new file mode 100644 index 000000000000..9ca7a07a4df0 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_identity_blocked_pir_color_24.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_identity_blocked_pir_grayscale_color_24.xml b/common/common-ui/src/main/res/drawable/ic_identity_blocked_pir_grayscale_color_24.xml new file mode 100644 index 000000000000..de4052e7cf09 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_identity_blocked_pir_grayscale_color_24.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_identity_theft_restoration_color_24.xml b/common/common-ui/src/main/res/drawable/ic_identity_theft_restoration_color_24.xml new file mode 100644 index 000000000000..a946854278a8 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_identity_theft_restoration_color_24.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_identity_theft_restoration_grayscale_color_24.xml b/common/common-ui/src/main/res/drawable/ic_identity_theft_restoration_grayscale_color_24.xml new file mode 100644 index 000000000000..96d02a09e4ea --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_identity_theft_restoration_grayscale_color_24.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_key_color_24.xml b/common/common-ui/src/main/res/drawable/ic_key_color_24.xml new file mode 100644 index 000000000000..4b152a801d18 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_key_color_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_lock_color_24.xml b/common/common-ui/src/main/res/drawable/ic_lock_color_24.xml new file mode 100644 index 000000000000..27162cb109fa --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_lock_color_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_microphone_color_24.xml b/common/common-ui/src/main/res/drawable/ic_microphone_color_24.xml new file mode 100644 index 000000000000..a6cf82f52e11 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_microphone_color_24.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_privacy_pro_color_24.xml b/common/common-ui/src/main/res/drawable/ic_privacy_pro_color_24.xml new file mode 100644 index 000000000000..1c64b3a19a68 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_privacy_pro_color_24.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_settings_color_24.xml b/common/common-ui/src/main/res/drawable/ic_settings_color_24.xml new file mode 100644 index 000000000000..962fad1ff515 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_settings_color_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_shield_color_24.xml b/common/common-ui/src/main/res/drawable/ic_shield_color_24.xml new file mode 100644 index 000000000000..6810b8f8e09c --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_shield_color_24.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_status_indicator.xml b/common/common-ui/src/main/res/drawable/ic_status_indicator.xml new file mode 100644 index 000000000000..51a4b6ea7700 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_status_indicator.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/common/common-ui/src/main/res/drawable/ic_sync_color_24.xml b/common/common-ui/src/main/res/drawable/ic_sync_color_24.xml new file mode 100644 index 000000000000..2ec36b36353f --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_sync_color_24.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_video_player_color_24.xml b/common/common-ui/src/main/res/drawable/ic_video_player_color_24.xml new file mode 100644 index 000000000000..76b467df5f62 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_video_player_color_24.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_vpn_color_24.xml b/common/common-ui/src/main/res/drawable/ic_vpn_color_24.xml new file mode 100644 index 000000000000..5cc01cc7fc4b --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_vpn_color_24.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/common/common-ui/src/main/res/drawable/ic_vpn_grayscale_color_24.xml b/common/common-ui/src/main/res/drawable/ic_vpn_grayscale_color_24.xml new file mode 100644 index 000000000000..cc27e807a685 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_vpn_grayscale_color_24.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/common/common-ui/src/main/res/layout/component_settings.xml b/common/common-ui/src/main/res/layout/component_settings.xml new file mode 100644 index 000000000000..a22c55224d89 --- /dev/null +++ b/common/common-ui/src/main/res/layout/component_settings.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/common-ui/src/main/res/layout/view_settings_list_item.xml b/common/common-ui/src/main/res/layout/view_settings_list_item.xml new file mode 100644 index 000000000000..f31d09a85c5a --- /dev/null +++ b/common/common-ui/src/main/res/layout/view_settings_list_item.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/common-ui/src/main/res/layout/view_status_indicator.xml b/common/common-ui/src/main/res/layout/view_status_indicator.xml new file mode 100644 index 000000000000..6d50d42d2088 --- /dev/null +++ b/common/common-ui/src/main/res/layout/view_status_indicator.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/common/common-ui/src/main/res/values/attrs-settings-list_item.xml b/common/common-ui/src/main/res/values/attrs-settings-list_item.xml new file mode 100644 index 000000000000..db021c437cf9 --- /dev/null +++ b/common/common-ui/src/main/res/values/attrs-settings-list_item.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/common/common-ui/src/main/res/values/design-system-colors.xml b/common/common-ui/src/main/res/values/design-system-colors.xml index b9c14b43201a..dd0e27a0a8cb 100644 --- a/common/common-ui/src/main/res/values/design-system-colors.xml +++ b/common/common-ui/src/main/res/values/design-system-colors.xml @@ -101,6 +101,7 @@ #59000000 + #21C000 #EB102D #2EEB102D #C10D25 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 e26016eb5b83..0fcda51138cd 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 @@ -64,6 +64,7 @@ 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 com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import java.io.InputStream import javax.inject.Inject @@ -98,6 +99,7 @@ interface DuckPlayerInternal : DuckPlayer { @ContributesBinding(AppScope::class, boundType = DuckPlayer::class) @ContributesBinding(AppScope::class, boundType = DuckPlayerInternal::class) +@ContributesMultibinding(AppScope::class, boundType = PrivacyConfigCallbackPlugin::class) class RealDuckPlayer @Inject constructor( private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, private val duckPlayerFeature: DuckPlayerFeature, diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ReturningUserToggleTargetMatcher.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ReturningUserToggleTargetMatcher.kt new file mode 100644 index 000000000000..be9b5f534b33 --- /dev/null +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ReturningUserToggleTargetMatcher.kt @@ -0,0 +1,37 @@ +/* + * 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 + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager +import com.duckduckgo.experiments.impl.reinstalls.REINSTALL_VARIANT +import com.duckduckgo.feature.toggles.api.Toggle.State.Target +import com.duckduckgo.feature.toggles.api.Toggle.TargetMatcherPlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class ReturningUserToggleTargetMatcher @Inject constructor( + private val variantManager: VariantManager, +) : TargetMatcherPlugin { + override fun matchesTargetProperty(target: Target): Boolean { + return target.isReturningUser?.let { isReturningUserTarget -> + val isReturningUser = variantManager.getVariantKey() == REINSTALL_VARIANT + isReturningUserTarget == isReturningUser + } ?: true + } +} diff --git a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/ReturningUserToggleTargetMatcherTest.kt b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/ReturningUserToggleTargetMatcherTest.kt new file mode 100644 index 000000000000..5a73edd08f24 --- /dev/null +++ b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/ReturningUserToggleTargetMatcherTest.kt @@ -0,0 +1,62 @@ +package com.duckduckgo.experiments.impl + +import com.duckduckgo.experiments.api.VariantManager +import com.duckduckgo.feature.toggles.api.Toggle +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ReturningUserToggleTargetMatcherTest { + private val variantManager: VariantManager = mock() + private val matcher = ReturningUserToggleTargetMatcher(variantManager) + + @Test + fun whenReturningUserAndNullTargetThenMatchesTargetReturnsTrue() { + whenever(variantManager.getVariantKey()).thenReturn("ru") + + assertTrue(matcher.matchesTargetProperty(NULL_TARGET)) + } + + @Test + fun whenNotReturningUserAndNullTargetThenMatchesTargetReturnsTrue() { + whenever(variantManager.getVariantKey()).thenReturn("foo") + + assertTrue(matcher.matchesTargetProperty(NULL_TARGET)) + } + + @Test + fun whenReturningUserAndTargetMatchesThenReturnTrue() { + whenever(variantManager.getVariantKey()).thenReturn("ru") + + assertTrue(matcher.matchesTargetProperty(RU_TARGET)) + } + + @Test + fun whenNoReturningUserAndTargetMatchesThenReturnTrue() { + whenever(variantManager.getVariantKey()).thenReturn("foo") + + assertTrue(matcher.matchesTargetProperty(NOT_RU_TARGET)) + } + + @Test + fun whenReturningUserAndTargetNotMatchingThenReturnFalse() { + whenever(variantManager.getVariantKey()).thenReturn("ru") + + assertFalse(matcher.matchesTargetProperty(NOT_RU_TARGET)) + } + + @Test + fun whenNoReturningUserAndTargetNotMatchingThenReturnTrue() { + whenever(variantManager.getVariantKey()).thenReturn("foo") + + assertFalse(matcher.matchesTargetProperty(RU_TARGET)) + } + + companion object { + private val NULL_TARGET = Toggle.State.Target(null, null, null, null, null) + private val RU_TARGET = NULL_TARGET.copy(isReturningUser = true) + private val NOT_RU_TARGET = NULL_TARGET.copy(isReturningUser = false) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index c57356ffaa95..5ee535601ef2 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -209,7 +209,7 @@ platform :android do version = props["VERSION"] releaseNotes = release_notes_github() apkPath = "app/build/outputs/apk/play/release/duckduckgo-#{version}-play-release.apk" - token = ENV["GITHUB_UPLOAD_TOKEN"] + token = ENV["GH_TOKEN"] UI.message ("Upload new app version to GitHub\nVersion: #{version}\nRelease Notes:\n=====\n#{releaseNotes}\n=====\n") 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 5b46860d3563..70d7fb646269 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 @@ -29,7 +29,6 @@ import java.lang.reflect.Proxy import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.ChronoUnit -import java.util.Locale import kotlin.random.Random import org.apache.commons.math3.distribution.EnumeratedIntegerDistribution @@ -39,7 +38,6 @@ class FeatureToggles private constructor( private val flavorNameProvider: () -> String, private val featureName: String, private val appVariantProvider: () -> String?, - private val localeProvider: () -> Locale?, private val forceDefaultVariant: () -> Unit, private val callback: FeatureTogglesCallback?, ) { @@ -52,7 +50,6 @@ class FeatureToggles private constructor( private var flavorNameProvider: () -> String = { "" }, private var featureName: String? = null, private var appVariantProvider: () -> String? = { "" }, - private var localeProvider: () -> Locale? = { Locale.getDefault() }, private var forceDefaultVariant: () -> Unit = { /** noop **/ }, private var callback: FeatureTogglesCallback? = null, ) { @@ -62,7 +59,6 @@ class FeatureToggles private constructor( fun flavorNameProvider(flavorNameProvider: () -> String) = apply { this.flavorNameProvider = flavorNameProvider } fun featureName(featureName: String) = apply { this.featureName = featureName } fun appVariantProvider(variantName: () -> String?) = apply { this.appVariantProvider = variantName } - fun localeProvider(locale: () -> Locale?) = apply { this.localeProvider = locale } fun forceDefaultVariantProvider(forceDefaultVariant: () -> Unit) = apply { this.forceDefaultVariant = forceDefaultVariant } fun callback(callback: FeatureTogglesCallback) = apply { this.callback = callback } fun build(): FeatureToggles { @@ -82,7 +78,6 @@ class FeatureToggles private constructor( flavorNameProvider = flavorNameProvider, featureName = featureName!!, appVariantProvider = appVariantProvider, - localeProvider = localeProvider, forceDefaultVariant = forceDefaultVariant, callback = this.callback, ) @@ -130,7 +125,6 @@ class FeatureToggles private constructor( appVersionProvider = appVersionProvider, flavorNameProvider = flavorNameProvider, appVariantProvider = appVariantProvider, - localeProvider = localeProvider, forceDefaultVariant = forceDefaultVariant, callback = callback, ).also { featureToggleCache[method] = it } @@ -230,6 +224,8 @@ interface Toggle { val variantKey: String?, val localeCountry: String?, val localeLanguage: String?, + val isReturningUser: Boolean?, + val isPrivacyProEligible: Boolean?, ) data class Cohort( val name: String, @@ -264,6 +260,18 @@ interface Toggle { fun get(key: String): State? } + /** + * It is possible to add feature [Target]s. + * To do that, just add the property inside the [Target] and implement the [TargetMatcherPlugin] to do the matching + */ + interface TargetMatcherPlugin { + /** + * Implement this method when adding a new target property. + * @return `true` if the target matches else false + */ + fun matchesTargetProperty(target: State.Target): Boolean + } + /** * This annotation is required. * It specifies the default value of the feature flag when it's not remotely defined @@ -301,7 +309,6 @@ internal class ToggleImpl constructor( private val appVersionProvider: () -> Int, private val flavorNameProvider: () -> String = { "" }, private val appVariantProvider: () -> String?, - private val localeProvider: () -> Locale?, private val forceDefaultVariant: () -> Unit, private val callback: FeatureTogglesCallback?, ) : Toggle { @@ -317,31 +324,6 @@ internal class ToggleImpl constructor( return this.featureName().hashCode() } - private fun Toggle.State.evaluateTargetMatching(isExperiment: Boolean): Boolean { - val variant = appVariantProvider.invoke() - // no targets then consider always treated - if (this.targets.isEmpty()) { - return true - } - // if it's an experiment we only check target variants and ignore all the rest - val variantTargets = this.targets.mapNotNull { it.variantKey } - if (isExperiment && variantTargets.isNotEmpty()) { - return variantTargets.contains(variant) - } - // finally, check all other targets - val countryTarget = this.targets.mapNotNull { it.localeCountry?.lowercase() } - val languageTarget = this.targets.mapNotNull { it.localeLanguage?.lowercase() } - - if (countryTarget.isNotEmpty() && !countryTarget.contains(localeProvider.invoke()?.country?.lowercase())) { - return false - } - if (languageTarget.isNotEmpty() && !languageTarget.contains(localeProvider.invoke()?.language?.lowercase())) { - return false - } - - return true - } - override fun featureName(): FeatureName { val parts = key.split("_") return if (parts.size == 2) { @@ -381,6 +363,27 @@ internal class ToggleImpl constructor( } private fun isRolloutEnabled(): Boolean { + // This fun is in there because it should never be called outside this method + fun Toggle.State.evaluateTargetMatching(isExperiment: Boolean): Boolean { + val variant = appVariantProvider.invoke() + // no targets then consider always treated + if (this.targets.isEmpty()) { + return true + } + // if it's an experiment we only check target variants and ignore all the rest + // this is because the (retention) experiments define their targets some place else + val variantTargets = this.targets.mapNotNull { it.variantKey } + if (isExperiment && variantTargets.isNotEmpty()) { + return variantTargets.contains(variant) + } + // finally, check all other targets + val nonVariantTargets = this.targets.filter { it.variantKey == null } + + // callback should never be null, but if it is, consider targets a match + return callback?.matchesToggleTargets(nonVariantTargets) ?: true + } + + // This fun is in there because it should never be called outside this method fun evaluateLocalEnable(state: State, isExperiment: Boolean): Boolean { // variants are only considered for Experiment feature flags val doTargetsMatch = state.evaluateTargetMatching(isExperiment) @@ -484,26 +487,12 @@ internal class ToggleImpl constructor( cohorts[randomIndex.sample()] }.getOrNull() } - fun containsAndMatchCohortTargets(targets: State.Target?): Boolean { - return targets?.let { - targets.localeLanguage?.let { targetLanguage -> - val deviceLocale = localeProvider.invoke() - if (deviceLocale?.language != targetLanguage) { - return false - } - } - targets.localeCountry?.let { targetCountry -> - val deviceLocale = localeProvider.invoke() - if (deviceLocale?.country != targetCountry) { - return false - } - } - return true - } ?: return true // no targets mean any target + fun containsAndMatchCohortTargets(targets: List): Boolean { + return callback?.matchesToggleTargets(targets) ?: true } // In the remote config, targets is a list, but it should not be. So we pick the first one (?) - if (!containsAndMatchCohortTargets(targets.firstOrNull())) { + if (!containsAndMatchCohortTargets(targets)) { return null } diff --git a/feature-toggles/feature-toggles-impl/lint-baseline.xml b/feature-toggles/feature-toggles-impl/lint-baseline.xml index 1a704e9c033e..e7d6f9d09fbd 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,40 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + + + + + @@ -118,7 +151,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -129,7 +162,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -140,7 +173,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -151,7 +184,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -162,7 +195,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -173,7 +206,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -184,7 +217,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -195,7 +228,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -206,7 +239,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -217,7 +250,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -228,7 +261,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -239,7 +272,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -250,7 +283,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -261,7 +294,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -272,7 +305,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -283,7 +316,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -294,7 +327,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -305,7 +338,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -316,7 +349,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -327,7 +360,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -338,7 +371,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -349,7 +382,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -360,7 +393,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -371,7 +404,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -382,7 +415,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -393,7 +426,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -404,7 +437,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -415,7 +448,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -426,7 +459,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -437,7 +470,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -448,7 +481,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -459,7 +492,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -470,7 +503,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -481,7 +514,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -492,7 +525,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -503,7 +536,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -514,7 +547,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -525,7 +558,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -536,7 +569,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -547,7 +580,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -558,7 +591,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -569,7 +602,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -580,7 +613,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -591,7 +624,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -602,7 +635,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -613,7 +646,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -624,7 +657,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -635,7 +668,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -646,7 +679,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -657,7 +690,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -668,7 +701,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -679,7 +712,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -690,7 +723,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -701,7 +734,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -712,7 +745,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -723,7 +756,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -734,7 +767,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -745,7 +778,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -756,7 +789,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -767,7 +800,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -778,7 +811,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -789,7 +822,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -800,7 +833,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -811,7 +844,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -822,7 +855,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -833,7 +866,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -844,7 +877,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> diff --git a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallback.kt b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallback.kt index 7d2fdf780273..c26b89e38424 100644 --- a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallback.kt +++ b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallback.kt @@ -16,11 +16,17 @@ package com.duckduckgo.feature.toggles.impl +import com.duckduckgo.anvil.annotations.ContributesPluginPoint import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle.State.Target +import com.duckduckgo.feature.toggles.api.Toggle.TargetMatcherPlugin import com.duckduckgo.feature.toggles.impl.FeatureTogglesPixelName.EXPERIMENT_ENROLLMENT import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import javax.inject.Inject @@ -29,7 +35,9 @@ import okio.ByteString.Companion.encode @ContributesBinding(AppScope::class) class RealFeatureTogglesCallback @Inject constructor( private val pixel: Pixel, + private val targetMatchers: PluginPoint, ) : FeatureTogglesCallback { + override fun onCohortAssigned( experimentName: String, cohortName: String, @@ -42,6 +50,23 @@ class RealFeatureTogglesCallback @Inject constructor( pixel.fire(pixelName = pixelName, parameters = params, type = Pixel.PixelType.Unique(tag = tag)) } + override fun matchesToggleTargets(targets: List): Boolean { + // no targets mean any target + if (targets.isEmpty()) return true + + targets.forEach { target -> + if (target is Target) { + val targetMatched = targetMatchers.getPlugins().all { it.matchesTargetProperty(target) } + // one target matched, return true already + if (targetMatched) { + return true + } + } + } + + return false + } + private fun getPixelName( experimentName: String, cohortName: String, @@ -53,3 +78,29 @@ class RealFeatureTogglesCallback @Inject constructor( internal enum class FeatureTogglesPixelName(override val pixelName: String) : Pixel.PixelName { EXPERIMENT_ENROLLMENT("experiment_enroll"), } + +@ContributesPluginPoint( + scope = AppScope::class, + boundType = TargetMatcherPlugin::class, +) +@Suppress("unused") +private interface TargetMatcherPluginTrigger + +@ContributesMultibinding(AppScope::class) +class LocaleToggleTargetMatcher @Inject constructor( + private val appBuildConfig: AppBuildConfig, +) : TargetMatcherPlugin { + override fun matchesTargetProperty(target: Target): Boolean { + val country = appBuildConfig.deviceLocale.country.lowercase() + val language = appBuildConfig.deviceLocale.language.lowercase() + + val isCountryMatching = target.localeCountry?.let { + country == it.lowercase() + } ?: true + val isLanguageMatching = target.localeLanguage?.let { + language == it.lowercase() + } ?: true + + return isCountryMatching && isLanguageMatching + } +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt index cdcb9173a7c6..4e3ee407b222 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt @@ -22,7 +22,6 @@ import com.duckduckgo.feature.toggles.api.Cohorts.TREATMENT import com.duckduckgo.feature.toggles.api.Toggle.FeatureName import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName -import com.duckduckgo.feature.toggles.api.Toggle.State.Target import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback import java.lang.IllegalStateException import kotlinx.coroutines.test.runTest @@ -156,7 +155,7 @@ class FeatureTogglesTest { toggleStore.set( "test_forcesDefaultVariant", State( - targets = listOf(Target("na", localeCountry = null, localeLanguage = null)), + targets = listOf(State.Target("na", localeCountry = null, localeLanguage = null, null, null)), ), ) assertNull(provider.variantKey) @@ -428,7 +427,7 @@ class FeatureTogglesTest { val state = Toggle.State( remoteEnableState = null, enable = true, - targets = listOf(Toggle.State.Target("ma", localeCountry = null, localeLanguage = null)), + targets = listOf(State.Target("ma", localeCountry = null, localeLanguage = null, null, null)), ) // Use directly the store because setRawStoredState() populates the local state when the remote state is null @@ -446,7 +445,7 @@ class FeatureTogglesTest { val state = Toggle.State( remoteEnableState = null, enable = true, - targets = listOf(Toggle.State.Target(provider.variantKey!!, localeCountry = null, localeLanguage = null)), + targets = listOf(State.Target(provider.variantKey!!, localeCountry = null, localeLanguage = null, null, null)), ) // Use directly the store because setRawStoredState() populates the local state when the remote state is null @@ -464,7 +463,7 @@ class FeatureTogglesTest { val state = Toggle.State( remoteEnableState = null, enable = true, - targets = listOf(Toggle.State.Target("zz", localeCountry = null, localeLanguage = null)), + targets = listOf(State.Target("zz", localeCountry = null, localeLanguage = null, null, null)), ) // Use directly the store because setRawStoredState() populates the local state when the remote state is null @@ -491,8 +490,8 @@ class FeatureTogglesTest { remoteEnableState = null, enable = true, targets = listOf( - Toggle.State.Target("ma", localeCountry = null, localeLanguage = null), - Toggle.State.Target("mb", localeCountry = null, localeLanguage = null), + State.Target("ma", localeCountry = null, localeLanguage = null, null, null), + State.Target("mb", localeCountry = null, localeLanguage = null, null, null), ), ) @@ -512,8 +511,8 @@ class FeatureTogglesTest { remoteEnableState = null, enable = true, targets = listOf( - Toggle.State.Target("ma", localeCountry = null, localeLanguage = null), - Toggle.State.Target("zz", localeCountry = null, localeLanguage = null), + State.Target("ma", localeCountry = null, localeLanguage = null, null, null), + State.Target("zz", localeCountry = null, localeLanguage = null, null, null), ), ) @@ -621,6 +620,10 @@ private class FakeFeatureTogglesCallback : FeatureTogglesCallback { this.enrollmentDate = enrollmentDate times++ } + + override fun matchesToggleTargets(targets: List): Boolean { + return true + } } private enum class Cohorts(override val cohortName: String) : CohortName { 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 ddf18a59e6b4..2c7a07872b0a 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 @@ -33,6 +33,7 @@ import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName import com.duckduckgo.feature.toggles.codegen.ContributesRemoteFeatureCodeGeneratorTest.Cohorts.BLUE import com.duckduckgo.feature.toggles.codegen.ContributesRemoteFeatureCodeGeneratorTest.Cohorts.CONTROL +import com.duckduckgo.feature.toggles.fakes.FakeFeatureTogglesCallback import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding @@ -66,6 +67,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { private val appBuildConfig: AppBuildConfig = mock() private lateinit var variantManager: FakeVariantManager private lateinit var toggleStore: FakeToggleStore + private val featureTogglesCallback = FakeFeatureTogglesCallback() @Before fun setup() { @@ -76,16 +78,15 @@ class ContributesRemoteFeatureCodeGeneratorTest { toggleStore, featureName = "testFeature", appVersionProvider = { appBuildConfig.versionCode }, - localeProvider = { appBuildConfig.deviceLocale }, flavorNameProvider = { appBuildConfig.flavor.name }, appVariantProvider = { variantManager.getVariantKey() }, forceDefaultVariant = { variantManager.updateVariants(emptyList()) }, + callback = featureTogglesCallback, ).build().create(TestTriggerFeature::class.java) anotherTestFeature = FeatureToggles.Builder( toggleStore, featureName = "testFeature", appVersionProvider = { appBuildConfig.versionCode }, - localeProvider = { appBuildConfig.deviceLocale }, flavorNameProvider = { appBuildConfig.flavor.name }, appVariantProvider = { variantManager.getVariantKey() }, forceDefaultVariant = { variantManager.updateVariants(emptyList()) }, @@ -1523,7 +1524,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { val feature = generatedFeatureNewInstance() val privacyPlugin = (feature as PrivacyFeaturePlugin) - whenever(appBuildConfig.deviceLocale).thenReturn(Locale(Locale.FRANCE.language, Locale.US.country)) + featureTogglesCallback.locale = Locale(Locale.FRANCE.language, Locale.US.country) // all disabled assertTrue( @@ -1552,17 +1553,17 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertTrue(testFeature.self().isEnabled()) assertTrue(testFeature.fooFeature().isEnabled()) assertEquals( - listOf(Toggle.State.Target("mc", "US", "fr")), + listOf(Toggle.State.Target("mc", "US", "fr", null, null)), testFeature.fooFeature().getRawStoredState()!!.targets, ) } @Test - fun `test feature with multiple targets not matching`() { + fun `test multiple languages`() { val feature = generatedFeatureNewInstance() val privacyPlugin = (feature as PrivacyFeaturePlugin) - whenever(appBuildConfig.deviceLocale).thenReturn(Locale.FRANCE) + featureTogglesCallback.locale = Locale(Locale.FRANCE.language, Locale.US.country) // all disabled assertTrue( @@ -1576,8 +1577,11 @@ class ContributesRemoteFeatureCodeGeneratorTest { "state": "enabled", "targets": [ { - "variantKey": "mc", "localeCountry": "US", + "localeLanguage": "en" + }, + { + "localeCountry": "FR", "localeLanguage": "fr" } ] @@ -1591,7 +1595,71 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertTrue(testFeature.self().isEnabled()) assertFalse(testFeature.fooFeature().isEnabled()) assertEquals( - listOf(Toggle.State.Target("mc", "US", "fr")), + listOf( + Toggle.State.Target(null, "US", "en", null, null), + Toggle.State.Target(null, "FR", "fr", null, null), + ), + testFeature.fooFeature().getRawStoredState()!!.targets, + ) + + featureTogglesCallback.locale = Locale.US + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + assertEquals( + listOf( + Toggle.State.Target(null, "US", "en", null, null), + Toggle.State.Target(null, "FR", "fr", null, null), + ), + testFeature.fooFeature().getRawStoredState()!!.targets, + ) + + featureTogglesCallback.locale = Locale.FRANCE + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + assertEquals( + listOf( + Toggle.State.Target(null, "US", "en", null, null), + Toggle.State.Target(null, "FR", "fr", null, null), + ), + testFeature.fooFeature().getRawStoredState()!!.targets, + ) + } + + @Test + fun `test feature with multiple targets not matching`() { + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + featureTogglesCallback.locale = Locale.FRANCE + + // all disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "targets": [ + { + "variantKey": "mc", + "localeCountry": "US", + "localeLanguage": "fr" + } + ] + } + } + } + """.trimIndent(), + ), + ) + + // foo feature is not an experiment and the target has a variantKey. As this is a mistake, that target is invalidated, hence assertTrue + assertTrue(testFeature.fooFeature().isEnabled()) + assertEquals( + listOf(Toggle.State.Target("mc", "US", "fr", null, null)), testFeature.fooFeature().getRawStoredState()!!.targets, ) } @@ -1635,9 +1703,9 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertTrue(testFeature.fooFeature().isEnabled()) assertEquals( listOf( - Toggle.State.Target("mc", null, null), - Toggle.State.Target(null, "US", null), - Toggle.State.Target(null, null, "fr"), + Toggle.State.Target("mc", null, null, null, null), + Toggle.State.Target(null, "US", null, null, null), + Toggle.State.Target(null, null, "fr", null, null), ), testFeature.fooFeature().getRawStoredState()!!.targets, ) @@ -1648,7 +1716,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { val feature = generatedFeatureNewInstance() val privacyPlugin = (feature as PrivacyFeaturePlugin) - whenever(appBuildConfig.deviceLocale).thenReturn(Locale.FRANCE) + featureTogglesCallback.locale = Locale.FRANCE // all disabled assertTrue( @@ -1668,7 +1736,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { "localeCountry": "US" }, { - "localeLanguage": "fr" + "localeLanguage": "zh" } ] } @@ -1682,9 +1750,9 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertFalse(testFeature.fooFeature().isEnabled()) assertEquals( listOf( - Toggle.State.Target("mc", null, null), - Toggle.State.Target(null, "US", null), - Toggle.State.Target(null, null, "fr"), + Toggle.State.Target("mc", null, null, null, null), + Toggle.State.Target(null, "US", null, null, null), + Toggle.State.Target(null, null, "zh", null, null), ), testFeature.fooFeature().getRawStoredState()!!.targets, ) @@ -1774,8 +1842,8 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertTrue(testFeature.fooFeature().isEnabled()) assertEquals( listOf( - Toggle.State.Target("ma", localeCountry = null, localeLanguage = null), - Toggle.State.Target("mb", localeCountry = null, localeLanguage = null), + Toggle.State.Target("ma", localeCountry = null, localeLanguage = null, null, null), + Toggle.State.Target("mb", localeCountry = null, localeLanguage = null, null, null), ), testFeature.fooFeature().getRawStoredState()!!.targets, ) @@ -1783,8 +1851,8 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertFalse(testFeature.experimentFooFeature().isEnabled()) assertEquals( listOf( - Toggle.State.Target("ma", localeCountry = null, localeLanguage = null), - Toggle.State.Target("mb", localeCountry = null, localeLanguage = null), + Toggle.State.Target("ma", localeCountry = null, localeLanguage = null, null, null), + Toggle.State.Target("mb", localeCountry = null, localeLanguage = null, null, null), ), testFeature.experimentFooFeature().getRawStoredState()!!.targets, ) @@ -1792,7 +1860,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertFalse(testFeature.variantFeature().isEnabled()) assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = null, localeLanguage = null), + Toggle.State.Target("mc", localeCountry = null, localeLanguage = null, null, null), ), testFeature.variantFeature().getRawStoredState()!!.targets, ) @@ -1850,8 +1918,8 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertFalse(testFeature.experimentFooFeature().isEnabled()) assertEquals( listOf( - Toggle.State.Target("ma", localeCountry = null, localeLanguage = null), - Toggle.State.Target("mb", localeCountry = null, localeLanguage = null), + Toggle.State.Target("ma", localeCountry = null, localeLanguage = null, null, null), + Toggle.State.Target("mb", localeCountry = null, localeLanguage = null, null, null), ), testFeature.experimentFooFeature().getRawStoredState()!!.targets, ) @@ -1860,7 +1928,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertEquals(1, variantManager.saveVariantsCallCounter) assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = null, localeLanguage = null), + Toggle.State.Target("mc", localeCountry = null, localeLanguage = null, null, null), ), testFeature.variantFeature().getRawStoredState()!!.targets, ) @@ -1915,8 +1983,8 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertEquals("na", variantManager.variant) assertEquals( listOf( - Toggle.State.Target("ma", localeCountry = null, localeLanguage = null), - Toggle.State.Target("mb", localeCountry = null, localeLanguage = null), + Toggle.State.Target("ma", localeCountry = null, localeLanguage = null, null, null), + Toggle.State.Target("mb", localeCountry = null, localeLanguage = null, null, null), ), testFeature.experimentFooFeature().getRawStoredState()!!.targets, ) @@ -1925,7 +1993,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertEquals("na", variantManager.variant) assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = null, localeLanguage = null), + Toggle.State.Target("mc", localeCountry = null, localeLanguage = null, null, null), ), testFeature.variantFeature().getRawStoredState()!!.targets, ) @@ -1966,7 +2034,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertEquals("", variantManager.getVariantKey()) assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = null, localeLanguage = null), + Toggle.State.Target("mc", localeCountry = null, localeLanguage = null, null, null), ), testFeature.experimentDisabledByDefault().getRawStoredState()!!.targets, ) @@ -2007,7 +2075,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertEquals("", variantManager.getVariantKey()) assertEquals( listOf( - Toggle.State.Target("", localeCountry = null, localeLanguage = null), + Toggle.State.Target("", localeCountry = null, localeLanguage = null, null, null), ), testFeature.experimentDisabledByDefault().getRawStoredState()!!.targets, ) @@ -2048,7 +2116,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertEquals("mc", variantManager.getVariantKey()) assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = null, localeLanguage = null), + Toggle.State.Target("mc", localeCountry = null, localeLanguage = null, null, null), ), testFeature.experimentDisabledByDefault().getRawStoredState()!!.targets, ) @@ -2089,7 +2157,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertTrue(testFeature.experimentDisabledByDefault().isEnabled()) // true because experiments only check variantKey assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = "US", localeLanguage = null), + Toggle.State.Target("mc", localeCountry = "US", localeLanguage = null, null, null), ), testFeature.experimentDisabledByDefault().getRawStoredState()!!.targets, ) @@ -2120,7 +2188,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertTrue(testFeature.experimentDisabledByDefault().isEnabled()) // true because experiments only check variantKey assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = null, localeLanguage = "US"), + Toggle.State.Target("mc", localeCountry = null, localeLanguage = "US", null, null), ), testFeature.experimentDisabledByDefault().getRawStoredState()!!.targets, ) @@ -2150,7 +2218,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertTrue(testFeature.experimentDisabledByDefault().isEnabled()) assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = null, localeLanguage = null), + Toggle.State.Target("mc", localeCountry = null, localeLanguage = null, null, null), ), testFeature.experimentDisabledByDefault().getRawStoredState()!!.targets, ) @@ -2180,7 +2248,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertFalse(testFeature.experimentDisabledByDefault().isEnabled()) // true because experiments only check variantKey assertEquals( listOf( - Toggle.State.Target("ma", localeCountry = null, localeLanguage = null), + Toggle.State.Target("ma", localeCountry = null, localeLanguage = null, null, null), ), testFeature.experimentDisabledByDefault().getRawStoredState()!!.targets, ) @@ -2222,7 +2290,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertTrue(testFeature.experimentDisabledByDefault().isEnabled()) assertEquals( listOf( - Toggle.State.Target("mc", localeCountry = "US", localeLanguage = null), + Toggle.State.Target("mc", localeCountry = "US", localeLanguage = null, null, null), ), testFeature.experimentDisabledByDefault().getRawStoredState()!!.targets, ) @@ -2848,7 +2916,7 @@ class ContributesRemoteFeatureCodeGeneratorTest { val privacyPlugin = (feature as PrivacyFeaturePlugin) - whenever(appBuildConfig.deviceLocale).thenReturn(Locale(Locale.FRANCE.language, Locale.US.country)) + featureTogglesCallback.locale = Locale(Locale.FRANCE.language, Locale.US.country) assertTrue( privacyPlugin.store( @@ -2893,15 +2961,15 @@ class ContributesRemoteFeatureCodeGeneratorTest { assertFalse(testFeature.fooFeature().isEnabled(CONTROL)) assertFalse(testFeature.fooFeature().isEnabled(BLUE)) - whenever(appBuildConfig.deviceLocale).thenReturn(Locale(Locale.US.language, Locale.FRANCE.country)) + featureTogglesCallback.locale = Locale(Locale.US.language, Locale.FRANCE.country) assertFalse(testFeature.fooFeature().isEnabled(CONTROL)) assertFalse(testFeature.fooFeature().isEnabled(BLUE)) - whenever(appBuildConfig.deviceLocale).thenReturn(Locale.US) + featureTogglesCallback.locale = Locale.US assertFalse(testFeature.fooFeature().isEnabled(CONTROL)) assertFalse(testFeature.fooFeature().isEnabled(BLUE)) - whenever(appBuildConfig.deviceLocale).thenReturn(Locale.FRANCE) + featureTogglesCallback.locale = Locale.FRANCE assertTrue(testFeature.fooFeature().isEnabled(CONTROL)) assertFalse(testFeature.fooFeature().isEnabled(BLUE)) diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/fakes/FakeFeatureTogglesCallback.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/fakes/FakeFeatureTogglesCallback.kt new file mode 100644 index 000000000000..1d402ab20aec --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/fakes/FakeFeatureTogglesCallback.kt @@ -0,0 +1,72 @@ +/* + * 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.feature.toggles.fakes + +import com.duckduckgo.feature.toggles.api.Toggle.State.Target +import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback +import java.util.* + +class FakeFeatureTogglesCallback constructor() : FeatureTogglesCallback { + var locale = Locale.US + var isReturningUser = true + var isPrivacyProEligible = false + + override fun onCohortAssigned( + experimentName: String, + cohortName: String, + enrollmentDate: String, + ) { + // no-op + } + + override fun matchesToggleTargets(targets: List): Boolean { + if (targets.isEmpty()) return true + + targets.forEach { target -> + if (target is Target) { + if (matchTarget(target)) { + return true + } + } else { + throw RuntimeException("targets shall be of type Toggle.State.Target") + } + } + + return false + } + + private fun matchTarget(target: Target): Boolean { + fun matchLocale(targetCountry: String?, targetLanguage: String?): Boolean { + val countryMatches = targetCountry?.let { locale.country.lowercase() == targetCountry.lowercase() } ?: true + val languageMatches = targetLanguage?.let { locale.language.lowercase() == targetLanguage.lowercase() } ?: true + + return countryMatches && languageMatches + } + + fun matchReturningUser(targetIsReturningUser: Boolean?): Boolean { + return targetIsReturningUser?.let { targetIsReturningUser == isReturningUser } ?: true + } + + fun matchPrivacyProEligible(targetPProEligible: Boolean?): Boolean { + return targetPProEligible?.let { targetPProEligible == isPrivacyProEligible } ?: true + } + + return matchLocale(target.localeCountry, target.localeLanguage) && + matchPrivacyProEligible(target.isPrivacyProEligible) && + matchReturningUser(target.isReturningUser) + } +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/LocaleToggleTargetMatcherTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/LocaleToggleTargetMatcherTest.kt new file mode 100644 index 000000000000..20eaeff2d97d --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/LocaleToggleTargetMatcherTest.kt @@ -0,0 +1,82 @@ +package com.duckduckgo.feature.toggles.impl + +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.feature.toggles.api.Toggle +import java.util.Locale +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class LocaleToggleTargetMatcherTest { + private val appBuildConfig: AppBuildConfig = mock() + private val matcher = LocaleToggleTargetMatcher(appBuildConfig) + + @Test + fun whenAnyLocaleAndNullTargetThenTrue() { + whenever(appBuildConfig.deviceLocale).thenReturn(Locale.US) + + assertTrue(matcher.matchesTargetProperty(NULL_TARGET)) + } + + @Test + fun whenLocaleCountryNotMatchingTargetThenFalse() { + whenever(appBuildConfig.deviceLocale).thenReturn(Locale.CHINA) + + assertFalse(matcher.matchesTargetProperty(US_COUNTRY_TARGET)) + } + + @Test + fun whenLocaleLanguageNotMatchingTargetThenFalse() { + whenever(appBuildConfig.deviceLocale).thenReturn(Locale.CHINA) + + assertFalse(matcher.matchesTargetProperty(US_LANG_TARGET)) + } + + @Test + fun whenLocaleLanguageMatchesButNotCountryThenFalse() { + whenever(appBuildConfig.deviceLocale).thenReturn(Locale(Locale.US.language, Locale.FRANCE.country)) + + assertFalse(matcher.matchesTargetProperty(US_TARGET)) + } + + @Test + fun whenLocaleLanguageMatchesThenTrue() { + whenever(appBuildConfig.deviceLocale).thenReturn(Locale(Locale.US.language, Locale.FRANCE.country)) + + assertTrue(matcher.matchesTargetProperty(US_LANG_TARGET)) + } + + @Test + fun whenLocaleCountryMatchesButNotLanguageThenFalse() { + whenever(appBuildConfig.deviceLocale).thenReturn(Locale(Locale.FRANCE.language, Locale.US.country)) + + assertFalse(matcher.matchesTargetProperty(US_TARGET)) + } + + @Test + fun whenLocaleCountryMatchesThenTrue() { + whenever(appBuildConfig.deviceLocale).thenReturn(Locale(Locale.FRANCE.language, Locale.US.country)) + + assertTrue(matcher.matchesTargetProperty(US_COUNTRY_TARGET)) + } + + @Test + fun testIgnoreCasing() { + whenever(appBuildConfig.deviceLocale).thenReturn(Locale.US) + + assertTrue(matcher.matchesTargetProperty(US_TARGET_LOWERCASE)) + } + + companion object { + private val NULL_TARGET = Toggle.State.Target(null, null, null, null, null) + private val US_COUNTRY_TARGET = NULL_TARGET.copy(localeCountry = Locale.US.country) + private val US_LANG_TARGET = NULL_TARGET.copy(localeLanguage = Locale.US.language) + private val US_TARGET = NULL_TARGET.copy(localeLanguage = Locale.US.language, localeCountry = Locale.US.country) + private val US_TARGET_LOWERCASE = NULL_TARGET.copy( + localeLanguage = Locale.US.language.lowercase(), + localeCountry = Locale.US.country.lowercase(), + ) + } +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallbackTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallbackTest.kt index a8e1a5578daa..8c00615f3ab4 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallbackTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallbackTest.kt @@ -2,6 +2,8 @@ package com.duckduckgo.feature.toggles.impl import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.feature.toggles.api.Toggle.TargetMatcherPlugin import okio.ByteString.Companion.encode import org.junit.Test import org.mockito.Mockito.mock @@ -9,7 +11,12 @@ import org.mockito.kotlin.verify class RealFeatureTogglesCallbackTest { private val pixel: Pixel = mock() - private val callback = RealFeatureTogglesCallback(pixel) + private val plugins: PluginPoint = object : PluginPoint { + override fun getPlugins(): Collection { + TODO("Not yet implemented") + } + } + private val callback = RealFeatureTogglesCallback(pixel, plugins) @Test fun `test pixel is sent with correct parameters`() { diff --git a/feature-toggles/feature-toggles-internal-api/src/main/java/com/duckduckgo/feature/toggles/internal/api/FeatureTogglesCallback.kt b/feature-toggles/feature-toggles-internal-api/src/main/java/com/duckduckgo/feature/toggles/internal/api/FeatureTogglesCallback.kt index 087c0e253d11..7fe8df361614 100644 --- a/feature-toggles/feature-toggles-internal-api/src/main/java/com/duckduckgo/feature/toggles/internal/api/FeatureTogglesCallback.kt +++ b/feature-toggles/feature-toggles-internal-api/src/main/java/com/duckduckgo/feature/toggles/internal/api/FeatureTogglesCallback.kt @@ -26,4 +26,9 @@ interface FeatureTogglesCallback { * This method is called whenever a cohort is assigned to the FeatureToggle */ fun onCohortAssigned(experimentName: String, cohortName: String, enrollmentDate: String) + + /** + * @return `true` if the ANY of the remote feature targets match the device configuration, `false` otherwise + */ + fun matchesToggleTargets(targets: List): Boolean } diff --git a/auth-jwt/auth-jwt-api/.gitignore b/malicious-site-protection/malicious-site-protection-api/.gitignore similarity index 100% rename from auth-jwt/auth-jwt-api/.gitignore rename to malicious-site-protection/malicious-site-protection-api/.gitignore diff --git a/auth-jwt/auth-jwt-api/build.gradle b/malicious-site-protection/malicious-site-protection-api/build.gradle similarity index 71% rename from auth-jwt/auth-jwt-api/build.gradle rename to malicious-site-protection/malicious-site-protection-api/build.gradle index af7750526bff..c552e49e7c43 100644 --- a/auth-jwt/auth-jwt-api/build.gradle +++ b/malicious-site-protection/malicious-site-protection-api/build.gradle @@ -15,17 +15,16 @@ */ plugins { - id 'java-library' - id 'kotlin' + id 'com.android.library' + id 'kotlin-android' } -apply from: "$rootProject.projectDir/code-formatting.gradle" +apply from: "$rootProject.projectDir/gradle/android-library.gradle" -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 +android { + namespace 'com.duckduckgo.malicioussiteprotection.api' } -kotlin { - jvmToolchain(17) +dependencies { + implementation Kotlin.stdlib.jdk7 } diff --git a/malicious-site-protection/malicious-site-protection-api/src/main/kotlin/com/duckduckgo/malicioussiteprotection/api/MaliciousSiteProtection.kt b/malicious-site-protection/malicious-site-protection-api/src/main/kotlin/com/duckduckgo/malicioussiteprotection/api/MaliciousSiteProtection.kt new file mode 100644 index 000000000000..ac25c78c3083 --- /dev/null +++ b/malicious-site-protection/malicious-site-protection-api/src/main/kotlin/com/duckduckgo/malicioussiteprotection/api/MaliciousSiteProtection.kt @@ -0,0 +1,19 @@ +/* + * 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.malicioussiteprotection.api + +interface MaliciousSiteProtection diff --git a/auth-jwt/auth-jwt-impl/build.gradle b/malicious-site-protection/malicious-site-protection-impl/build.gradle similarity index 70% rename from auth-jwt/auth-jwt-impl/build.gradle rename to malicious-site-protection/malicious-site-protection-impl/build.gradle index 81d42735b010..8e5767047da1 100644 --- a/auth-jwt/auth-jwt-impl/build.gradle +++ b/malicious-site-protection/malicious-site-protection-impl/build.gradle @@ -24,37 +24,46 @@ plugins { apply from: "$rootProject.projectDir/gradle/android-library.gradle" dependencies { - implementation project(":auth-jwt-api") + implementation project(":malicious-site-protection-api") anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') implementation project(path: ':di') - implementation project(path: ':common-utils') + ksp AndroidX.room.compiler + implementation KotlinX.coroutines.android + implementation AndroidX.core.ktx implementation Google.dagger + implementation project(path: ':common-utils') + implementation "com.squareup.logcat:logcat:_" + implementation JakeWharton.timber - implementation "io.jsonwebtoken:jjwt-api:_" - runtimeOnly "io.jsonwebtoken:jjwt-impl:_" - runtimeOnly("io.jsonwebtoken:jjwt-orgjson:_") { - exclude(group: 'org.json', module: 'json') // provided by Android natively - } + implementation Google.android.material testImplementation Testing.junit4 testImplementation "org.mockito.kotlin:mockito-kotlin:_" - testImplementation Testing.robolectric - testImplementation AndroidX.test.ext.junit testImplementation project(path: ':common-test') + testImplementation CashApp.turbine + testImplementation Testing.robolectric + testImplementation(KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } coreLibraryDesugaring Android.tools.desugarJdkLibs } android { - namespace "com.duckduckgo.authjwt.impl" + namespace "com.duckduckgo.malicioussiteprotection.impl" anvil { generateDaggerFactories = true // default is false } + lint { + baseline file("lint-baseline.xml") + } testOptions { unitTests { includeAndroidResources = true diff --git a/malicious-site-protection/malicious-site-protection-impl/lint-baseline.xml b/malicious-site-protection/malicious-site-protection-impl/lint-baseline.xml new file mode 100644 index 000000000000..c584e1295716 --- /dev/null +++ b/malicious-site-protection/malicious-site-protection-impl/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/MaliciousSiteProtectionFeature.kt b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/MaliciousSiteProtectionFeature.kt new file mode 100644 index 000000000000..bf3b6892c6cf --- /dev/null +++ b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/MaliciousSiteProtectionFeature.kt @@ -0,0 +1,38 @@ +/* + * 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.malicioussiteprotection.impl + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "maliciousSiteProtection", +) +/** + * This is the class that represents the maliciousSiteProtection feature flags + */ +interface MaliciousSiteProtectionFeature { + /** + * @return `true` when the remote config has the global "maliciousSiteProtection" feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @Toggle.InternalAlwaysEnabled + @Toggle.DefaultValue(false) + fun self(): Toggle +} diff --git a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/RealMaliciousSiteProtection.kt b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/RealMaliciousSiteProtection.kt new file mode 100644 index 000000000000..7cb11d7ccb00 --- /dev/null +++ b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/RealMaliciousSiteProtection.kt @@ -0,0 +1,66 @@ +/* + * 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.malicioussiteprotection.impl + +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.malicioussiteprotection.api.MaliciousSiteProtection +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.json.JSONObject + +@ContributesBinding(AppScope::class, MaliciousSiteProtection::class) +@ContributesMultibinding(AppScope::class, PrivacyConfigCallbackPlugin::class) +class RealMaliciousSiteProtection @Inject constructor( + private val dispatchers: DispatcherProvider, + private val maliciousSiteProtectionFeature: MaliciousSiteProtectionFeature, + @IsMainProcess private val isMainProcess: Boolean, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : MaliciousSiteProtection, PrivacyConfigCallbackPlugin { + + private var isFeatureEnabled = false + private var hashPrefixUpdateFrequency = 20L + private var filterSetUpdateFrequency = 720L + + init { + if (isMainProcess) { + loadToMemory() + } + } + + override fun onPrivacyConfigDownloaded() { + loadToMemory() + } + + private fun loadToMemory() { + appCoroutineScope.launch(dispatchers.io()) { + isFeatureEnabled = maliciousSiteProtectionFeature.self().isEnabled() + maliciousSiteProtectionFeature.self().getSettings()?.let { + JSONObject(it).let { settings -> + hashPrefixUpdateFrequency = settings.getLong("hashPrefixUpdateFrequency") + filterSetUpdateFrequency = settings.getLong("filterSetUpdateFrequency") + } + } + } + } +} diff --git a/malicious-site-protection/readme.md b/malicious-site-protection/readme.md new file mode 100644 index 000000000000..6fdea2dd377c --- /dev/null +++ b/malicious-site-protection/readme.md @@ -0,0 +1,9 @@ +# Feature Name + +In-browser feature to detect phishing and malware sites. + +## Who can help you better understand this feature? +- ❓ Cris Barreiro + +## More information +- [Asana: feature documentation](https://app.asana.com/0/1208717418466383/1199621914667995/f) \ No newline at end of file diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/volume/NetpDataVolumeStore.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/volume/NetpDataVolumeStore.kt index 35b0dfdedda0..90a7e2064af9 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/volume/NetpDataVolumeStore.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/volume/NetpDataVolumeStore.kt @@ -35,13 +35,19 @@ class RealNetpDataVolumeStore @Inject constructor( private val networkProtectionPrefs: NetworkProtectionPrefs, ) : NetpDataVolumeStore { override var dataVolume: DataVolume - get() = DataVolume( - receivedBytes = networkProtectionPrefs.getLong(KEY_RECEIVED_BYTES, 0L), - transmittedBytes = networkProtectionPrefs.getLong(KEY_TRANSMITTED_BYTES, 0L), - ) + get() { + return kotlin.runCatching { + DataVolume( + receivedBytes = networkProtectionPrefs.getLong(KEY_RECEIVED_BYTES, 0L), + transmittedBytes = networkProtectionPrefs.getLong(KEY_TRANSMITTED_BYTES, 0L), + ) + }.getOrDefault(DataVolume()) + } set(value) { - networkProtectionPrefs.putLong(KEY_RECEIVED_BYTES, value.receivedBytes) - networkProtectionPrefs.putLong(KEY_TRANSMITTED_BYTES, value.transmittedBytes) + kotlin.runCatching { + networkProtectionPrefs.putLong(KEY_RECEIVED_BYTES, value.receivedBytes) + networkProtectionPrefs.putLong(KEY_TRANSMITTED_BYTES, value.transmittedBytes) + } } companion object { diff --git a/node_modules/@duckduckgo/content-scope-scripts/README.md b/node_modules/@duckduckgo/content-scope-scripts/README.md index 65c869442cda..a8b262520449 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/README.md +++ b/node_modules/@duckduckgo/content-scope-scripts/README.md @@ -32,8 +32,21 @@ Utilities to automatically generate TypeScript types from JSON Schema files. ## NPM commands +Consider using [nvm](https://github.com/nvm-sh/nvm) to manage node versions, after installing in the project directory run: + +``` +nvm use +``` + From the top-level root folder of this npm workspace, you can run the following npm commands: +**Install dependencies**: + +Will install all the dependencies we need to build and run the project: +``` +npm install +``` + **Build all workspaces**: Use this to produce the same output as a release. The `build` directory will be populated with diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/autofillPasswordImport.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/autofillPasswordImport.js new file mode 100644 index 000000000000..01a0e11d15fd --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/autofillPasswordImport.js @@ -0,0 +1,4164 @@ +/*! © DuckDuckGo ContentScopeScripts protections https://github.com/duckduckgo/content-scope-scripts/ */ +(function () { + 'use strict'; + + /* eslint-disable no-redeclare */ + const Set$1 = globalThis.Set; + const Reflect$1 = globalThis.Reflect; + globalThis.customElements?.get.bind(globalThis.customElements); + globalThis.customElements?.define.bind(globalThis.customElements); + const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; + const objectKeys = Object.keys; + const objectEntries = Object.entries; + const objectDefineProperty = Object.defineProperty; + const Proxy$1 = globalThis.Proxy; + globalThis.dispatchEvent?.bind(globalThis); + globalThis.addEventListener?.bind(globalThis); + globalThis.removeEventListener?.bind(globalThis); + globalThis.crypto?.randomUUID?.bind(globalThis.crypto); + + /* eslint-disable no-redeclare, no-global-assign */ + /* global cloneInto, exportFunction, false */ + + // Only use globalThis for testing this breaks window.wrappedJSObject code in Firefox + + let globalObj = typeof window === 'undefined' ? globalThis : window; + let Error$1 = globalObj.Error; + let messageSecret; + + // 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) { + messageSecret = secret; + } + + const exemptionLists = {}; + function shouldExemptUrl(type, url) { + for (const regex of exemptionLists[type]) { + if (regex.test(url)) { + return true; + } + } + return false; + } + + let debug = false; + + function initStringExemptionLists(args) { + const { stringExemptionLists } = args; + debug = args.debug; + for (const type in stringExemptionLists) { + exemptionLists[type] = []; + for (const stringExemption of stringExemptionLists[type]) { + exemptionLists[type].push(new RegExp(stringExemption)); + } + } + } + + /** + * Best guess effort of the tabs hostname; where possible always prefer the args.site.domain + * @returns {string|null} inferred tab hostname + */ + function getTabHostname() { + let framingOrigin = null; + try { + // @ts-expect-error - globalThis.top is possibly 'null' here + framingOrigin = globalThis.top.location.href; + } catch { + framingOrigin = globalThis.document.referrer; + } + + // Not supported in Firefox + if ('ancestorOrigins' in globalThis.location && globalThis.location.ancestorOrigins.length) { + // ancestorOrigins is reverse order, with the last item being the top frame + framingOrigin = globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1); + } + + try { + // @ts-expect-error - framingOrigin is possibly 'null' here + framingOrigin = new URL(framingOrigin).hostname; + } catch { + framingOrigin = null; + } + return framingOrigin; + } + + /** + * Returns true if hostname is a subset of exceptionDomain or an exact match. + * @param {string} hostname + * @param {string} exceptionDomain + * @returns {boolean} + */ + function matchHostname(hostname, exceptionDomain) { + return hostname === exceptionDomain || hostname.endsWith(`.${exceptionDomain}`); + } + + const lineTest = /(\()?(https?:[^)]+):[0-9]+:[0-9]+(\))?/; + function getStackTraceUrls(stack) { + const urls = new Set$1(); + try { + const errorLines = stack.split('\n'); + // Should cater for Chrome and Firefox stacks, we only care about https? resources. + for (const line of errorLines) { + const res = line.match(lineTest); + if (res) { + urls.add(new URL(res[2], location.href)); + } + } + } catch (e) { + // Fall through + } + return urls; + } + + function getStackTraceOrigins(stack) { + const urls = getStackTraceUrls(stack); + const origins = new Set$1(); + for (const url of urls) { + origins.add(url.hostname); + } + return origins; + } + + // Checks the stack trace if there are known libraries that are broken. + function shouldExemptMethod(type) { + // Short circuit stack tracing if we don't have checks + if (!(type in exemptionLists) || exemptionLists[type].length === 0) { + return false; + } + const stack = getStack(); + const errorFiles = getStackTraceUrls(stack); + for (const path of errorFiles) { + if (shouldExemptUrl(type, path.href)) { + return true; + } + } + return false; + } + + function isFeatureBroken(args, feature) { + return isPlatformSpecificFeature(feature) + ? !args.site.enabledFeatures.includes(feature) + : args.site.isBroken || args.site.allowlisted || !args.site.enabledFeatures.includes(feature); + } + + function camelcase(dashCaseText) { + return dashCaseText.replace(/-(.)/g, (match, letter) => { + 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() { + 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; + } + + /** + * Take configSeting which should be an array of possible values. + * 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[]} 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) { + let bestOption; + for (const item of configSetting) { + if (item.criteria) { + if (item.criteria.arch === 'AppleSilicon' && isAppleSilicon()) { + bestOption = item; + break; + } + } else { + bestOption = item; + } + } + + return bestOption; + } + + const functionMap = { + /** Useful for debugging APIs in the wild, shouldn't be used */ + debug: (...args) => { + console.log('debugger', ...args); + // eslint-disable-next-line no-debugger + debugger; + }, + + 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} configSetting + * @param {*} [defaultValue] + * @returns + */ + function processAttr(configSetting, defaultValue) { + if (configSetting === undefined) { + return defaultValue; + } + + const configSettingType = typeof configSetting; + switch (configSettingType) { + case 'object': + if (Array.isArray(configSetting)) { + configSetting = processAttrByCriteria(configSetting); + if (configSetting === undefined) { + return defaultValue; + } + } + + if (!configSetting.type) { + return defaultValue; + } + + if (configSetting.type === 'function') { + if (configSetting.functionName && functionMap[configSetting.functionName]) { + return functionMap[configSetting.functionName]; + } + } + + if (configSetting.type === 'undefined') { + return undefined; + } + + // All JSON expressable types are handled here + return configSetting.value; + default: + return defaultValue; + } + } + + function getStack() { + return new Error$1().stack; + } + + /** + * @param {*[]} argsArray + * @returns {string} + */ + function debugSerialize(argsArray) { + const maxSerializedSize = 1000; + const serializedArgs = argsArray.map((arg) => { + try { + const serializableOut = JSON.stringify(arg); + if (serializableOut.length > maxSerializedSize) { + return ``; + } + 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 JSON.stringify(serializedArgs); + } + + /** + * @template {object} P + * @typedef {object} ProxyObject

+ * @property {(target?: object, thisArg?: P, args?: object) => void} apply + */ + + /** + * @template [P=object] + */ + class DDGProxy { + /** + * @param {import('./content-feature').default} feature + * @param {P} objectScope + * @param {string} property + * @param {ProxyObject

} proxyObject + */ + constructor(feature, objectScope, property, proxyObject) { + this.objectScope = objectScope; + this.property = property; + this.feature = feature; + this.featureName = feature.name; + this.camelFeatureName = camelcase(this.featureName); + const outputHandler = (...args) => { + this.feature.addDebugFlag(); + const isExempt = shouldExemptMethod(this.camelFeatureName); + // Keep this here as getStack() is expensive + if (debug) { + postDebugMessage(this.camelFeatureName, { + isProxy: true, + action: isExempt ? 'ignore' : 'restrict', + kind: this.property, + documentUrl: document.location.href, + stack: getStack(), + args: debugSerialize(args[2]), + }); + } + // The normal return value + if (isExempt) { + return DDGReflect.apply(...args); + } + return proxyObject.apply(...args); + }; + const getMethod = (target, prop, receiver) => { + this.feature.addDebugFlag(); + if (prop === 'toString') { + const method = Reflect.get(target, prop, receiver).bind(target); + Object.defineProperty(method, 'toString', { + value: String.toString.bind(String.toString), + enumerable: false, + }); + return method; + } + return DDGReflect.get(target, prop, receiver); + }; + { + this._native = objectScope[property]; + const handler = {}; + handler.apply = outputHandler; + handler.get = getMethod; + this.internal = new globalObj.Proxy(objectScope[property], handler); + } + } + + // Actually apply the proxy to the native property + overload() { + { + this.objectScope[this.property] = this.internal; + } + } + + 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, + }); + } + } + + const maxCounter = new Map(); + function numberOfTimesDebugged(feature) { + if (!maxCounter.has(feature)) { + maxCounter.set(feature, 1); + } else { + maxCounter.set(feature, maxCounter.get(feature) + 1); + } + return maxCounter.get(feature); + } + + const DEBUG_MAX_TIMES = 5000; + + function postDebugMessage(feature, message, allowNonDebug = false) { + if (!debug && !allowNonDebug) { + return; + } + if (numberOfTimesDebugged(feature) > DEBUG_MAX_TIMES) { + return; + } + if (message.stack) { + const scriptOrigins = [...getStackTraceOrigins(message.stack)]; + message.scriptOrigins = scriptOrigins; + } + globalObj.postMessage({ + action: feature, + message, + }); + } + + let DDGReflect; + + // Exports for usage where we have to cross the xray boundary: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts + { + DDGReflect = globalObj.Reflect; + } + + /** + * @param {string | null} topLevelHostname + * @param {object[]} featureList + * @returns {boolean} + */ + function isUnprotectedDomain(topLevelHostname, featureList) { + let unprotectedDomain = false; + if (!topLevelHostname) { + return false; + } + const domainParts = topLevelHostname.split('.'); + + // walk up the domain to see if it's unprotected + while (domainParts.length > 1 && !unprotectedDomain) { + const partialDomain = domainParts.join('.'); + + unprotectedDomain = featureList.filter((domain) => domain.domain === partialDomain).length > 0; + + domainParts.shift(); + } + + return unprotectedDomain; + } + + /** + * @typedef {object} Platform + * @property {'ios' | 'macos' | 'extension' | 'android' | 'windows'} name + * @property {string | number } [version] + */ + + /** + * @typedef {object} UserPreferences + * @property {Platform} platform + * @property {boolean} [debug] + * @property {boolean} [globalPrivacyControl] + * @property {number} [versionNumber] - Android version number only + * @property {string} [versionString] - Non Android version string + * @property {string} sessionKey + */ + + /** + * Used to inialize extension code in the load phase + */ + function computeLimitedSiteObject() { + const topLevelHostname = getTabHostname(); + return { + domain: topLevelHostname, + }; + } + + /** + * Expansion point to add platform specific versioning logic + * @param {UserPreferences} preferences + * @returns {string | number | undefined} + */ + function getPlatformVersion(preferences) { + if (preferences.versionNumber) { + return preferences.versionNumber; + } + if (preferences.versionString) { + return preferences.versionString; + } + return undefined; + } + + function parseVersionString(versionString) { + return versionString.split('.').map(Number); + } + + /** + * @param {string} minVersionString + * @param {string} applicationVersionString + * @returns {boolean} + */ + function satisfiesMinVersion(minVersionString, applicationVersionString) { + const minVersions = parseVersionString(minVersionString); + const currentVersions = parseVersionString(applicationVersionString); + const maxLength = Math.max(minVersions.length, currentVersions.length); + for (let i = 0; i < maxLength; i++) { + const minNumberPart = minVersions[i] || 0; + const currentVersionPart = currentVersions[i] || 0; + if (currentVersionPart > minNumberPart) { + return true; + } + if (currentVersionPart < minNumberPart) { + return false; + } + } + return true; + } + + /** + * @param {string | number | undefined} minSupportedVersion + * @param {string | number | undefined} currentVersion + * @returns {boolean} + */ + function isSupportedVersion(minSupportedVersion, currentVersion) { + if (typeof currentVersion === 'string' && typeof minSupportedVersion === 'string') { + if (satisfiesMinVersion(minSupportedVersion, currentVersion)) { + return true; + } + } else if (typeof currentVersion === 'number' && typeof minSupportedVersion === 'number') { + if (minSupportedVersion <= currentVersion) { + return true; + } + } + return false; + } + + /** + * @typedef RemoteConfig + * @property {Record} features + * @property {string[]} unprotectedTemporary + */ + + /** + * @param {RemoteConfig} data + * @param {string[]} userList + * @param {UserPreferences} preferences + * @param {string[]} platformSpecificFeatures + */ + function processConfig(data, userList, preferences, platformSpecificFeatures = []) { + const topLevelHostname = getTabHostname(); + const site = computeLimitedSiteObject(); + const allowlisted = userList.filter((domain) => domain === topLevelHostname).length > 0; + /** @type {Record} */ + const output = { ...preferences }; + if (output.platform) { + const version = getPlatformVersion(preferences); + if (version) { + output.platform.version = version; + } + } + const enabledFeatures = computeEnabledFeatures(data, topLevelHostname, preferences.platform?.version, platformSpecificFeatures); + const isBroken = isUnprotectedDomain(topLevelHostname, data.unprotectedTemporary); + output.site = Object.assign(site, { + isBroken, + allowlisted, + enabledFeatures, + }); + + // Copy feature settings from remote config to preferences object + output.featureSettings = parseFeatureSettings(data, enabledFeatures); + 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; + } + + /** + * Retutns a list of enabled features + * @param {RemoteConfig} data + * @param {string | null} topLevelHostname + * @param {Platform['version']} platformVersion + * @param {string[]} platformSpecificFeatures + * @returns {string[]} + */ + 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; + } + } + 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; + } + + /** + * Returns the relevant feature settings for the enabled features + * @param {RemoteConfig} data + * @param {string[]} enabledFeatures + * @returns {Record} + */ + function parseFeatureSettings(data, enabledFeatures) { + /** @type {Record} */ + const featureSettings = {}; + const remoteFeatureNames = Object.keys(data.features); + remoteFeatureNames.forEach((featureName) => { + if (!enabledFeatures.includes(featureName)) { + return; + } + + featureSettings[featureName] = data.features[featureName].settings; + }); + return featureSettings; + } + + function isGloballyDisabled(args) { + return args.site.allowlisted || args.site.isBroken; + } + + /** + * @import {FeatureName} from "./features"; + * @type {FeatureName[]} + */ + const platformSpecificFeatures = ['windowsPermissionUsage', 'messageBridge']; + + function isPlatformSpecificFeature(featureName) { + return platformSpecificFeatures.includes(featureName); + } + + function createCustomEvent(eventName, eventDetail) { + + // @ts-expect-error - possibly null + return new OriginalCustomEvent(eventName, eventDetail); + } + + /** @deprecated */ + function legacySendMessage(messageType, options) { + // FF & Chrome + return ( + originalWindowDispatchEvent && + originalWindowDispatchEvent(createCustomEvent('sendMessageProxy' + messageSecret, { detail: { messageType, options } })) + ); + // TBD other platforms + } + + /** + * Takes a function that returns an element and tries to find it with exponential backoff. + * @param {number} delay + * @param {number} [maxAttempts=4] - The maximum number of attempts to find the element. + * @param {number} [delay=500] - The initial delay to be used to create the exponential backoff. + * @returns {Promise} + */ + function withExponentialBackoff(fn, maxAttempts = 4, delay = 500) { + return new Promise((resolve, reject) => { + let attempts = 0; + const tryFn = () => { + attempts += 1; + const error = new Error$1('Element not found'); + try { + const element = fn(); + if (element) { + resolve(element); + } else if (attempts < maxAttempts) { + setTimeout(tryFn, delay * Math.pow(2, attempts)); + } else { + reject(error); + } + } catch { + if (attempts < maxAttempts) { + setTimeout(tryFn, delay * Math.pow(2, attempts)); + } else { + reject(error); + } + } + }; + tryFn(); + }); + } + + const baseFeatures = /** @type {const} */ ([ + 'fingerprintingAudio', + 'fingerprintingBattery', + 'fingerprintingCanvas', + 'googleRejected', + 'gpc', + 'fingerprintingHardware', + 'referrer', + 'fingerprintingScreenSize', + 'fingerprintingTemporaryStorage', + 'navigatorInterface', + 'elementHiding', + 'exceptionHandler', + 'apiManipulation', + ]); + + const otherFeatures = /** @type {const} */ ([ + 'clickToLoad', + 'cookie', + 'messageBridge', + 'duckPlayer', + 'harmfulApis', + 'webCompat', + 'windowsPermissionUsage', + 'brokerProtection', + 'performanceMetrics', + 'breakageReporting', + 'autofillPasswordImport', + ]); + + /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ + /** @type {Record} */ + const platformSupport = { + apple: ['webCompat', ...baseFeatures], + 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge'], + 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() { + this.marks = []; + } + + /** + * Create performance marker + * @param {string} name + * @returns {PerformanceMark} + */ + mark(name) { + const mark = new PerformanceMark(name); + this.marks.push(mark); + return mark; + } + + /** + * Measure all performance markers + */ + measureAll() { + this.marks.forEach((mark) => { + mark.measure(); + }); + } + } + + /** + * Tiny wrapper around performance.mark and performance.measure + */ + // eslint-disable-next-line no-redeclare + class PerformanceMark { + /** + * @param {string} name + */ + constructor(name) { + this.name = name; + performance.mark(this.name + 'Start'); + } + + end() { + performance.mark(this.name + 'End'); + } + + measure() { + performance.measure(this.name, this.name + 'Start', this.name + 'End'); + } + } + + function isJSONArray(value) { + return Array.isArray(value); + } + function isJSONObject(value) { + return value !== null && typeof value === 'object' && (value.constructor === undefined || + // for example Object.create(null) + value.constructor.name === 'Object') // do not match on classes or Array + ; + } + + /** + * Test deep equality of two JSON values, objects, or arrays + */ + // TODO: write unit tests + function isEqual(a, b) { + // FIXME: this function will return false for two objects with the same keys + // but different order of keys + return JSON.stringify(a) === JSON.stringify(b); + } + + /** + * Get all but the last items from an array + */ + // TODO: write unit tests + function initial(array) { + return array.slice(0, array.length - 1); + } + + /** + * Get the last item from an array + */ + // TODO: write unit tests + function last(array) { + return array[array.length - 1]; + } + + /** + * Test whether a value is an Object or an Array (and not a primitive JSON value) + */ + // TODO: write unit tests + function isObjectOrArray(value) { + return typeof value === 'object' && value !== null; + } + + /** + * Immutability helpers + * + * inspiration: + * + * https://www.npmjs.com/package/seamless-immutable + * https://www.npmjs.com/package/ih + * https://www.npmjs.com/package/mutatis + * https://github.com/mariocasciaro/object-path-immutable + */ + + /** + * Shallow clone of an Object, Array, or value + * Symbols are cloned too. + */ + function shallowClone(value) { + if (isJSONArray(value)) { + // copy array items + const copy = value.slice(); + + // copy all symbols + Object.getOwnPropertySymbols(value).forEach(symbol => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + copy[symbol] = value[symbol]; + }); + return copy; + } else if (isJSONObject(value)) { + // copy object properties + const copy = { + ...value + }; + + // copy all symbols + Object.getOwnPropertySymbols(value).forEach(symbol => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + copy[symbol] = value[symbol]; + }); + return copy; + } else { + return value; + } + } + + /** + * Update a value in an object in an immutable way. + * If the value is unchanged, the original object will be returned + */ + function applyProp(object, key, value) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (object[key] === value) { + // return original object unchanged when the new value is identical to the old one + return object; + } else { + const updatedObject = shallowClone(object); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + updatedObject[key] = value; + return updatedObject; + } + } + + /** + * helper function to get a nested property in an object or array + * + * @return Returns the field when found, or undefined when the path doesn't exist + */ + function getIn(object, path) { + let value = object; + let i = 0; + while (i < path.length) { + if (isJSONObject(value)) { + value = value[path[i]]; + } else if (isJSONArray(value)) { + value = value[parseInt(path[i])]; + } else { + value = undefined; + } + i++; + } + return value; + } + + /** + * helper function to replace a nested property in an object with a new value + * without mutating the object itself. + * + * @param object + * @param path + * @param value + * @param [createPath=false] + * If true, `path` will be created when (partly) missing in + * the object. For correctly creating nested Arrays or + * Objects, the function relies on `path` containing number + * in case of array indexes. + * If false (default), an error will be thrown when the + * path doesn't exist. + * @return Returns a new, updated object or array + */ + function setIn(object, path, value) { + let createPath = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + if (path.length === 0) { + return value; + } + const key = path[0]; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + 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) { + const newObject = IS_INTEGER_REGEX.test(key) ? [] : {}; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + newObject[key] = updatedValue; + return newObject; + } else { + throw new Error('Path does not exist'); + } + } + } + const IS_INTEGER_REGEX = /^\d+$/; + + /** + * helper function to replace a nested property in an object with a new value + * without mutating the object itself. + * + * @return Returns a new, updated object or array + */ + function updateIn(object, path, transform) { + if (path.length === 0) { + return transform(object); + } + if (!isObjectOrArray(object)) { + throw new Error('Path doesn\'t exist'); + } + const key = path[0]; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + 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); + } + + /** + * helper function to delete a nested property in an object + * without mutating the object itself. + * + * @return Returns a new, updated object or array + */ + function deleteIn(object, path) { + if (path.length === 0) { + return object; + } + if (!isObjectOrArray(object)) { + throw new Error('Path does not exist'); + } + if (path.length === 1) { + const key = path[0]; + if (!(key in object)) { + // key doesn't exist. return object unchanged + return object; + } else { + const updatedObject = shallowClone(object); + if (isJSONArray(updatedObject)) { + updatedObject.splice(parseInt(key), 1); + } + if (isJSONObject(updatedObject)) { + delete updatedObject[key]; + } + return updatedObject; + } + } + const key = path[0]; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const updatedValue = deleteIn(object[key], path.slice(1)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return applyProp(object, key, updatedValue); + } + + /** + * Insert a new item in an array at a specific index. + * Example usage: + * + * insertAt({arr: [1,2,3]}, ['arr', '2'], 'inserted') // [1,2,'inserted',3] + */ + function insertAt(document, path, value) { + 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)); + } + const updatedItems = shallowClone(items); + updatedItems.splice(parseInt(index), 0, value); + return updatedItems; + }); + } + + /** + * Test whether a path exists in a JSON object + * @return Returns true if the path exists, else returns false + */ + function existsIn(document, path) { + if (document === undefined) { + return false; + } + if (path.length === 0) { + return true; + } + if (document === null) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return existsIn(document[path[0]], path.slice(1)); + } + + /** + * Parse a JSON Pointer + */ + function parseJSONPointer(pointer) { + const path = pointer.split('/'); + path.shift(); // remove the first empty entry + + return path.map(p => p.replace(/~1/g, '/').replace(/~0/g, '~')); + } + + /** + * Compile a JSON Pointer + */ + function compileJSONPointer(path) { + return path.map(compileJSONPointerProp).join(''); + } + + /** + * Compile a single path property from a JSONPath + */ + function compileJSONPointerProp(pathProp) { + return '/' + String(pathProp).replace(/~/g, '~0').replace(/\//g, '~1'); + } + + /** + * Apply a patch to a JSON object + * The original JSON object will not be changed, + * instead, the patch is applied in an immutable way + */ + function immutableJSONPatch(document, operations, options) { + let updatedDocument = document; + for (let i = 0; i < operations.length; i++) { + validateJSONPatchOperation(operations[i]); + 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') { + updatedDocument = remove(updatedDocument, path); + } else if (operation.op === 'replace') { + updatedDocument = replace(updatedDocument, path, operation.value); + } else if (operation.op === 'copy') { + updatedDocument = copy(updatedDocument, path, parseFrom(operation.from)); + } else if (operation.op === 'move') { + updatedDocument = move(updatedDocument, path, parseFrom(operation.from)); + } else if (operation.op === 'test') { + test(updatedDocument, path, operation.value); + } else { + throw new Error('Unknown JSONPatch operation ' + JSON.stringify(operation)); + } + } + return updatedDocument; + } + + /** + * Replace an existing item + */ + function replace(document, path, value) { + return setIn(document, path, value); + } + + /** + * Remove an item or property + */ + function remove(document, path) { + return deleteIn(document, path); + } + + /** + * Add an item or property + */ + function add(document, path, value) { + if (isArrayItem(document, path)) { + return insertAt(document, path, value); + } else { + return setIn(document, path, value); + } + } + + /** + * Copy a value + */ + function copy(document, path, from) { + const value = getIn(document, from); + if (isArrayItem(document, path)) { + return insertAt(document, path, value); + } else { + const value = getIn(document, from); + return setIn(document, path, value); + } + } + + /** + * Move a value + */ + function move(document, path, from) { + const value = getIn(document, from); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const removedJson = deleteIn(document, from); + return isArrayItem(removedJson, path) ? insertAt(removedJson, path, value) : setIn(removedJson, path, value); + } + + /** + * Test whether the data contains the provided value at the specified path. + * Throws an error when the test fails + */ + function test(document, path, value) { + if (value === undefined) { + throw new Error(`Test failed: no value provided (path: "${compileJSONPointer(path)}")`); + } + if (!existsIn(document, path)) { + throw new Error(`Test failed: path not found (path: "${compileJSONPointer(path)}")`); + } + const actualValue = getIn(document, path); + if (!isEqual(actualValue, value)) { + throw new Error(`Test failed, value differs (path: "${compileJSONPointer(path)}")`); + } + } + function isArrayItem(document, path) { + if (path.length === 0) { + return false; + } + const parent = getIn(document, initial(path)); + return Array.isArray(parent); + } + + /** + * Resolve the path index of an array, resolves indexes '-' + * @returns Returns the resolved path + */ + function resolvePathIndex(document, path) { + if (last(path) !== '-') { + return path; + } + const parentPath = initial(path); + const parent = getIn(document, parentPath); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return parentPath.concat(parent.length); + } + + /** + * Validate a JSONPatch operation. + * Throws an error when there is an issue + */ + function validateJSONPatchOperation(operation) { + // TODO: write unit tests + const ops = ['add', 'remove', 'replace', 'copy', 'move', 'test']; + if (!ops.includes(operation.op)) { + throw new Error('Unknown JSONPatch op ' + JSON.stringify(operation.op)); + } + if (typeof operation.path !== 'string') { + throw new Error('Required property "path" missing or not a string in operation ' + JSON.stringify(operation)); + } + if (operation.op === 'copy' || operation.op === 'move') { + if (typeof operation.from !== 'string') { + throw new Error('Required property "from" missing or not a string in operation ' + JSON.stringify(operation)); + } + } + } + function parsePath(document, pointer) { + return resolvePathIndex(document, parseJSONPointer(pointer)); + } + function parseFrom(fromPointer) { + return parseJSONPointer(fromPointer); + } + + /* global false, cloneInto, exportFunction */ + + + /** + * Like Object.defineProperty, but with support for Firefox's mozProxies. + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.BatteryManager.prototype) + * @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) { + { + objectDefineProperty(object, propertyName, descriptor); + } + } + + /** + * return a proxy to `newFn` that fakes .toString() and .toString.toString() to resemble the `origFn`. + * WARNING: do NOT proxy toString multiple times, as it will not work as expected. + * + * @param {*} newFn + * @param {*} origFn + * @param {string} [mockValue] - when provided, .toString() will return this value + */ + function wrapToString(newFn, origFn, mockValue) { + if (typeof newFn !== 'function' || typeof origFn !== 'function') { + return newFn; + } + + return new Proxy(newFn, { get: toStringGetTrap(origFn, mockValue) }); + } + + /** + * generate a proxy handler trap that fakes .toString() and .toString.toString() to resemble the `targetFn`. + * Note that it should be used as the get() trap. + * @param {*} targetFn + * @param {string} [mockValue] - when provided, .toString() will return this value + * @returns { (target: any, prop: string, receiver: any) => any } + */ + function toStringGetTrap(targetFn, mockValue) { + // We wrap two levels deep to handle toString.toString() calls + 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) { + // 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 Reflect.apply(target, targetFn, argumentsList); + } else { + return Reflect.apply(target, thisArg, argumentsList); + } + }, + 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) { + if (thisArg === toStringProxy) { + return Reflect.apply(target, origToString, argumentsList); + } else { + return Reflect.apply(target, thisArg, argumentsList); + } + }, + }); + return toStringToStringProxy; + } + return Reflect.get(target, prop, receiver); + }, + }); + return toStringProxy; + } + return Reflect.get(target, prop, receiver); + }; + } + + /** + * Wrap a `get`/`set` or `value` property descriptor. Only for data properties. For methods, use wrapMethod(). For constructors, use wrapConstructor(). + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Screen.prototype) + * @param {string} propertyName + * @param {Partial} descriptor + * @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) { + if (!object) { + return; + } + + /** @type {StrictPropertyDescriptor} */ + // @ts-expect-error - we check for undefined below + const origDescriptor = getOwnPropertyDescriptor(object, propertyName); + if (!origDescriptor) { + // this happens if the property is not implemented in the browser + return; + } + + if ( + ('value' in origDescriptor && 'value' in descriptor) || + ('get' in origDescriptor && 'get' in descriptor) || + ('set' in origDescriptor && 'set' in descriptor) + ) { + definePropertyFn(object, propertyName, { + ...origDescriptor, + ...descriptor, + }); + 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)}`); + } + } + + /** + * Wrap a method descriptor. Only for function properties. For data properties, use wrapProperty(). For constructors, use wrapConstructor(). + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Bluetooth.prototype) + * @param {string} propertyName + * @param {(originalFn, ...args) => any } wrapperFn - wrapper function receives the original function as the first argument + * @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) { + if (!object) { + return; + } + + /** @type {StrictPropertyDescriptor} */ + // @ts-expect-error - we check for undefined below + const origDescriptor = getOwnPropertyDescriptor(object, propertyName); + if (!origDescriptor) { + // this happens if the property is not implemented in the browser + 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`); + } + + const newFn = wrapToString(function () { + return wrapperFn.call(this, origFn, ...arguments); + }, origFn); + + definePropertyFn(object, propertyName, { + ...origDescriptor, + value: newFn, + }); + return origDescriptor; + } + + /** + * @template {keyof typeof globalThis} StandardInterfaceName + * @param {StandardInterfaceName} interfaceName - the name of the interface to shim (must be some known standard API, e.g. 'MediaSession') + * @param {typeof globalThis[StandardInterfaceName]} ImplClass - the class to use as the shim implementation + * @param {DefineInterfaceOptions} options - options for defining the interface + * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property + */ + function shimInterface(interfaceName, ImplClass, options, definePropertyFn) { + + /** @type {DefineInterfaceOptions} */ + const defaultOptions = { + allowConstructorCall: false, + disallowConstructor: false, + constructorErrorMessage: 'Illegal constructor', + wrapToString: true, + }; + + const fullOptions = { + interfaceDescriptorOptions: { writable: true, enumerable: false, configurable: true, value: ImplClass }, + ...defaultOptions, + ...options, + }; + + // In some cases we can get away without a full proxy, but in many cases below we need it. + // For example, we can't redefine `prototype` property on ES6 classes. + // Se we just always wrap the class to make the code more maintaibnable + + /** @type {ProxyHandler} */ + const proxyHandler = {}; + + // handle the case where the constructor is called without new + if (fullOptions.allowConstructorCall) { + // make the constructor function callable without new + proxyHandler.apply = function (target, thisArg, argumentsList) { + 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); + }; + } + + if (fullOptions.wrapToString) { + // mask toString() on class methods. `ImplClass.prototype` is non-configurable: we can't override or proxy it, so we have to wrap each method individually + 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] }`), + }); + } + } + + // wrap toString on the constructor function itself + Object.assign(proxyHandler, { + get: toStringGetTrap(ImplClass, `function ${interfaceName}() { [native code] }`), + }); + } + + // Note that instanceof should still work, since the `.prototype` object is proxied too: + // Interface() instanceof Interface === true + // ImplClass() instanceof Interface === true + const Interface = new Proxy(ImplClass, proxyHandler); + + // Make sure that Interface().constructor === Interface (not ImplClass) + if (ImplClass.prototype?.constructor === ImplClass) { + /** @type {StrictDataDescriptor} */ + // @ts-expect-error - As long as ImplClass is a normal class, it should have the prototype property + const descriptor = getOwnPropertyDescriptor(ImplClass.prototype, 'constructor'); + if (descriptor.writable) { + ImplClass.prototype.constructor = Interface; + } + } + + // mock the name property + definePropertyFn(ImplClass, 'name', { + value: interfaceName, + configurable: true, + enumerable: false, + writable: false, + }); + + // interfaces are exposed directly on the global object, not on its prototype + definePropertyFn(globalThis, interfaceName, { ...fullOptions.interfaceDescriptorOptions, value: Interface }); + } + + /** + * Define a missing standard property on a global (prototype) object. Only for data properties. + * For constructors, use shimInterface(). + * Most of the time, you'd want to call shimInterface() first to shim the class itself (MediaSession), and then shimProperty() for the global singleton instance (Navigator.prototype.mediaSession). + * @template Base + * @template {keyof Base & string} K + * @param {Base} baseObject - object whose property we are shimming (most commonly a prototype object, e.g. Navigator.prototype) + * @param {K} propertyName - name of the property to shim (e.g. 'mediaSession') + * @param {Base[K]} implInstance - instance to use as the shim (e.g. new MyMediaSession()) + * @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) { + // @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}]`), + }); + + /** @type {StrictPropertyDescriptor} */ + let descriptor; + + // Note that we only cover most common cases: a getter for "readonly" properties, and a value descriptor for writable properties. + // 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 proxiedGetter = new Proxy(getter, { + get: toStringGetTrap(getter, `function get ${propertyName}() { [native code] }`), + }); + descriptor = { + configurable: true, + enumerable: true, + get: proxiedGetter, + }; + } else { + descriptor = { + configurable: true, + enumerable: true, + writable: true, + value: proxiedInstance, + }; + } + + definePropertyFn(baseObject, propertyName, descriptor); + } + + /** + * @callback DefinePropertyFn + * @param {object} baseObj + * @param {PropertyKey} propertyName + * @param {StrictPropertyDescriptor} descriptor + * @returns {object} + */ + + /** + * @typedef {Object} BaseStrictPropertyDescriptor + * @property {boolean} configurable + * @property {boolean} enumerable + */ + + /** + * @typedef {BaseStrictPropertyDescriptor & { value: any; writable: boolean }} StrictDataDescriptor + * @typedef {BaseStrictPropertyDescriptor & { get: () => any; set: (v: any) => void }} StrictAccessorDescriptor + * @typedef {BaseStrictPropertyDescriptor & { get: () => any }} StrictGetDescriptor + * @typedef {BaseStrictPropertyDescriptor & { set: (v: any) => void }} StrictSetDescriptor + * @typedef {StrictDataDescriptor | StrictAccessorDescriptor | StrictGetDescriptor | StrictSetDescriptor} StrictPropertyDescriptor + */ + + /** + * @typedef {Object} BaseDefineInterfaceOptions + * @property {string} [constructorErrorMessage] + * @property {boolean} wrapToString + */ + + /** + * @typedef {{ allowConstructorCall: true; disallowConstructor: false }} DefineInterfaceOptionsWithAllowConstructorCallMixin + */ + + /** + * @typedef {{ allowConstructorCall: false; disallowConstructor: true }} DefineInterfaceOptionsWithDisallowConstructorMixin + */ + + /** + * @typedef {{ allowConstructorCall: false; disallowConstructor: false }} DefineInterfaceOptionsDefaultMixin + */ + + /** + * @typedef {BaseDefineInterfaceOptions & (DefineInterfaceOptionsWithAllowConstructorCallMixin | DefineInterfaceOptionsWithDisallowConstructorMixin | DefineInterfaceOptionsDefaultMixin)} DefineInterfaceOptions + */ + + /** + * A wrapper for messaging on Windows. + * + * This requires 3 methods to be available, see {@link WindowsMessagingConfig} for details + * + * @document messaging/lib/examples/windows.example.js + * + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + /** + * An implementation of {@link MessagingTransport} for Windows + * + * All messages go through `window.chrome.webview` APIs + * + * @implements {MessagingTransport} + */ + class WindowsMessagingTransport { + /** + * @param {WindowsMessagingConfig} config + * @param {import('../index.js').MessagingContext} messagingContext + * @internal + */ + constructor(config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + this.globals = { + window, + JSONparse: window.JSON.parse, + JSONstringify: window.JSON.stringify, + Promise: window.Promise, + Error: window.Error, + 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); + } + } + } + + /** + * @param {import('../index.js').NotificationMessage} msg + */ + notify(msg) { + const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); + const notification = WindowsNotification.fromNotification(msg, data); + this.config.methods.postMessage(notification); + } + + /** + * @param {import('../index.js').RequestMessage} msg + * @param {{signal?: AbortSignal}} opts + * @return {Promise} + */ + 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); + + // send the message + this.config.methods.postMessage(outgoing); + + // compare incoming messages against the `msg.id` + const comparator = (eventData) => { + 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; + } + + // now wait for a matching message + return new this.globals.Promise((resolve, reject) => { + try { + this._subscribe(comparator, opts, (value, unsubscribe) => { + unsubscribe(); + + if (!isMessageResponse(value)) { + console.warn('unknown response type', value); + return reject(new this.globals.Error('unknown response')); + } + + if (value.result) { + return resolve(value.result); + } + + const message = this.globals.String(value.error?.message || 'unknown error'); + reject(new this.globals.Error(message)); + }); + } catch (e) { + reject(e); + } + }); + } + + /** + * @param {import('../index.js').Subscription} msg + * @param {(value: unknown | undefined) => void} callback + */ + subscribe(msg, callback) { + // compare incoming messages against the `msg.subscriptionName` + const comparator = (eventData) => { + 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); + }; + + // now listen for matching incoming messages. + return this._subscribe(comparator, {}, cb); + } + + /** + * @typedef {import('../index.js').MessageResponse | import('../index.js').SubscriptionEvent} Incoming + */ + /** + * @param {(eventData: any) => boolean} comparator + * @param {{signal?: AbortSignal}} options + * @param {(value: Incoming, unsubscribe: (()=>void)) => void} callback + * @internal + */ + _subscribe(comparator, options, callback) { + // if already aborted, reject immediately + if (options?.signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + /** @type {(()=>void) | undefined} */ + // eslint-disable-next-line prefer-const + let teardown; + + /** + * @param {MessageEvent} event + */ + const idHandler = (event) => { + if (this.messagingContext.env === 'production') { + if (event.origin !== null && event.origin !== undefined) { + console.warn('ignoring because evt.origin is not `null` or `undefined`'); + return; + } + } + if (!event.data) { + console.warn('data absent from message'); + return; + } + if (comparator(event.data)) { + if (!teardown) throw new Error('unreachable'); + callback(event.data, teardown); + } + }; + + // what to do if this promise is aborted + const abortHandler = () => { + teardown?.(); + throw new DOMException('Aborted', 'AbortError'); + }; + + // console.log('DEBUG: handler setup', { config, comparator }) + + this.config.methods.addEventListener('message', idHandler); + options?.signal?.addEventListener('abort', abortHandler); + + teardown = () => { + // console.log('DEBUG: handler teardown', { config, comparator }) + + this.config.methods.removeEventListener('message', idHandler); + options?.signal?.removeEventListener('abort', abortHandler); + }; + + return () => { + teardown?.(); + }; + } + } + + /** + * To construct this configuration object, you need access to 3 methods + * + * - `postMessage` + * - `addEventListener` + * - `removeEventListener` + * + * These would normally be available on Windows via the following: + * + * - `window.chrome.webview.postMessage` + * - `window.chrome.webview.addEventListener` + * - `window.chrome.webview.removeEventListener` + * + * Depending on where the script is running, we may want to restrict access to those globals. On the native + * side those handlers `window.chrome.webview` handlers might be deleted and replaces with in-scope variables, such as: + * + * [Example](./examples/windows.example.js) + * + */ + class WindowsMessagingConfig { + /** + * @param {object} params + * @param {WindowsInteropMethods} params.methods + * @internal + */ + constructor(params) { + /** + * The methods required for communication + */ + this.methods = params.methods; + /** + * @type {'windows'} + */ + this.platform = 'windows'; + } + } + + /** + * This data type represents a message sent to the Windows + * platform via `window.chrome.webview.postMessage`. + * + * **NOTE**: This is sent when a response is *not* expected + */ + class WindowsNotification { + /** + * @param {object} params + * @param {string} params.Feature + * @param {string} params.SubFeatureName + * @param {string} params.Name + * @param {Record} [params.Data] + * @internal + */ + constructor(params) { + /** + * Alias for: {@link NotificationMessage.context} + */ + this.Feature = params.Feature; + /** + * Alias for: {@link NotificationMessage.featureName} + */ + this.SubFeatureName = params.SubFeatureName; + /** + * Alias for: {@link NotificationMessage.method} + */ + this.Name = params.Name; + /** + * Alias for: {@link NotificationMessage.params} + */ + this.Data = params.Data; + } + + /** + * Helper to convert a {@link NotificationMessage} to a format that Windows can support + * @param {NotificationMessage} notification + * @returns {WindowsNotification} + */ + static fromNotification(notification, data) { + /** @type {WindowsNotification} */ + const output = { + Data: data, + Feature: notification.context, + SubFeatureName: notification.featureName, + Name: notification.method, + }; + return output; + } + } + + /** + * This data type represents a message sent to the Windows + * platform via `window.chrome.webview.postMessage` when it + * expects a response + */ + class WindowsRequestMessage { + /** + * @param {object} params + * @param {string} params.Feature + * @param {string} params.SubFeatureName + * @param {string} params.Name + * @param {Record} [params.Data] + * @param {string} [params.Id] + * @internal + */ + constructor(params) { + this.Feature = params.Feature; + this.SubFeatureName = params.SubFeatureName; + this.Name = params.Name; + this.Data = params.Data; + this.Id = params.Id; + } + + /** + * Helper to convert a {@link RequestMessage} to a format that Windows can support + * @param {RequestMessage} msg + * @param {Record} data + * @returns {WindowsRequestMessage} + */ + static fromRequest(msg, data) { + /** @type {WindowsRequestMessage} */ + const output = { + Data: data, + Feature: msg.context, + SubFeatureName: msg.featureName, + Name: msg.method, + Id: msg.id, + }; + return output; + } + } + + /** + * These are all the shared data types used throughout. Transports receive these types and + * can choose how to deliver the message to their respective native platforms. + * + * - Notifications via {@link NotificationMessage} + * - Request -> Response via {@link RequestMessage} and {@link MessageResponse} + * - Subscriptions via {@link Subscription} + * + * Note: For backwards compatibility, some platforms may alter the data shape within the transport. + * + * @module Messaging Schema + * + */ + + /** + * This is the format of an outgoing message. + * + * - See {@link MessageResponse} for what's expected in a response + * + * **NOTE**: + * - Windows will alter this before it's sent, see: {@link Messaging.WindowsRequestMessage} + */ + class RequestMessage { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.method + * @param {string} params.id + * @param {Record} [params.params] + * @internal + */ + constructor(params) { + /** + * The global context for this message. For example, something like `contentScopeScripts` or `specialPages` + * @type {string} + */ + this.context = params.context; + /** + * The name of the sub-feature, such as `duckPlayer` or `clickToLoad` + * @type {string} + */ + this.featureName = params.featureName; + /** + * The name of the handler to be executed on the native side + */ + this.method = params.method; + /** + * The `id` that native sides can use when sending back a response + */ + this.id = params.id; + /** + * Optional data payload - must be a plain key/value object + */ + this.params = params.params; + } + } + + /** + * **NOTE**: + * - Windows will alter this before it's sent, see: {@link Messaging.WindowsNotification} + */ + class NotificationMessage { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.method + * @param {Record} [params.params] + * @internal + */ + constructor(params) { + /** + * The global context for this message. For example, something like `contentScopeScripts` or `specialPages` + */ + this.context = params.context; + /** + * The name of the sub-feature, such as `duckPlayer` or `clickToLoad` + */ + this.featureName = params.featureName; + /** + * The name of the handler to be executed on the native side + */ + this.method = params.method; + /** + * An optional payload + */ + this.params = params.params; + } + } + + class Subscription { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.subscriptionName + * @internal + */ + constructor(params) { + this.context = params.context; + this.featureName = params.featureName; + this.subscriptionName = params.subscriptionName; + } + } + + /** + * @param {RequestMessage} request + * @param {Record} data + * @return {data is MessageResponse} + */ + function isResponseFor(request, data) { + if ('result' in data) { + 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 false; + } + + /** + * @param {Subscription} sub + * @param {Record} data + * @return {data is SubscriptionEvent} + */ + function isSubscriptionEventFor(sub, data) { + if ('subscriptionName' in data) { + return data.featureName === sub.featureName && data.context === sub.context && data.subscriptionName === sub.subscriptionName; + } + + return false; + } + + /** + * + * A wrapper for messaging on WebKit platforms. It supports modern WebKit messageHandlers + * along with encryption for older versions (like macOS Catalina) + * + * Note: If you wish to support Catalina then you'll need to implement the native + * part of the message handling, see {@link WebkitMessagingTransport} for details. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + /** + * @example + * On macOS 11+, this will just call through to `window.webkit.messageHandlers.x.postMessage` + * + * Eg: for a `foo` message defined in Swift that accepted the payload `{"bar": "baz"}`, the following + * would occur: + * + * ```js + * const json = await window.webkit.messageHandlers.foo.postMessage({ bar: "baz" }); + * const response = JSON.parse(json) + * ``` + * + * @example + * On macOS 10 however, the process is a little more involved. A method will be appended to `window` + * that allows the response to be delivered there instead. It's not exactly this, but you can visualize the flow + * as being something along the lines of: + * + * ```js + * // add the window method + * window["_0123456"] = (response) => { + * // decrypt `response` and deliver the result to the caller here + * // then remove the temporary method + * delete window['_0123456'] + * }; + * + * // send the data + `messageHanding` values + * window.webkit.messageHandlers.foo.postMessage({ + * bar: "baz", + * messagingHandling: { + * methodName: "_0123456", + * secret: "super-secret", + * key: [1, 2, 45, 2], + * iv: [34, 4, 43], + * } + * }); + * + * // later in swift, the following JavaScript snippet will be executed + * (() => { + * window['_0123456']({ + * ciphertext: [12, 13, 4], + * tag: [3, 5, 67, 56] + * }) + * })() + * ``` + * @implements {MessagingTransport} + */ + class WebkitMessagingTransport { + /** + * @param {WebkitMessagingConfig} config + * @param {import('../index.js').MessagingContext} messagingContext + */ + constructor(config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + this.globals = captureGlobals(); + if (!this.config.hasModernWebkitAPI) { + this.captureWebkitHandlers(this.config.webkitMessageHandlerNames); + } + } + + /** + * Sends message to the webkit layer (fire and forget) + * @param {String} handler + * @param {*} data + * @internal + */ + wkSend(handler, data = {}) { + if (!(handler in this.globals.window.webkit.messageHandlers)) { + throw new MissingHandler(`Missing webkit handler: '${handler}'`, handler); + } + if (!this.config.hasModernWebkitAPI) { + const outgoing = { + ...data, + messageHandling: { + ...data.messageHandling, + secret: this.config.secret, + }, + }; + if (!(handler in this.globals.capturedWebkitHandlers)) { + throw new MissingHandler(`cannot continue, method ${handler} not captured on macos < 11`, handler); + } else { + return this.globals.capturedWebkitHandlers[handler](outgoing); + } + } + return this.globals.window.webkit.messageHandlers[handler].postMessage?.(data); + } + + /** + * Sends message to the webkit layer and waits for the specified response + * @param {String} handler + * @param {import('../index.js').RequestMessage} data + * @returns {Promise<*>} + * @internal + */ + async wkSendAndWait(handler, data) { + if (this.config.hasModernWebkitAPI) { + const response = await this.wkSend(handler, data); + return this.globals.JSONparse(response || '{}'); + } + + try { + const randMethodName = this.createRandMethodName(); + const key = await this.createRandKey(); + const iv = this.createRandIv(); + + 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 + data.messageHandling = new SecureMessagingParams({ + methodName: randMethodName, + secret: this.config.secret, + key: this.globals.Arrayfrom(key), + 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 || '{}'); + } catch (e) { + // re-throw when the error is just a 'MissingHandler' + if (e instanceof MissingHandler) { + throw e; + } else { + console.error('decryption failed', e); + console.error(e); + return { error: e }; + } + } + } + + /** + * @param {import('../index.js').NotificationMessage} msg + */ + notify(msg) { + this.wkSend(msg.context, msg); + } + + /** + * @param {import('../index.js').RequestMessage} msg + */ + async request(msg) { + const data = await this.wkSendAndWait(msg.context, msg); + + if (isResponseFor(msg, data)) { + if (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('an unknown error occurred'); + } + + /** + * Generate a random method name and adds it to the global scope + * The native layer will use this method to send the response + * @param {string | number} randomMethodName + * @param {Function} callback + * @internal + */ + generateRandomMethod(randomMethodName, callback) { + this.globals.ObjectDefineProperty(this.globals.window, randomMethodName, { + enumerable: false, + // configurable, To allow for deletion later + configurable: true, + writable: false, + /** + * @param {any[]} args + */ + value: (...args) => { + callback(...args); + delete this.globals.window[randomMethodName]; + }, + }); + } + + /** + * @internal + * @return {string} + */ + randomString() { + return '' + this.globals.getRandomValues(new this.globals.Uint32Array(1))[0]; + } + + /** + * @internal + * @return {string} + */ + createRandMethodName() { + return '_' + this.randomString(); + } + + /** + * @type {{name: string, length: number}} + * @internal + */ + algoObj = { + name: 'AES-GCM', + length: 256, + }; + + /** + * @returns {Promise} + * @internal + */ + 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); + } + + /** + * @returns {Uint8Array} + * @internal + */ + createRandIv() { + return this.globals.getRandomValues(new this.globals.Uint8Array(12)); + } + + /** + * @param {BufferSource} ciphertext + * @param {BufferSource} key + * @param {Uint8Array} iv + * @returns {Promise} + * @internal + */ + async decrypt(ciphertext, key, iv) { + const cryptoKey = await this.globals.importKey('raw', key, 'AES-GCM', false, ['decrypt']); + const algo = { + name: 'AES-GCM', + iv, + }; + + const decrypted = await this.globals.decrypt(algo, cryptoKey, ciphertext); + + const dec = new this.globals.TextDecoder(); + return dec.decode(decrypted); + } + + /** + * When required (such as on macos 10.x), capture the `postMessage` method on + * each webkit messageHandler + * + * @param {string[]} handlerNames + */ + captureWebkitHandlers(handlerNames) { + const handlers = window.webkit.messageHandlers; + if (!handlers) throw new MissingHandler('window.webkit.messageHandlers was absent', 'all'); + for (const webkitMessageHandlerName of handlerNames) { + if (typeof handlers[webkitMessageHandlerName]?.postMessage === 'function') { + /** + * `bind` is used here to ensure future calls to the captured + * `postMessage` have the correct `this` context + */ + const original = handlers[webkitMessageHandlerName]; + const bound = handlers[webkitMessageHandlerName].postMessage?.bind(original); + this.globals.capturedWebkitHandlers[webkitMessageHandlerName] = bound; + delete handlers[webkitMessageHandlerName].postMessage; + } + } + } + + /** + * @param {import('../index.js').Subscription} msg + * @param {(value: unknown) => void} 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`); + } + this.globals.ObjectDefineProperty(this.globals.window, msg.subscriptionName, { + enumerable: false, + configurable: true, + writable: false, + value: (data) => { + if (data && isSubscriptionEventFor(msg, data)) { + callback(data.params); + } else { + console.warn('Received a message that did not match the subscription', data); + } + }, + }); + return () => { + this.globals.ReflectDeleteProperty(this.globals.window, msg.subscriptionName); + }; + } + } + + /** + * Use this configuration to create an instance of {@link Messaging} for WebKit platforms + * + * We support modern WebKit environments *and* macOS Catalina. + * + * Please see {@link WebkitMessagingTransport} for details on how messages are sent/received + * + * [Example](./examples/webkit.example.js) + */ + class WebkitMessagingConfig { + /** + * @param {object} params + * @param {boolean} params.hasModernWebkitAPI + * @param {string[]} params.webkitMessageHandlerNames + * @param {string} params.secret + * @internal + */ + constructor(params) { + /** + * Whether or not the current WebKit Platform supports secure messaging + * by default (eg: macOS 11+) + */ + this.hasModernWebkitAPI = params.hasModernWebkitAPI; + /** + * A list of WebKit message handler names that a user script can send. + * + * For example, if the native platform can receive messages through this: + * + * ```js + * window.webkit.messageHandlers.foo.postMessage('...') + * ``` + * + * then, this property would be: + * + * ```js + * webkitMessageHandlerNames: ['foo'] + * ``` + */ + this.webkitMessageHandlerNames = params.webkitMessageHandlerNames; + /** + * A string provided by native platforms to be sent with future outgoing + * messages. + */ + this.secret = params.secret; + } + } + + /** + * This is the additional payload that gets appended to outgoing messages. + * It's used in the Swift side to encrypt the response that comes back + */ + class SecureMessagingParams { + /** + * @param {object} params + * @param {string} params.methodName + * @param {string} params.secret + * @param {number[]} params.key + * @param {number[]} params.iv + */ + constructor(params) { + /** + * The method that's been appended to `window` to be called later + */ + this.methodName = params.methodName; + /** + * The secret used to ensure message sender validity + */ + this.secret = params.secret; + /** + * The CipherKey as number[] + */ + this.key = params.key; + /** + * The Initial Vector as number[] + */ + this.iv = params.iv; + } + } + + /** + * Capture some globals used for messaging handling to prevent page + * scripts from tampering with this + */ + function captureGlobals() { + // Create base with null prototype + const globals = { + window, + getRandomValues: window.crypto.getRandomValues.bind(window.crypto), + TextEncoder, + TextDecoder, + Uint8Array, + Uint16Array, + Uint32Array, + JSONstringify: window.JSON.stringify, + JSONparse: window.JSON.parse, + Arrayfrom: window.Array.from, + Promise: window.Promise, + Error: window.Error, + ReflectDeleteProperty: window.Reflect.deleteProperty.bind(window.Reflect), + ObjectDefineProperty: window.Object.defineProperty, + addEventListener: window.addEventListener.bind(window), + /** @type {Record} */ + capturedWebkitHandlers: {}, + }; + if (isSecureContext) { + // skip for HTTP content since window.crypto.subtle is unavailable + globals.generateKey = window.crypto.subtle.generateKey.bind(window.crypto.subtle); + globals.exportKey = window.crypto.subtle.exportKey.bind(window.crypto.subtle); + globals.importKey = window.crypto.subtle.importKey.bind(window.crypto.subtle); + globals.encrypt = window.crypto.subtle.encrypt.bind(window.crypto.subtle); + globals.decrypt = window.crypto.subtle.decrypt.bind(window.crypto.subtle); + } + return globals; + } + + /** + * + * A wrapper for messaging on Android. + * + * You must share a {@link AndroidMessagingConfig} instance between features + * + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + /** + * @typedef {import('../index.js').Subscription} Subscription + * @typedef {import('../index.js').MessagingContext} MessagingContext + * @typedef {import('../index.js').RequestMessage} RequestMessage + * @typedef {import('../index.js').NotificationMessage} NotificationMessage + */ + + /** + * An implementation of {@link MessagingTransport} for Android + * + * All messages go through `window.chrome.webview` APIs + * + * @implements {MessagingTransport} + */ + class AndroidMessagingTransport { + /** + * @param {AndroidMessagingConfig} config + * @param {MessagingContext} messagingContext + * @internal + */ + constructor(config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + } + + /** + * @param {NotificationMessage} msg + */ + notify(msg) { + try { + this.config.sendMessageThrows?.(JSON.stringify(msg)); + } catch (e) { + console.error('.notify failed', e); + } + } + + /** + * @param {RequestMessage} msg + * @return {Promise} + */ + request(msg) { + return new Promise((resolve, reject) => { + // subscribe early + const unsub = this.config.subscribe(msg.id, handler); + + try { + this.config.sendMessageThrows?.(JSON.stringify(msg)); + } catch (e) { + unsub(); + reject(new Error('request failed to send: ' + e.message || 'unknown error')); + } + + function handler(data) { + if (isResponseFor(msg, data)) { + // success case, forward .result only + if (data.result) { + resolve(data.result || {}); + return unsub(); + } + + // error case, forward the error as a regular promise rejection + if (data.error) { + reject(new Error(data.error.message)); + return unsub(); + } + + // getting here is undefined behavior + unsub(); + 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) { + const unsub = this.config.subscribe(msg.subscriptionName, (data) => { + if (isSubscriptionEventFor(msg, data)) { + callback(data.params || {}); + } + }); + return () => { + unsub(); + }; + } + } + + /** + * Android shared messaging configuration. This class should be constructed once and then shared + * between features (because of the way it modifies globals). + * + * For example, if Android is injecting a JavaScript module like C-S-S which contains multiple 'sub-features', then + * this class would be instantiated once and then shared between all sub-features. + * + * The following example shows all the fields that are required to be passed in: + * + * ```js + * const config = new AndroidMessagingConfig({ + * // a value that native has injected into the script + * messageSecret: 'abc', + * + * // the name of the window method that android will deliver responses through + * messageCallback: 'callback_123', + * + * // the `@JavascriptInterface` name from native that will be used to receive messages + * javascriptInterface: "ARandomValue", + * + * // the global object where methods will be registered + * target: globalThis + * }); + * ``` + * Once an instance of {@link AndroidMessagingConfig} is created, you can then use it to construct + * many instances of {@link Messaging} (one per feature). See `examples/android.example.js` for an example. + * + * + * ## Native integration + * + * Assuming you have the following: + * - a `@JavascriptInterface` named `"ContentScopeScripts"` + * - a sub-feature called `"featureA"` + * - and a method on `"featureA"` called `"helloWorld"` + * + * Then delivering a {@link NotificationMessage} to it, would be roughly this in JavaScript (remember `params` is optional though) + * + * ``` + * const secret = "abc"; + * const json = JSON.stringify({ + * context: "ContentScopeScripts", + * featureName: "featureA", + * method: "helloWorld", + * params: { "foo": "bar" } + * }); + * window.ContentScopeScripts.process(json, secret) + * ``` + * When you receive the JSON payload (note that it will be a string), you'll need to deserialize/verify it according to {@link "Messaging Implementation Guide"} + * + * + * ## Responding to a {@link RequestMessage}, or pushing a {@link SubscriptionEvent} + * + * If you receive a {@link RequestMessage}, you'll need to deliver a {@link MessageResponse}. + * Similarly, if you want to push new data, you need to deliver a {@link SubscriptionEvent}. In both + * cases you'll do this through a global `window` method. Given the snippet below, this is how it would relate + * to the {@link AndroidMessagingConfig}: + * + * - `$messageCallback` matches {@link AndroidMessagingConfig.messageCallback} + * - `$messageSecret` matches {@link AndroidMessagingConfig.messageSecret} + * - `$message` is JSON string that represents one of {@link MessageResponse} or {@link SubscriptionEvent} + * + * ``` + * object ReplyHandler { + * fun constructReply(message: String, messageCallback: String, messageSecret: String): String { + * return """ + * (function() { + * window['$messageCallback']('$messageSecret', $message); + * })(); + * """.trimIndent() + * } + * } + * ``` + */ + class AndroidMessagingConfig { + /** @type {(json: string, secret: string) => void} */ + _capturedHandler; + /** + * @param {object} params + * @param {Record} params.target + * @param {boolean} params.debug + * @param {string} params.messageSecret - a secret to ensure that messages are only + * processed by the correct handler + * @param {string} params.javascriptInterface - the name of the javascript interface + * registered on the native side + * @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) { + this.target = params.target; + this.debug = params.debug; + this.javascriptInterface = params.javascriptInterface; + this.messageSecret = params.messageSecret; + this.messageCallback = params.messageCallback; + + /** + * @type {Map void>} + * @internal + */ + this.listeners = new globalThis.Map(); + + /** + * Capture the global handler and remove it from the global object. + */ + this._captureGlobalHandler(); + + /** + * Assign the incoming handler method to the global object. + */ + this._assignHandlerMethod(); + } + + /** + * The transport can call this to transmit a JSON payload along with a secret + * to the native Android handler. + * + * Note: This can throw - it's up to the transport to handle the error. + * + * @type {(json: string) => void} + * @throws + * @internal + */ + sendMessageThrows(json) { + this._capturedHandler(json, this.messageSecret); + } + + /** + * A subscription on Android is just a named listener. All messages from + * android -> are delivered through a single function, and this mapping is used + * to route the messages to the correct listener. + * + * Note: Use this to implement request->response by unsubscribing after the first + * response. + * + * @param {string} id + * @param {(msg: MessageResponse | SubscriptionEvent) => void} callback + * @returns {() => void} + * @internal + */ + subscribe(id, callback) { + this.listeners.set(id, callback); + return () => { + this.listeners.delete(id); + }; + } + + /** + * Accept incoming messages and try to deliver it to a registered listener. + * + * This code is defensive to prevent any single handler from affecting another if + * it throws (producer interference). + * + * @param {MessageResponse | SubscriptionEvent} payload + * @internal + */ + _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 the payload has an 'id' field, then it's a message response + if ('id' in payload) { + if (this.listeners.has(payload.id)) { + this._tryCatch(() => this.listeners.get(payload.id)?.(payload)); + } else { + this._log('no listeners for ', payload); + } + } + + // if the payload has an 'subscriptionName' field, then it's a push event + if ('subscriptionName' in payload) { + if (this.listeners.has(payload.subscriptionName)) { + this._tryCatch(() => this.listeners.get(payload.subscriptionName)?.(payload)); + } else { + this._log('no subscription listeners for ', payload); + } + } + } + + /** + * + * @param {(...args: any[]) => any} fn + * @param {string} [context] + */ + _tryCatch(fn, context = 'none') { + try { + return fn(); + } catch (e) { + if (this.debug) { + console.error('AndroidMessagingConfig error:', context); + console.error(e); + } + } + } + + /** + * @param {...any} args + */ + _log(...args) { + if (this.debug) { + console.log('AndroidMessagingConfig', ...args); + } + } + + /** + * Capture the global handler and remove it from the global object. + */ + _captureGlobalHandler() { + const { target, javascriptInterface } = this; + + if (Object.prototype.hasOwnProperty.call(target, javascriptInterface)) { + this._capturedHandler = target[javascriptInterface].process.bind(target[javascriptInterface]); + delete target[javascriptInterface]; + } else { + this._capturedHandler = () => { + this._log('Android messaging interface not available', javascriptInterface); + }; + } + } + + /** + * Assign the incoming handler method to the global object. + * This is the method that Android will call to deliver messages. + */ + _assignHandlerMethod() { + /** + * @type {(secret: string, response: MessageResponse | SubscriptionEvent) => void} + */ + const responseHandler = (providedSecret, response) => { + if (providedSecret === this.messageSecret) { + this._dispatch(response); + } + }; + + Object.defineProperty(this.target, this.messageCallback, { + value: responseHandler, + }); + } + } + + /** + * + * An abstraction for communications between JavaScript and host platforms. + * + * 1) First you construct your platform-specific configuration (eg: {@link WebkitMessagingConfig}) + * 2) Then use that to get an instance of the Messaging utility which allows + * you to send and receive data in a unified way + * 3) Each platform implements {@link MessagingTransport} along with its own Configuration + * - For example, to learn what configuration is required for Webkit, see: {@link WebkitMessagingConfig} + * - Or, to learn about how messages are sent and received in Webkit, see {@link WebkitMessagingTransport} + * + * ## Links + * Please see the following links for examples + * + * - Windows: {@link WindowsMessagingConfig} + * - Webkit: {@link WebkitMessagingConfig} + * - Android: {@link AndroidMessagingConfig} + * - Schema: {@link "Messaging Schema"} + * - Implementation Guide: {@link "Messaging Implementation Guide"} + * + * @module Messaging + */ + + /** + * Common options/config that are *not* transport specific. + */ + class MessagingContext { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {"production" | "development"} params.env + * @internal + */ + constructor(params) { + this.context = params.context; + this.featureName = params.featureName; + this.env = params.env; + } + } + + /** + * @typedef {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | TestTransportConfig} MessagingConfig + */ + + /** + * + */ + class Messaging { + /** + * @param {MessagingContext} messagingContext + * @param {MessagingConfig} config + */ + constructor(messagingContext, config) { + this.messagingContext = messagingContext; + this.transport = getTransport(config, this.messagingContext); + } + + /** + * Send a 'fire-and-forget' message. + * @throws {MissingHandler} + * + * @example + * + * ```ts + * const messaging = new Messaging(config) + * messaging.notify("foo", {bar: "baz"}) + * ``` + * @param {string} name + * @param {Record} [data] + */ + notify(name, data = {}) { + const message = new NotificationMessage({ + context: this.messagingContext.context, + featureName: this.messagingContext.featureName, + method: name, + params: data, + }); + this.transport.notify(message); + } + + /** + * Send a request, and wait for a response + * @throws {MissingHandler} + * + * @example + * ``` + * const messaging = new Messaging(config) + * const response = await messaging.request("foo", {bar: "baz"}) + * ``` + * + * @param {string} name + * @param {Record} [data] + * @return {Promise} + */ + 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, + }); + return this.transport.request(message); + } + + /** + * @param {string} name + * @param {(value: unknown) => void} callback + * @return {() => void} + */ + subscribe(name, callback) { + const msg = new Subscription({ + context: this.messagingContext.context, + featureName: this.messagingContext.featureName, + subscriptionName: name, + }); + return this.transport.subscribe(msg, callback); + } + } + + /** + * Use this to create testing transport on the fly. + * It's useful for debugging, and for enabling scripts to run in + * other environments - for example, testing in a browser without the need + * for a full integration + */ + class TestTransportConfig { + /** + * @param {MessagingTransport} impl + */ + constructor(impl) { + this.impl = impl; + } + } + + /** + * @implements {MessagingTransport} + */ + class TestTransport { + /** + * @param {TestTransportConfig} config + * @param {MessagingContext} messagingContext + */ + constructor(config, messagingContext) { + this.config = config; + this.messagingContext = messagingContext; + } + + notify(msg) { + return this.config.impl.notify(msg); + } + + request(msg) { + return this.config.impl.request(msg); + } + + subscribe(msg, callback) { + return this.config.impl.subscribe(msg, callback); + } + } + + /** + * @param {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | TestTransportConfig} config + * @param {MessagingContext} messagingContext + * @returns {MessagingTransport} + */ + function getTransport(config, messagingContext) { + if (config instanceof WebkitMessagingConfig) { + return new WebkitMessagingTransport(config, messagingContext); + } + if (config instanceof WindowsMessagingConfig) { + return new WindowsMessagingTransport(config, messagingContext); + } + if (config instanceof AndroidMessagingConfig) { + return new AndroidMessagingTransport(config, messagingContext); + } + if (config instanceof TestTransportConfig) { + return new TestTransport(config, messagingContext); + } + throw new Error('unreachable'); + } + + /** + * Thrown when a handler cannot be found + */ + class MissingHandler extends Error { + /** + * @param {string} message + * @param {string} handlerName + */ + constructor(message, handlerName) { + super(message); + this.handlerName = handlerName; + } + } + + /** + * Workaround defining MessagingTransport locally because "import()" is not working in `@implements` + * @typedef {import('@duckduckgo/messaging').MessagingTransport} MessagingTransport + */ + + /** + * @deprecated - A temporary constructor for the extension to make the messaging config + */ + function extensionConstructMessagingConfig() { + const messagingTransport = new SendMessageMessagingTransport(); + return new TestTransportConfig(messagingTransport); + } + + /** + * A temporary implementation of {@link MessagingTransport} to communicate with Extensions. + * It wraps the current messaging system that calls `sendMessage` + * + * @implements {MessagingTransport} + * @deprecated - Use this only to communicate with Android and the Extension while support to {@link Messaging} + * is not ready and we need to use `sendMessage()`. + */ + class SendMessageMessagingTransport { + /** + * 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(); + + constructor() { + this.globals = { + window: globalThis, + globalThis, + JSONparse: globalThis.JSON.parse, + JSONstringify: globalThis.JSON.stringify, + Promise: globalThis.Promise, + Error: globalThis.Error, + String: globalThis.String, + }; + } + + /** + * Callback for update() handler. This connects messages sent from the Platform + * with callback functions in the _queue. + * @param {any} response + */ + onResponse(response) { + this._queue.forEach((subscription) => subscription(response)); + } + + /** + * @param {import('@duckduckgo/messaging').NotificationMessage} msg + */ + notify(msg) { + let params = msg.params; + + // Unwrap 'setYoutubePreviewsEnabled' params to match expected payload + // for sendMessage() + if (msg.method === 'setYoutubePreviewsEnabled') { + params = msg.params?.youtubePreviewsEnabled; + } + // Unwrap 'updateYouTubeCTLAddedFlag' params to match expected payload + // for sendMessage() + if (msg.method === 'updateYouTubeCTLAddedFlag') { + params = msg.params?.youTubeCTLAddedFlag; + } + + legacySendMessage(msg.method, params); + } + + /** + * @param {import('@duckduckgo/messaging').RequestMessage} req + * @return {Promise} + */ + request(req) { + let comparator = (eventData) => { + return eventData.responseMessageType === req.method; + }; + let params = req.params; + + // Adapts request for 'getYouTubeVideoDetails' by identifying the correct + // response for each request and updating params to expect current + // implementation specifications. + if (req.method === 'getYouTubeVideoDetails') { + comparator = (eventData) => { + return ( + eventData.responseMessageType === req.method && + eventData.response && + eventData.response.videoURL === req.params?.videoURL + ); + }; + params = req.params?.videoURL; + } + + legacySendMessage(req.method, params); + + return new this.globals.Promise((resolve) => { + this._subscribe(comparator, (msgRes, unsubscribe) => { + unsubscribe(); + + return resolve(msgRes.response); + }); + }); + } + + /** + * @param {import('@duckduckgo/messaging').Subscription} msg + * @param {(value: unknown | undefined) => void} callback + */ + subscribe(msg, callback) { + const comparator = (eventData) => { + 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 this._subscribe(comparator, cb); + } + + /** + * @param {(eventData: any) => boolean} comparator + * @param {(value: any, unsubscribe: (()=>void)) => void} callback + * @internal + */ + _subscribe(comparator, callback) { + /** @type {(()=>void) | undefined} */ + // eslint-disable-next-line prefer-const + let teardown; + + /** + * @param {MessageEvent} event + */ + const idHandler = (event) => { + if (!event) { + console.warn('no message available'); + return; + } + if (comparator(event)) { + if (!teardown) throw new this.globals.Error('unreachable'); + callback(event, teardown); + } + }; + this._queue.add(idHandler); + + teardown = () => { + this._queue.delete(idHandler); + }; + + return () => { + teardown?.(); + }; + } + } + + /** + * @typedef {object} AssetConfig + * @property {string} regularFontUrl + * @property {string} boldFontUrl + */ + + /** + * @typedef {object} Site + * @property {string | null} domain + * @property {boolean} [isBroken] + * @property {boolean} [allowlisted] + * @property {string[]} [enabledFeatures] + */ + + class ContentFeature { + /** @type {import('./utils.js').RemoteConfig | undefined} */ + #bundledConfig; + /** @type {object | undefined} */ + #trackerLookup; + /** @type {boolean | undefined} */ + #documentOriginIsTracker; + /** @type {Record | undefined} */ + // eslint-disable-next-line no-unused-private-class-members + #bundledfeatureSettings; + /** @type {import('../../messaging').Messaging} */ + // eslint-disable-next-line no-unused-private-class-members + #messaging; + /** @type {boolean} */ + #isDebugFlagSet = false; + + /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record, assets?: AssetConfig | undefined, site: Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */ + #args; + + constructor(featureName) { + this.name = featureName; + this.#args = null; + this.monitor = new PerformanceMonitor(); + } + + get isDebug() { + return this.#args?.debug || false; + } + + get desktopModeEnabled() { + return this.#args?.desktopModeEnabled || false; + } + + get forcedZoomEnabled() { + return this.#args?.forcedZoomEnabled || false; + } + + /** + * @param {import('./utils').Platform} platform + */ + set platform(platform) { + this._platform = platform; + } + + get platform() { + // @ts-expect-error - Type 'Platform | undefined' is not assignable to type 'Platform' + return this._platform; + } + + /** + * @type {AssetConfig | undefined} + */ + get assetConfig() { + return this.#args?.assets; + } + + /** + * @returns {boolean} + */ + get documentOriginIsTracker() { + return !!this.#documentOriginIsTracker; + } + + /** + * @returns {object} + **/ + get trackerLookup() { + return this.#trackerLookup || {}; + } + + /** + * @returns {import('./utils.js').RemoteConfig | undefined} + **/ + get bundledConfig() { + return this.#bundledConfig; + } + + /** + * @deprecated as we should make this internal to the class and not used externally + * @return {MessagingContext} + */ + _createMessagingContext() { + const contextName = 'contentScopeScripts'; + + return new MessagingContext({ + context: contextName, + env: this.isDebug ? 'development' : 'production', + featureName: this.name, + }); + } + + /** + * Lazily create a messaging instance for the given Platform + feature combo + * + * @return {import('@duckduckgo/messaging').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'); + messagingConfig = extensionConstructMessagingConfig(); + } + this._messaging = new Messaging(messagingContext, messagingConfig); + return this._messaging; + } + + /** + * Get the value of a config setting. + * If the value is not set, return the default value. + * If the value is not an object, return the value. + * If the value is an object, check its type property. + * @param {string} attrName + * @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) { + const configSetting = this.getFeatureSetting(attrName); + return processAttr(configSetting, defaultValue); + } + + /** + * Return a specific setting from the feature settings + * If the "settings" key within the config has a "domains" key, it will be used to override the settings. + * This uses JSONPatch to apply the patches to settings before getting the setting value. + * For example.com getFeatureSettings('val') will return 1: + * ```json + * { + * "settings": { + * "domains": [ + * { + * "domain": "example.com", + * "patchSettings": [ + * { "op": "replace", "path": "/val", "value": 1 } + * ] + * } + * ] + * } + * } + * ``` + * "domain" can either be a string or an array of strings. + + * For boolean states you should consider using getFeatureSettingEnabled. + * @param {string} featureKeyName + * @param {string} [featureName] + * @returns {any} + */ + getFeatureSetting(featureKeyName, featureName) { + let result = this._getFeatureSettings(featureName); + if (featureKeyName === 'domains') { + 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; + }); + for (const match of domainMatch) { + if (match.patchSettings === undefined) { + continue; + } + try { + result = immutableJSONPatch(result, match.patchSettings); + } catch (e) { + console.error('Error applying patch settings', e); + } + } + return result?.[featureKeyName]; + } + + /** + * Return the settings object for a feature + * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature + * @returns {any} + */ + _getFeatureSettings(featureName) { + const camelFeatureName = featureName || camelcase(this.name); + return this.#args?.featureSettings?.[camelFeatureName]; + } + + /** + * For simple boolean settings, return true if the setting is 'enabled' + * For objects, verify the 'state' field is 'enabled'. + * This allows for future forwards compatibility with more complex settings if required. + * For example: + * ```json + * { + * "toggle": "enabled" + * } + * ``` + * Could become later (without breaking changes): + * ```json + * { + * "toggle": { + * "state": "enabled", + * "someOtherKey": 1 + * } + * } + * ``` + * This also supports domain overrides as per `getFeatureSetting`. + * @param {string} featureKeyName + * @param {string} [featureName] + * @returns {boolean} + */ + getFeatureSettingEnabled(featureKeyName, featureName) { + const result = this.getFeatureSetting(featureKeyName, featureName); + if (typeof result === 'object') { + return result.state === 'enabled'; + } + return result === 'enabled'; + } + + /** + * Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page + * Consider using patchSettings instead as per `getFeatureSetting`. + * @param {string} featureKeyName + * @return {any[]} + * @private + */ + matchDomainFeatureSetting(featureKeyName) { + const domain = this.#args?.site.domain; + 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, rule.domain); + }); + } + + init(args) {} + + callInit(args) { + const mark = this.monitor.mark(this.name + 'CallInit'); + this.#args = args; + this.platform = args.platform; + this.init(args); + mark.end(); + this.measure(); + } + + load(args) {} + + /** + * This is a wrapper around `this.messaging.notify` that applies the + * auto-generated types from the `src/types` folder. It's used + * to provide per-feature type information based on the schemas + * in `src/messages` + * + * @type {import("@duckduckgo/messaging").Messaging['notify']} + */ + notify(...args) { + const [name, params] = args; + this.messaging.notify(name, params); + } + + /** + * This is a wrapper around `this.messaging.request` that applies the + * auto-generated types from the `src/types` folder. It's used + * to provide per-feature type information based on the schemas + * in `src/messages` + * + * @type {import("@duckduckgo/messaging").Messaging['request']} + */ + request(...args) { + const [name, params] = args; + return this.messaging.request(name, params); + } + + /** + * This is a wrapper around `this.messaging.subscribe` that applies the + * auto-generated types from the `src/types` folder. It's used + * to provide per-feature type information based on the schemas + * in `src/messages` + * + * @type {import("@duckduckgo/messaging").Messaging['subscribe']} + */ + subscribe(...args) { + const [name, cb] = args; + return this.messaging.subscribe(name, cb); + } + + /** + * @param {import('./content-scope-features.js').LoadArgs} args + */ + callLoad(args) { + const mark = this.monitor.mark(this.name + 'CallLoad'); + this.#args = args; + this.platform = args.platform; + this.#bundledConfig = args.bundledConfig; + // If we have a bundled config, treat it as a regular config + // This will be overriden by the remote config if it is available + if (this.#bundledConfig && this.#args) { + const enabledFeatures = computeEnabledFeatures(args.bundledConfig, args.site.domain, this.platform.version); + this.#args.featureSettings = parseFeatureSettings(args.bundledConfig, enabledFeatures); + } + this.#trackerLookup = args.trackerLookup; + this.#documentOriginIsTracker = args.documentOriginIsTracker; + this.load(args); + mark.end(); + } + + measure() { + if (this.#args?.debug) { + this.monitor.measureAll(); + } + } + + update() {} + + /** + * Register a flag that will be added to page breakage reports + */ + addDebugFlag() { + if (this.#isDebugFlagSet) return; + this.#isDebugFlagSet = true; + this.messaging?.notify('addDebugFlag', { + flag: this.name, + }); + } + + /** + * Define a property descriptor with debug flags. + * Mainly used for defining new properties. For overriding existing properties, consider using wrapProperty(), wrapMethod() and wrapConstructor(). + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.BatteryManager.prototype) + * @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) { + // 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) => { + const descriptorProp = descriptor[k]; + if (typeof descriptorProp === 'function') { + const addDebugFlag = this.addDebugFlag.bind(this); + const wrapper = new Proxy$1(descriptorProp, { + apply(target, thisArg, argumentsList) { + addDebugFlag(); + return Reflect$1.apply(descriptorProp, thisArg, argumentsList); + }, + }); + descriptor[k] = wrapToString(wrapper, descriptorProp); + } + }); + + return defineProperty(object, propertyName, descriptor); + } + + /** + * Wrap a `get`/`set` or `value` property descriptor. Only for data properties. For methods, use wrapMethod(). For constructors, use wrapConstructor(). + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Screen.prototype) + * @param {string} propertyName + * @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)); + } + + /** + * Wrap a method descriptor. Only for function properties. For data properties, use wrapProperty(). For constructors, use wrapConstructor(). + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Bluetooth.prototype) + * @param {string} propertyName + * @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)); + } + + /** + * @template {keyof typeof globalThis} StandardInterfaceName + * @param {StandardInterfaceName} interfaceName - the name of the interface to shim (must be some known standard API, e.g. 'MediaSession') + * @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)); + } + + /** + * Define a missing standard property on a global (prototype) object. Only for data properties. + * For constructors, use shimInterface(). + * Most of the time, you'd want to call shimInterface() first to shim the class itself (MediaSession), and then shimProperty() for the global singleton instance (Navigator.prototype.mediaSession). + * @template Base + * @template {keyof Base & string} K + * @param {Base} instanceHost - object whose property we are shimming (most commonly a prototype object, e.g. Navigator.prototype) + * @param {K} instanceProp - name of the property to shim (e.g. 'mediaSession') + * @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)); + } + } + + const ANIMATION_DURATION_MS = 1000; + const ANIMATION_ITERATIONS = Infinity; + const BACKGROUND_COLOR_START = 'rgba(85, 127, 243, 0.10)'; + const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)'; + const OVERLAY_ID = 'ddg-password-import-overlay'; + + /** + * @typedef ButtonAnimationStyle + * @property {Record} transform + * @property {string} zIndex + * @property {string} borderRadius + * @property {number} offsetLeftEm + * @property {number} offsetTopEm + */ + + /** + * @typedef ElementConfig + * @property {HTMLElement|Element|SVGElement} element + * @property {ButtonAnimationStyle} animationStyle + * @property {boolean} shouldTap + * @property {boolean} shouldWatchForRemoval + */ + + /** + * This feature is responsible for animating some buttons passwords.google.com, + * during a password import flow. The overall approach is: + * 1. Check if the path is supported, + * 2. Find the element to animate based on the path - using structural selectors first and then fallback to label texts), + * 3. Animate the element, or tap it if it should be autotapped. + */ + class AutofillPasswordImport extends ContentFeature { + #exportButtonSettings; + + #settingsButtonSettings; + + #signInButtonSettings; + + /** @type {HTMLElement|Element|SVGElement|null} */ + #elementToCenterOn; + + /** @type {HTMLElement|null} */ + #currentOverlay; + + /** @type {ElementConfig|null} */ + #currentElementConfig; + + #domLoaded; + + /** + * @returns {ButtonAnimationStyle} + */ + get settingsButtonAnimationStyle() { + return { + transform: { + start: 'scale(0.90)', + mid: 'scale(0.96)', + }, + zIndex: '984', + borderRadius: '100%', + offsetLeftEm: 0.01, + offsetTopEm: 0, + }; + } + + /** + * @returns {ButtonAnimationStyle} + */ + get exportButtonAnimationStyle() { + return { + transform: { + start: 'scale(1)', + mid: 'scale(1.01)', + }, + zIndex: '984', + borderRadius: '100%', + offsetLeftEm: 0, + offsetTopEm: 0, + }; + } + + /** + * @returns {ButtonAnimationStyle} + */ + get signInButtonAnimationStyle() { + return { + transform: { + start: 'scale(1)', + mid: 'scale(1.3, 1.5)', + }, + zIndex: '999', + borderRadius: '2px', + offsetLeftEm: 0, + offsetTopEm: -0.05, + }; + } + + /** + * @param {HTMLElement|null} overlay + */ + set currentOverlay(overlay) { + this.#currentOverlay = overlay; + } + + /** + * @returns {HTMLElement|null} + */ + get currentOverlay() { + return this.#currentOverlay ?? null; + } + + /** + * @returns {ElementConfig|null} + */ + get currentElementConfig() { + return this.#currentElementConfig; + } + + /** + * @returns {Promise} + */ + get domLoaded() { + return this.#domLoaded; + } + + /** + * Takes a path and returns the element and style to animate. + * @param {string} path + * @returns {Promise} + */ + async getElementAndStyleFromPath(path) { + if (path === '/') { + const element = await this.findSettingsElement(); + return element != null + ? { + animationStyle: this.settingsButtonAnimationStyle, + element, + shouldTap: this.#settingsButtonSettings?.shouldAutotap ?? false, + shouldWatchForRemoval: false, + } + : null; + } else if (path === '/options') { + const element = await this.findExportElement(); + return element != null + ? { + animationStyle: this.exportButtonAnimationStyle, + element, + shouldTap: this.#exportButtonSettings?.shouldAutotap ?? false, + shouldWatchForRemoval: true, + } + : null; + } else if (path === '/intro') { + const element = await this.findSignInButton(); + return element != null + ? { + animationStyle: this.signInButtonAnimationStyle, + element, + shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false, + shouldWatchForRemoval: false, + } + : null; + } else { + return null; + } + } + + /** + * Removes the overlay if it exists. + */ + removeOverlayIfNeeded() { + if (this.currentOverlay != null) { + this.currentOverlay.style.display = 'none'; + this.currentOverlay.remove(); + this.currentOverlay = null; + document.removeEventListener('scroll', this); + } + } + + /** + * Updates the position of the overlay based on the element to center on. + */ + updateOverlayPosition() { + if (this.currentOverlay != null && this.currentElementConfig?.animationStyle != null && this.elementToCenterOn != null) { + const animations = this.currentOverlay.getAnimations(); + animations.forEach((animation) => animation.pause()); + const { top, left, width, height } = this.elementToCenterOn.getBoundingClientRect(); + this.currentOverlay.style.position = 'absolute'; + + const { animationStyle } = this.currentElementConfig; + const isRound = animationStyle.borderRadius === '100%'; + + const widthOffset = isRound ? width / 2 : 0; + const heightOffset = isRound ? height / 2 : 0; + + this.currentOverlay.style.top = `calc(${top}px + ${window.scrollY}px - ${widthOffset}px - 1px - ${animationStyle.offsetTopEm}em)`; + this.currentOverlay.style.left = `calc(${left}px + ${window.scrollX}px - ${heightOffset}px - 1px - ${animationStyle.offsetLeftEm}em)`; + + // Ensure overlay is non-interactive + this.currentOverlay.style.pointerEvents = 'none'; + animations.forEach((animation) => animation.play()); + } + } + + /** + * Creates an overlay element to animate, by adding a div to the body + * and styling it based on the found element. + * @param {HTMLElement|Element} mainElement + * @param {any} style + */ + createOverlayElement(mainElement, style) { + this.removeOverlayIfNeeded(); + + const overlay = document.createElement('div'); + overlay.setAttribute('id', OVERLAY_ID); + + if (this.elementToCenterOn != null) { + this.currentOverlay = overlay; + this.updateOverlayPosition(); + const mainElementRect = mainElement.getBoundingClientRect(); + overlay.style.width = `${mainElementRect.width}px`; + overlay.style.height = `${mainElementRect.height}px`; + overlay.style.zIndex = style.zIndex; + + // Ensure overlay is non-interactive + overlay.style.pointerEvents = 'none'; + + // insert in document.body + document.body.appendChild(overlay); + + document.addEventListener('scroll', this, { passive: true }); + } else { + this.currentOverlay = null; + } + } + + /** + * Observes the removal of an element from the DOM. + * @param {HTMLElement|Element} element + * @param {any} onRemoveCallback + */ + observeElementRemoval(element, onRemoveCallback) { + // Set up the mutation observer + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + // Check if the element has been removed from its parent + if (mutation.type === 'childList' && !document.contains(element)) { + // Element has been removed + onRemoveCallback(); + observer.disconnect(); // Stop observing + } + }); + }); + + // Start observing the parent node for child list changes + observer.observe(document.body, { childList: true, subtree: true }); + } + + /** + * + * @param {HTMLElement|Element|SVGElement} element + * @param {ButtonAnimationStyle} style + */ + setElementToCenterOn(element, style) { + const svgElement = element.parentNode?.querySelector('svg') ?? element.querySelector('svg'); + this.#elementToCenterOn = style.borderRadius === '100%' && svgElement != null ? svgElement : element; + } + + /** + * @returns {HTMLElement|Element|SVGElement|null} + */ + get elementToCenterOn() { + return this.#elementToCenterOn; + } + + /** + * Moves the element into view and animates it. + * @param {HTMLElement|Element} element + * @param {ButtonAnimationStyle} style + */ + animateElement(element, style) { + this.createOverlayElement(element, style); + if (this.currentOverlay != null) { + this.currentOverlay.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); // Scroll into view + const keyframes = [ + { + backgroundColor: BACKGROUND_COLOR_START, + offset: 0, + borderRadius: style.borderRadius, + border: `1px solid ${BACKGROUND_COLOR_START}`, + transform: style.transform.start, + }, // Start: 10% blue + { + backgroundColor: BACKGROUND_COLOR_END, + offset: 0.5, + borderRadius: style.borderRadius, + border: `1px solid ${BACKGROUND_COLOR_END}`, + transform: style.transform.mid, + transformOrigin: 'center', + }, // Middle: 25% blue + { + backgroundColor: BACKGROUND_COLOR_START, + offset: 1, + borderRadius: style.borderRadius, + border: `1px solid ${BACKGROUND_COLOR_START}`, + transform: style.transform.start, + }, // End: 10% blue + ]; + + // Define the animation options + const options = { + duration: ANIMATION_DURATION_MS, + iterations: ANIMATION_ITERATIONS, + }; + + // Apply the animation to the element + this.currentOverlay.animate(keyframes, options); + } + } + + autotapElement(element) { + element.click(); + } + + /** + * On passwords.google.com the export button is in a container that is quite ambiguious. + * To solve for that we first try to find the container and then the button inside it. + * If that fails, we look for the button based on it's label. + * @returns {Promise} + */ + async findExportElement() { + const findInContainer = () => { + const exportButtonContainer = document.querySelector(this.exportButtonContainerSelector); + return exportButtonContainer && exportButtonContainer.querySelectorAll('button')[1]; + }; + + const findWithLabel = () => { + return document.querySelector(this.exportButtonLabelTextSelector); + }; + + return await withExponentialBackoff(() => findInContainer() ?? findWithLabel()); + } + + /** + * @returns {Promise} + */ + async findSettingsElement() { + const fn = () => { + const settingsButton = document.querySelector(this.settingsButtonSelector); + return settingsButton; + }; + return await withExponentialBackoff(fn); + } + + /** + * @returns {Promise} + */ + async findSignInButton() { + return await withExponentialBackoff(() => document.querySelector(this.signinButtonSelector)); + } + + /** + * @param {Event} event + */ + handleEvent(event) { + if (event.type === 'scroll') { + requestAnimationFrame(() => this.updateOverlayPosition()); + } + } + + /** + * @param {ElementConfig|null} config + */ + setCurrentElementConfig(config) { + if (config != null) { + this.#currentElementConfig = config; + this.setElementToCenterOn(config.element, config.animationStyle); + } + } + + /** + * Checks if the path is supported for animation. + * @param {string} path + * @returns {boolean} + */ + isSupportedPath(path) { + return [this.#exportButtonSettings?.path, this.#settingsButtonSettings?.path, this.#signInButtonSettings?.path].includes(path); + } + + async handlePath(path) { + this.removeOverlayIfNeeded(); + if (this.isSupportedPath(path)) { + try { + this.setCurrentElementConfig(await this.getElementAndStyleFromPath(path)); + await this.animateOrTapElement(); + } catch { + console.error('password-import: failed for path:', path); + } + } + } + + /** + * Based on the current element config, animates the element or taps it. + * If the element should be watched for removal, it sets up a mutation observer. + */ + async animateOrTapElement() { + const { element, animationStyle, shouldTap, shouldWatchForRemoval } = this.currentElementConfig ?? {}; + if (element != null && animationStyle != null) { + if (shouldTap) { + this.autotapElement(element); + } else { + await this.domLoaded; + this.animateElement(element, animationStyle); + } + if (shouldWatchForRemoval) { + // Sometimes navigation events are not triggered, then we need to watch for removal + this.observeElementRemoval(element, () => { + this.removeOverlayIfNeeded(); + }); + } + } + } + + /** + * @returns {string} + */ + get exportButtonContainerSelector() { + return this.#exportButtonSettings?.selectors?.join(','); + } + + /** + * @returns {string} + */ + get exportButtonLabelTextSelector() { + return this.#exportButtonSettings?.labelTexts.map((text) => `button[aria-label="${text}"]`).join(','); + } + + /** + * @returns {string} + */ + get signinLabelTextSelector() { + return this.#signInButtonSettings?.labelTexts.map((text) => `a[aria-label="${text}"]:not([target="_top"])`).join(','); + } + + /** + * @returns {string} + */ + get signinButtonSelector() { + return `${this.#signInButtonSettings?.selectors?.join(',')}, ${this.signinLabelTextSelector}`; + } + + /** + * @returns {string} + */ + get settingsLabelTextSelector() { + return this.#settingsButtonSettings?.labelTexts.map((text) => `a[aria-label="${text}"]`).join(','); + } + + /** + * @returns {string} + */ + get settingsButtonSelector() { + return `${this.#settingsButtonSettings?.selectors?.join(',')}, ${this.settingsLabelTextSelector}`; + } + + setButtonSettings() { + this.#exportButtonSettings = this.getFeatureSetting('exportButton'); + this.#signInButtonSettings = this.getFeatureSetting('signInButton'); + this.#settingsButtonSettings = this.getFeatureSetting('settingsButton'); + } + + init() { + this.setButtonSettings(); + + const handlePath = this.handlePath.bind(this); + const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', { + async apply(target, thisArg, args) { + const path = args[1] === '' ? args[2].split('?')[0] : args[1]; + await handlePath(path); + return DDGReflect.apply(target, thisArg, args); + }, + }); + historyMethodProxy.overload(); + // listen for popstate events in order to run on back/forward navigations + window.addEventListener('popstate', async () => { + const path = window.location.pathname; + await handlePath(path); + }); + + this.#domLoaded = new Promise((resolve) => { + if (document.readyState !== 'loading') { + // @ts-expect-error - caller doesn't expect a value here + resolve(); + return; + } + + document.addEventListener( + 'DOMContentLoaded', + async () => { + // @ts-expect-error - caller doesn't expect a value here + resolve(); + const path = window.location.pathname; + await handlePath(path); + }, + { once: true }, + ); + }); + } + } + + var platformFeatures = { + ddg_feature_autofillPasswordImport: AutofillPasswordImport + }; + + let initArgs = null; + const updates = []; + const features = []; + const alwaysInitFeatures = new Set(['cookie']); + const performanceMonitor = new PerformanceMonitor(); + + // It's important to avoid enabling the features for non-HTML documents (such as + // XML documents that aren't XHTML). Note that it's necessary to check the + // document type in advance, to minimise the risk of a website breaking the + // checks by altering document.__proto__. In the future, it might be worth + // running the checks even earlier (and in the "isolated world" for the Chrome + // extension), to further reduce that risk. + const isHTMLDocument = + document instanceof HTMLDocument || (document instanceof XMLDocument && document.createElement('div') instanceof HTMLDivElement); + + /** + * @typedef {object} LoadArgs + * @property {import('./content-feature').Site} site + * @property {import('./utils.js').Platform} platform + * @property {boolean} documentOriginIsTracker + * @property {import('./utils.js').RemoteConfig} bundledConfig + * @property {string} [injectName] + * @property {object} trackerLookup - provided currently only by the extension + * @property {import('@duckduckgo/messaging').MessagingConfig} [messagingConfig] + */ + + /** + * @param {LoadArgs} args + */ + function load(args) { + const mark = performanceMonitor.mark('load'); + if (!isHTMLDocument) { + return; + } + + const featureNames = platformSupport["android-autofill-password-import"] ; + + for (const featureName of featureNames) { + const ContentFeature = platformFeatures['ddg_feature_' + featureName]; + const featureInstance = new ContentFeature(featureName); + featureInstance.callLoad(args); + features.push({ featureName, featureInstance }); + } + mark.end(); + } + + async function init(args) { + const mark = performanceMonitor.mark('init'); + initArgs = args; + if (!isHTMLDocument) { + return; + } + registerMessageSecret(args.messageSecret); + initStringExemptionLists(args); + const resolvedFeatures = await Promise.all(features); + resolvedFeatures.forEach(({ featureInstance, featureName }) => { + if (!isFeatureBroken(args, featureName) || alwaysInitExtensionFeatures(args, featureName)) { + featureInstance.callInit(args); + } + }); + // Fire off updates that came in faster than the init + while (updates.length) { + const update = updates.pop(); + await updateFeaturesInner(update); + } + mark.end(); + if (args.debug) { + performanceMonitor.measureAll(); + } + } + + function alwaysInitExtensionFeatures(args, featureName) { + return args.platform.name === 'extension' && alwaysInitFeatures.has(featureName); + } + + async function updateFeaturesInner(args) { + const resolvedFeatures = await Promise.all(features); + resolvedFeatures.forEach(({ featureInstance, featureName }) => { + if (!isFeatureBroken(initArgs, featureName) && featureInstance.update) { + featureInstance.update(args); + } + }); + } + + /** + * Check if the current document origin is on the tracker list, using the provided lookup trie. + * @param {object} trackerLookup Trie lookup of tracker domains + * @returns {boolean} True iff the origin is a tracker. + */ + function isTrackerOrigin(trackerLookup, originHostname = document.location.hostname) { + const parts = originHostname.split('.').reverse(); + let node = trackerLookup; + for (const sub of parts) { + if (node[sub] === 1) { + return true; + } else if (node[sub]) { + node = node[sub]; + } else { + return false; + } + } + return false; + } + + /** + * @module Android integration + */ + + function initCode() { + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const config = $CONTENT_SCOPE$; + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const userUnprotectedDomains = $USER_UNPROTECTED_DOMAINS$; + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const userPreferences = $USER_PREFERENCES$; + + const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences); + if (isGloballyDisabled(processedConfig)) { + return; + } + + const configConstruct = processedConfig; + const messageCallback = configConstruct.messageCallback; + const messageSecret = configConstruct.messageSecret; + const javascriptInterface = configConstruct.javascriptInterface; + processedConfig.messagingConfig = new AndroidMessagingConfig({ + messageSecret, + messageCallback, + javascriptInterface, + target: globalThis, + debug: processedConfig.debug, + }); + + load({ + platform: processedConfig.platform, + trackerLookup: processedConfig.trackerLookup, + documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup), + site: processedConfig.site, + bundledConfig: processedConfig.bundledConfig, + messagingConfig: processedConfig.messagingConfig, + }); + + init(processedConfig); + } + + initCode(); + +})(); 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 98ef773dd61e..e7e7b4a547e1 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js @@ -9,12 +9,54 @@ const customElementsDefine = globalThis.customElements?.define.bind(globalThis.customElements); const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; + const toString = Object.prototype.toString; const objectKeys = Object.keys; const objectEntries = Object.entries; const objectDefineProperty = Object.defineProperty; const URL$1 = globalThis.URL; const Proxy$1 = globalThis.Proxy; + const functionToString = Function.prototype.toString; + const TypeError$1 = globalThis.TypeError; + const Symbol = globalThis.Symbol; const hasOwnProperty = Object.prototype.hasOwnProperty; + const dispatchEvent = globalThis.dispatchEvent?.bind(globalThis); + const addEventListener = globalThis.addEventListener?.bind(globalThis); + const removeEventListener = globalThis.removeEventListener?.bind(globalThis); + const CustomEvent$1 = globalThis.CustomEvent; + const Promise$1 = globalThis.Promise; + const String$1 = globalThis.String; + const Map$1 = globalThis.Map; + const Error$2 = globalThis.Error; + const randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto); + + var capturedGlobals = /*#__PURE__*/Object.freeze({ + __proto__: null, + CustomEvent: CustomEvent$1, + Error: Error$2, + Map: Map$1, + Promise: Promise$1, + Proxy: Proxy$1, + Reflect: Reflect$1, + Set: Set$1, + String: String$1, + Symbol: Symbol, + TypeError: TypeError$1, + URL: URL$1, + addEventListener: addEventListener, + customElementsDefine: customElementsDefine, + customElementsGet: customElementsGet, + dispatchEvent: dispatchEvent, + functionToString: functionToString, + getOwnPropertyDescriptor: getOwnPropertyDescriptor, + getOwnPropertyDescriptors: getOwnPropertyDescriptors, + hasOwnProperty: hasOwnProperty, + objectDefineProperty: objectDefineProperty, + objectEntries: objectEntries, + objectKeys: objectKeys, + randomUUID: randomUUID, + removeEventListener: removeEventListener, + toString: toString + }); /* eslint-disable no-redeclare, no-global-assign */ /* global cloneInto, exportFunction, false */ @@ -203,7 +245,7 @@ } function isFeatureBroken(args, feature) { - return isWindowsSpecificFeature(feature) + return isPlatformSpecificFeature(feature) ? !args.site.enabledFeatures.includes(feature) : args.site.isBroken || args.site.allowlisted || !args.site.enabledFeatures.includes(feature); } @@ -656,10 +698,14 @@ return args.site.allowlisted || args.site.isBroken; } - const windowsSpecificFeatures = ['windowsPermissionUsage']; + /** + * @import {FeatureName} from "./features"; + * @type {FeatureName[]} + */ + const platformSpecificFeatures = ['windowsPermissionUsage', 'messageBridge']; - function isWindowsSpecificFeature(featureName) { - return windowsSpecificFeatures.includes(featureName); + function isPlatformSpecificFeature(featureName) { + return platformSpecificFeatures.includes(featureName); } function createCustomEvent(eventName, eventDetail) { @@ -697,6 +743,7 @@ const otherFeatures = /** @type {const} */ ([ 'clickToLoad', 'cookie', + 'messageBridge', 'duckPlayer', 'harmfulApis', 'webCompat', @@ -711,7 +758,7 @@ /** @type {Record} */ const platformSupport = { apple: ['webCompat', ...baseFeatures], - 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad'], + 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge'], android: [...baseFeatures, 'webCompat', 'clickToLoad', 'breakageReporting', 'duckPlayer'], 'android-autofill-password-import': ['autofillPasswordImport'], windows: ['cookie', ...baseFeatures, 'windowsPermissionUsage', 'duckPlayer', 'brokerProtection', 'breakageReporting'], @@ -3990,6 +4037,26 @@ /** * Return a specific setting from the feature settings + * If the "settings" key within the config has a "domains" key, it will be used to override the settings. + * This uses JSONPatch to apply the patches to settings before getting the setting value. + * For example.com getFeatureSettings('val') will return 1: + * ```json + * { + * "settings": { + * "domains": [ + * { + * "domain": "example.com", + * "patchSettings": [ + * { "op": "replace", "path": "/val", "value": 1 } + * ] + * } + * ] + * } + * } + * ``` + * "domain" can either be a string or an array of strings. + + * For boolean states you should consider using getFeatureSettingEnabled. * @param {string} featureKeyName * @param {string} [featureName] * @returns {any} @@ -4028,6 +4095,23 @@ /** * For simple boolean settings, return true if the setting is 'enabled' * For objects, verify the 'state' field is 'enabled'. + * This allows for future forwards compatibility with more complex settings if required. + * For example: + * ```json + * { + * "toggle": "enabled" + * } + * ``` + * Could become later (without breaking changes): + * ```json + * { + * "toggle": { + * "state": "enabled", + * "someOtherKey": 1 + * } + * } + * ``` + * This also supports domain overrides as per `getFeatureSetting`. * @param {string} featureKeyName * @param {string} [featureName] * @returns {boolean} @@ -4042,8 +4126,10 @@ /** * Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page + * Consider using patchSettings instead as per `getFeatureSetting`. * @param {string} featureKeyName * @return {any[]} + * @private */ matchDomainFeatureSetting(featureKeyName) { const domain = this.#args?.site.domain; @@ -6021,8 +6107,470 @@ } } + /** + * @param {unknown} input + * @return {input is Object} + */ + function isObject(input) { + return toString.call(input) === '[object Object]'; + } + + /** + * @param {unknown} input + * @return {input is string} + */ + function isString(input) { + return typeof input === 'string'; + } + + /** + * @import { Messaging } from "@duckduckgo/messaging"; + * @typedef {Pick} MessagingInterface + */ + + /** + * Sending this event + */ + class InstallProxy { + static NAME = 'INSTALL_BRIDGE'; + get name() { + return InstallProxy.NAME; + } + + /** + * @param {object} params + * @param {string} params.featureName + * @param {string} params.id + */ + constructor(params) { + this.featureName = params.featureName; + this.id = params.id; + } + + /** + * @param {unknown} params + */ + static create(params) { + if (!isObject(params)) return null; + if (!isString(params.featureName)) return null; + if (!isString(params.id)) return null; + return new InstallProxy({ featureName: params.featureName, id: params.id }); + } + } + + class DidInstall { + static NAME = 'DID_INSTALL'; + get name() { + return DidInstall.NAME; + } + /** + * @param {object} params + * @param {string} params.id + */ + constructor(params) { + this.id = params.id; + } + + /** + * @param {unknown} params + */ + static create(params) { + if (!isObject(params)) return null; + if (!isString(params.id)) return null; + return new DidInstall({ id: params.id }); + } + } + + class ProxyRequest { + static NAME = 'PROXY_REQUEST'; + get name() { + return ProxyRequest.NAME; + } + /** + * @param {object} params + * @param {string} params.featureName + * @param {string} params.method + * @param {string} params.id + * @param {Record} [params.params] + */ + constructor(params) { + this.featureName = params.featureName; + this.method = params.method; + this.params = params.params; + this.id = params.id; + } + /** + * @param {unknown} params + */ + static create(params) { + if (!isObject(params)) return null; + if (!isString(params.featureName)) return null; + if (!isString(params.method)) return null; + if (!isString(params.id)) return null; + if (params.params && !isObject(params.params)) return null; + return new ProxyRequest({ + featureName: params.featureName, + method: params.method, + params: params.params, + id: params.id, + }); + } + } + + class ProxyResponse { + static NAME = 'PROXY_RESPONSE'; + get name() { + return ProxyResponse.NAME; + } + /** + * @param {object} params + * @param {string} params.featureName + * @param {string} params.method + * @param {string} params.id + * @param {Record} [params.result] + * @param {import("@duckduckgo/messaging").MessageError} [params.error] + */ + constructor(params) { + this.featureName = params.featureName; + this.method = params.method; + this.result = params.result; + this.error = params.error; + this.id = params.id; + } + /** + * @param {unknown} params + */ + static create(params) { + if (!isObject(params)) return null; + if (!isString(params.featureName)) return null; + if (!isString(params.method)) return null; + if (!isString(params.id)) return null; + if (params.result && !isObject(params.result)) return null; + if (params.error && !isObject(params.error)) return null; + return new ProxyResponse({ + featureName: params.featureName, + method: params.method, + result: params.result, + error: params.error, + id: params.id, + }); + } + } + + /** + */ + class ProxyNotification { + static NAME = 'PROXY_NOTIFICATION'; + get name() { + return ProxyNotification.NAME; + } + /** + * @param {object} params + * @param {string} params.featureName + * @param {string} params.method + * @param {Record} [params.params] + */ + constructor(params) { + this.featureName = params.featureName; + this.method = params.method; + this.params = params.params; + } + + /** + * @param {unknown} params + */ + static create(params) { + if (!isObject(params)) return null; + if (!isString(params.featureName)) return null; + if (!isString(params.method)) return null; + if (params.params && !isObject(params.params)) return null; + return new ProxyNotification({ + featureName: params.featureName, + method: params.method, + params: params.params, + }); + } + } + + class SubscriptionRequest { + static NAME = 'SUBSCRIPTION_REQUEST'; + get name() { + return SubscriptionRequest.NAME; + } + /** + * @param {object} params + * @param {string} params.featureName + * @param {string} params.subscriptionName + * @param {string} params.id + */ + constructor(params) { + this.featureName = params.featureName; + this.subscriptionName = params.subscriptionName; + this.id = params.id; + } + /** + * @param {unknown} params + */ + static create(params) { + if (!isObject(params)) return null; + if (!isString(params.featureName)) return null; + if (!isString(params.subscriptionName)) return null; + if (!isString(params.id)) return null; + return new SubscriptionRequest({ + featureName: params.featureName, + subscriptionName: params.subscriptionName, + id: params.id, + }); + } + } + + class SubscriptionResponse { + static NAME = 'SUBSCRIPTION_RESPONSE'; + get name() { + return SubscriptionResponse.NAME; + } + /** + * @param {object} params + * @param {string} params.featureName + * @param {string} params.subscriptionName + * @param {string} params.id + * @param {Record} [params.params] + */ + constructor(params) { + this.featureName = params.featureName; + this.subscriptionName = params.subscriptionName; + this.id = params.id; + this.params = params.params; + } + /** + * @param {unknown} params + */ + static create(params) { + if (!isObject(params)) return null; + if (!isString(params.featureName)) return null; + if (!isString(params.subscriptionName)) return null; + if (!isString(params.id)) return null; + if (params.params && !isObject(params.params)) return null; + return new SubscriptionResponse({ + featureName: params.featureName, + subscriptionName: params.subscriptionName, + params: params.params, + id: params.id, + }); + } + } + + class SubscriptionUnsubscribe { + static NAME = 'SUBSCRIPTION_UNSUBSCRIBE'; + get name() { + return SubscriptionUnsubscribe.NAME; + } + /** + * @param {object} params + * @param {string} params.id + */ + constructor(params) { + this.id = params.id; + } + /** + * @param {unknown} params + */ + static create(params) { + if (!isObject(params)) return null; + if (!isString(params.id)) return null; + return new SubscriptionUnsubscribe({ + id: params.id, + }); + } + } + + /** + * @import { MessagingInterface } from "./schema.js" + * @typedef {Pick + * } Captured + */ + /** @type {Captured} */ + const captured = capturedGlobals; + + const ERROR_MSG = 'Did not install Message Bridge'; + + /** + * Try to create a message bridge. + * + * Note: This will throw an exception if the bridge cannot be established. + * + * @param {string} featureName + * @param {string} [token] + * @return {MessagingInterface} + * @throws {Error} + */ + function createPageWorldBridge(featureName, token) { + /** + * This feature never operates without a featureName or token + */ + if (typeof featureName !== 'string' || !token) { + throw new captured.Error(ERROR_MSG); + } + /** + * This feature never operates in a frame or insecure context + */ + if (isBeingFramed() || !isSecureContext) { + throw new captured.Error(ERROR_MSG); + } + + /** + * @param {string} eventName + * @return {`${string}-${string}`} + */ + const appendToken = (eventName) => { + return `${eventName}-${token}`; + }; + + /** + * Create the sender to centralize the sending logic + * @param {{name: string} & Record} incoming + */ + const send = (incoming) => { + // when the token is absent, just silently fail + if (!token) return; + const event = new captured.CustomEvent(appendToken(incoming.name), { detail: incoming }); + captured.dispatchEvent(event); + }; + + /** + * Events are synchronous (even across contexts), so we can figure out + * the result of installing the proxy before we return and give a + * better experience for consumers + */ + let installed = false; + const id = random(); + const evt = new InstallProxy({ featureName, id }); + const evtName = appendToken(DidInstall.NAME + '-' + id); + const didInstall = (/** @type {CustomEvent} */ e) => { + const result = DidInstall.create(e.detail); + if (result && result.id === id) { + installed = true; + } + captured.removeEventListener(evtName, didInstall); + }; + + captured.addEventListener(evtName, didInstall); + send(evt); + + if (!installed) { + // leaving this as a generic message for now + throw new captured.Error(ERROR_MSG); + } + + return createMessagingInterface(featureName, send, appendToken); + } + + /** + * We are executing exclusively in secure contexts, so this should never fail + */ + function random() { + if (typeof captured.randomUUID !== 'function') throw new Error('unreachable'); + return captured.randomUUID(); + } + + /** + * @param {string} featureName + * @param {(evt: {name: string} & Record) => void} send + * @param {(s: string) => string} appendToken + * @returns {MessagingInterface} + */ + function createMessagingInterface(featureName, send, appendToken) { + return { + /** + * @param {string} method + * @param {Record} params + */ + notify(method, params) { + send( + new ProxyNotification({ + method, + params, + featureName, + }), + ); + }, + + /** + * @param {string} method + * @param {Record} params + * @returns {Promise} + */ + request(method, params) { + const id = random(); + + send( + new ProxyRequest({ + method, + params, + featureName, + id, + }), + ); + + return new Promise((resolve, reject) => { + const responseName = appendToken(ProxyResponse.NAME + '-' + id); + const handler = (/** @type {CustomEvent} */ e) => { + const response = ProxyResponse.create(e.detail); + if (response && response.id === id) { + if ('error' in response && response.error) { + reject(new Error(response.error.message)); + } else if ('result' in response) { + resolve(response.result); + } + captured.removeEventListener(responseName, handler); + } + }; + captured.addEventListener(responseName, handler); + }); + }, + + /** + * @param {string} name + * @param {(d: any) => void} callback + * @returns {() => void} + */ + subscribe(name, callback) { + const id = random(); + + send( + new SubscriptionRequest({ + subscriptionName: name, + featureName, + id, + }), + ); + + const handler = (/** @type {CustomEvent} */ e) => { + const subscriptionEvent = SubscriptionResponse.create(e.detail); + if (subscriptionEvent) { + const { id: eventId, params } = subscriptionEvent; + if (eventId === id) { + callback(params); + } + } + }; + + const type = appendToken(SubscriptionResponse.NAME + '-' + id); + captured.addEventListener(type, handler); + + return () => { + captured.removeEventListener(type, handler); + const evt = new SubscriptionUnsubscribe({ id }); + send(evt); + }; + }, + }; + } + class NavigatorInterface extends ContentFeature { load(args) { + // @ts-expect-error: Accessing private method if (this.matchDomainFeatureSetting('privilegedDomains').length) { this.injectNavigatorInterface(args); } @@ -6047,6 +6595,15 @@ isDuckDuckGo() { return DDGPromise.resolve(true); }, + /** + * @import { MessagingInterface } from "./message-bridge/schema.js" + * @param {string} featureName + * @return {MessagingInterface} + * @throws {Error} + */ + createMessageBridge(featureName) { + return createPageWorldBridge(featureName, args.messageSecret); + }, }, enumerable: true, configurable: false, @@ -6373,10 +6930,12 @@ // determine whether strict hide rules should be injected as a style tag if (shouldInjectStyleTag) { + // @ts-expect-error: Accessing private method shouldInjectStyleTag = this.matchDomainFeatureSetting('styleTagExceptions').length === 0; } // collect all matching rules for domain + // @ts-expect-error: Accessing private method const activeDomainRules = this.matchDomainFeatureSetting('domains').flatMap((item) => item.rules); const overrideRules = activeDomainRules.filter((rule) => { @@ -13365,7 +13924,13 @@ function initCode() { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - const processedConfig = processConfig($CONTENT_SCOPE$, $USER_UNPROTECTED_DOMAINS$, $USER_PREFERENCES$); + const config = $CONTENT_SCOPE$; + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const userUnprotectedDomains = $USER_UNPROTECTED_DOMAINS$; + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const userPreferences = $USER_PREFERENCES$; + + const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences); if (isGloballyDisabled(processedConfig)) { return; } diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js index 9cf8cbe77704..4999e435b990 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js @@ -1098,11 +1098,11 @@ const opts2 = new WindowsMessagingConfig({ methods: { // @ts-expect-error - not in @types/chrome - postMessage: window.chrome.webview.postMessage, + postMessage: globalThis.windowsInteropPostMessage, // @ts-expect-error - not in @types/chrome - addEventListener: window.chrome.webview.addEventListener, + addEventListener: globalThis.windowsInteropAddEventListener, // @ts-expect-error - not in @types/chrome - removeEventListener: window.chrome.webview.removeEventListener + removeEventListener: globalThis.windowsInteropRemoveEventListener } }); return new Messaging(messageContext, opts2); @@ -2221,8 +2221,8 @@ }; // shared/components/Fallback/Fallback.jsx - function Fallback({ showDetails }) { - return /* @__PURE__ */ _("div", { class: Fallback_default.fallback }, /* @__PURE__ */ _("div", null, /* @__PURE__ */ _("p", null, "Something went wrong!"), showDetails && /* @__PURE__ */ _("p", null, "Please check logs for a message called ", /* @__PURE__ */ _("code", null, "reportPageException")))); + function Fallback({ showDetails, children }) { + return /* @__PURE__ */ _("div", { class: Fallback_default.fallback }, /* @__PURE__ */ _("div", null, /* @__PURE__ */ _("p", null, "Something went wrong!"), children, showDetails && /* @__PURE__ */ _("p", null, "Please check logs for a message called ", /* @__PURE__ */ _("code", null, "reportPageException")))); } // pages/duckplayer/app/components/Components.module.css diff --git a/package-lock.json b/package-lock.json index 6b6a3fa60842..16b1bec6d3d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,9 @@ "name": "ddg-android", "version": "1.0.0", "dependencies": { - "@duckduckgo/autoconsent": "^10.17.0", + "@duckduckgo/autoconsent": "^12.1.0", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#15.1.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.33.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.41.0", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#7.0.2", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1724449523" }, @@ -26,6 +26,7 @@ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -40,30 +41,39 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@duckduckgo/autoconsent": { - "version": "10.17.0", - "resolved": "https://registry.npmjs.org/@duckduckgo/autoconsent/-/autoconsent-10.17.0.tgz", - "integrity": "sha512-zMB4BE5fpiqvjXPA0k8bCorWgh6eFMlkedRfuRVQYhbWqwLgrnsA7lv4U0ORTIJkvbBjABuYaprwr1yd/15D/w==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@duckduckgo/autoconsent/-/autoconsent-12.1.0.tgz", + "integrity": "sha512-tWWYDYyzNKR2L1eah2FdIvytd5+4ymAWBwdAsd3WL22txfZ3iqsQgSZGMkFZSC1NMtamVjFudadyTCi/MqWHbA==", + "license": "MPL-2.0", "dependencies": { - "tldts-experimental": "^6.1.37" + "@ghostery/adblocker": "^2.0.4", + "@ghostery/adblocker-content": "^2.0.4", + "tldts-experimental": "^6.1.41" } }, "node_modules/@duckduckgo/autofill": { "resolved": "git+ssh://git@github.com/duckduckgo/duckduckgo-autofill.git#c992041d16ec10d790e6204dce9abf9966d1363c", - "hasInstallScript": true + "hasInstallScript": true, + "license": "Apache-2.0" }, "node_modules/@duckduckgo/content-scope-scripts": { - "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#96382a1140e2344f04e33fe9ae28da1290fa2d23", + "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#c4bb146afdf0c7a93fb9a7d95b1cb255708a470d", + "license": "Apache-2.0", "workspaces": [ "injected", "special-pages", "messaging", "types-generator" - ] + ], + "dependencies": { + "immutable-json-patch": "^6.0.1" + } }, "node_modules/@duckduckgo/privacy-dashboard": { "resolved": "git+ssh://git@github.com/duckduckgo/privacy-dashboard.git#597bffc321a8f4b8d23b13aa0145406c393c0d8e", @@ -73,13 +83,44 @@ } }, "node_modules/@duckduckgo/privacy-reference-tests": { - "resolved": "git+ssh://git@github.com/duckduckgo/privacy-reference-tests.git#6133e7d9d9cd5f1b925cab1971b4d785dc639df7" + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-reference-tests.git#6133e7d9d9cd5f1b925cab1971b4d785dc639df7", + "license": "Apache-2.0" + }, + "node_modules/@ghostery/adblocker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker/-/adblocker-2.1.1.tgz", + "integrity": "sha512-FL4yWrpNTCmtbAfeLotUoo94ZyNqHdZpZRo4Qlk0guPzDGcOtW4/c84UzS9D/Z9Z4H3nWSCrW0q38pjwAbDykA==", + "license": "MPL-2.0", + "dependencies": { + "@ghostery/adblocker-content": "^2.1.1", + "@ghostery/adblocker-extended-selectors": "^2.1.1", + "@remusao/guess-url-type": "^1.3.0", + "@remusao/small": "^1.2.1", + "@remusao/smaz": "^1.9.1", + "tldts-experimental": "^6.0.14" + } + }, + "node_modules/@ghostery/adblocker-content": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker-content/-/adblocker-content-2.1.1.tgz", + "integrity": "sha512-1DKHmPnlQleXapaL36xZOwwZmpdbjMP/IcWdTTzyriyCDIFlSwBDT1DJ3xg0TK61ahZMEwz1MnTGM6X99z/5rQ==", + "license": "MPL-2.0", + "dependencies": { + "@ghostery/adblocker-extended-selectors": "^2.1.1" + } + }, + "node_modules/@ghostery/adblocker-extended-selectors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker-extended-selectors/-/adblocker-extended-selectors-2.1.1.tgz", + "integrity": "sha512-jEHjU2CarS2MtRYfm/6iTKMS1DVzepuwXSMKg1zTyHl+u4ZKvKNYFK7plD0nUlL5a8akyRkYwLheXnKsW3nChQ==", + "license": "MPL-2.0" }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -94,6 +135,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -103,6 +145,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -112,6 +155,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -121,23 +165,69 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@remusao/guess-url-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@remusao/guess-url-type/-/guess-url-type-1.3.0.tgz", + "integrity": "sha512-SNSJGxH5ckvxb3EUHj4DqlAm/bxNxNv2kx/AESZva/9VfcBokwKNS+C4D1lQdWIDM1R3d3UG+xmVzlkNG8CPTQ==", + "license": "MPL-2.0" + }, + "node_modules/@remusao/small": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@remusao/small/-/small-1.3.0.tgz", + "integrity": "sha512-bydAhJI+ywmg5xMUcbqoR8KahetcfkFywEZpsyFZ8EBofilvWxbXnMSe4vnjDI1Y+SWxnNhR4AL/2BAXkf4b8A==", + "license": "MPL-2.0" + }, + "node_modules/@remusao/smaz": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz/-/smaz-1.10.0.tgz", + "integrity": "sha512-GQzCxmmMpLkyZwcwNgz8TpuBEWl0RUQa8IcvKiYlPxuyYKqyqPkCr0hlHI15ckn3kDUPS68VmTVgyPnLNrdVmg==", + "license": "MPL-2.0", + "dependencies": { + "@remusao/smaz-compress": "^1.10.0", + "@remusao/smaz-decompress": "^1.10.0" + } + }, + "node_modules/@remusao/smaz-compress": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz-compress/-/smaz-compress-1.10.0.tgz", + "integrity": "sha512-E/lC8OSU+3bQrUl64vlLyPzIxo7dxF2RvNBe9KzcM4ax43J/d+YMinmMztHyCIHqRbz7rBCtkp3c0KfeIbHmEg==", + "license": "MPL-2.0", + "dependencies": { + "@remusao/trie": "^1.5.0" + } + }, + "node_modules/@remusao/smaz-decompress": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz-decompress/-/smaz-decompress-1.10.0.tgz", + "integrity": "sha512-aA5ImUH480Pcs5/cOgToKmFnzi7osSNG6ft+7DdmQTaQEEst3nLq3JLlBEk+gwidURymjbx6DYs60LHaZ415VQ==", + "license": "MPL-2.0" + }, + "node_modules/@remusao/trie": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@remusao/trie/-/trie-1.5.0.tgz", + "integrity": "sha512-UX+3utJKgwCsg6sUozjxd38gNMVRXrY4TNX9VvCdSrlZBS1nZjRPi98ON3QjRAdf6KCguJFyQARRsulTeqQiPg==", + "license": "MPL-2.0" + }, "node_modules/@rollup/plugin-json": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz", "integrity": "sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==", "dev": true, + "license": "MIT", "dependencies": { "@rollup/pluginutils": "^3.0.8" }, @@ -150,6 +240,7 @@ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz", "integrity": "sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==", "dev": true, + "license": "MIT", "dependencies": { "@rollup/pluginutils": "^3.1.0", "@types/resolve": "1.17.1", @@ -170,6 +261,7 @@ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "0.0.39", "estree-walker": "^1.0.1", @@ -186,15 +278,17 @@ "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", + "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.19.8" + "undici-types": "~6.20.0" } }, "node_modules/@types/resolve": { @@ -202,6 +296,7 @@ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -211,6 +306,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -222,13 +318,15 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -240,13 +338,15 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -255,7 +355,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fsevents": { "version": "2.3.3", @@ -263,6 +364,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -276,6 +378,7 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -285,6 +388,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -294,6 +398,7 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -301,11 +406,18 @@ "node": ">= 0.4" } }, + "node_modules/immutable-json-patch": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/immutable-json-patch/-/immutable-json-patch-6.0.1.tgz", + "integrity": "sha512-BHL/cXMjwFZlTOffiWNdY8ZTvNyYLrutCnWxrcKPHr5FqpAb6vsO6WWSPnVSys3+DruFN6lhHJJPHi8uELQL5g==", + "license": "ISC" + }, "node_modules/is-builtin-module": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", "dev": true, + "license": "MIT", "dependencies": { "builtin-modules": "^3.3.0" }, @@ -321,6 +433,7 @@ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dev": true, + "license": "MIT", "dependencies": { "hasown": "^2.0.2" }, @@ -335,13 +448,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-worker": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -355,31 +470,36 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -392,6 +512,7 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } @@ -401,6 +522,7 @@ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dev": true, + "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -418,6 +540,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -434,6 +557,7 @@ "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "jest-worker": "^26.2.1", @@ -462,13 +586,15 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/serialize-javascript": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } @@ -478,6 +604,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -487,6 +614,7 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -497,6 +625,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -509,6 +638,7 @@ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -521,6 +651,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -535,23 +666,26 @@ } }, "node_modules/tldts-core": { - "version": "6.1.60", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.60.tgz", - "integrity": "sha512-XHjoxak8SFQnHnmYHb3PcnW5TZ+9ErLZemZei3azuIRhQLw4IExsVbL3VZJdHcLeNaXq6NqawgpDPpjBOg4B5g==" + "version": "6.1.64", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.64.tgz", + "integrity": "sha512-uqnl8vGV16KsyflHOzqrYjjArjfXaU6rMPXYy2/ZWoRKCkXtghgB4VwTDXUG+t0OTGeSewNAG31/x1gCTfLt+Q==", + "license": "MIT" }, "node_modules/tldts-experimental": { - "version": "6.1.60", - "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.60.tgz", - "integrity": "sha512-EK0d4Gq524rR3aUn5qnqq+v8poLIhPMiBGcRd6E50JodIlAt5d1vRxHj+BQ4R+5tC/CnhbFg9ms3BYHlnBfnyw==", + "version": "6.1.64", + "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.64.tgz", + "integrity": "sha512-Lm6dwThCCmUecyvOJwTfZgYRP9JB6UDam//96OSvZffBtBA3GuwucIiycLT5yO5nz0ZAGV37FF1hef2HE0K8BQ==", + "license": "MIT", "dependencies": { - "tldts-core": "^6.1.60" + "tldts-core": "^6.1.64" } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 667a9c1c6709..f7d9e0c080a7 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "rollup-plugin-terser": "^7.0.2" }, "dependencies": { - "@duckduckgo/autoconsent": "^10.17.0", + "@duckduckgo/autoconsent": "^12.1.0", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#15.1.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.33.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.41.0", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#7.0.2", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1724449523" } diff --git a/privacy-config/privacy-config-impl/build.gradle b/privacy-config/privacy-config-impl/build.gradle index 5480d43aa921..f990e19163c5 100644 --- a/privacy-config/privacy-config-impl/build.gradle +++ b/privacy-config/privacy-config-impl/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation project(path: ':content-scope-scripts-api') implementation project(path: ':browser-api') implementation project(path: ':experiments-api') + implementation project(path: ':statistics-api') implementation AndroidX.appCompat implementation Google.android.material diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt index 614144f322e2..133100a3c17f 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt @@ -17,12 +17,16 @@ package com.duckduckgo.privacy.config.impl import androidx.annotation.WorkerThread +import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.utils.extensions.extractETag import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Error import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Success +import com.duckduckgo.privacy.config.impl.RealPrivacyConfigDownloader.DownloadError.DOWNLOAD_ERROR +import com.duckduckgo.privacy.config.impl.RealPrivacyConfigDownloader.DownloadError.EMPTY_CONFIG_ERROR +import com.duckduckgo.privacy.config.impl.RealPrivacyConfigDownloader.DownloadError.STORE_ERROR import com.duckduckgo.privacy.config.impl.network.PrivacyConfigService import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -38,7 +42,7 @@ interface PrivacyConfigDownloader { suspend fun download(): ConfigDownloadResult sealed class ConfigDownloadResult { - object Success : ConfigDownloadResult() + data object Success : ConfigDownloadResult() data class Error(val error: String?) : ConfigDownloadResult() } } @@ -49,10 +53,12 @@ class RealPrivacyConfigDownloader @Inject constructor( private val privacyConfigService: PrivacyConfigService, private val privacyConfigPersister: PrivacyConfigPersister, private val privacyConfigCallbacks: PluginPoint, + private val pixel: Pixel, ) : PrivacyConfigDownloader { override suspend fun download(): PrivacyConfigDownloader.ConfigDownloadResult { Timber.d("Downloading privacy config") + val response = runCatching { privacyConfigService.privacyConfig() }.onSuccess { response -> @@ -62,17 +68,36 @@ class RealPrivacyConfigDownloader @Inject constructor( privacyConfigPersister.persistPrivacyConfig(it, eTag) privacyConfigCallbacks.getPlugins().forEach { callback -> callback.onPrivacyConfigDownloaded() } }.onFailure { + // error parsing remote config + notifyErrorToCallbacks(STORE_ERROR) return Error(it.localizedMessage) } - } ?: return Error(null) + } ?: run { + // empty response + notifyErrorToCallbacks(EMPTY_CONFIG_ERROR) + return Error(null) + } }.onFailure { + // error downloading remote config Timber.w(it.localizedMessage) + notifyErrorToCallbacks(DOWNLOAD_ERROR) } return if (response.isFailure) { + // error downloading remote config Error(response.exceptionOrNull()?.localizedMessage) } else { Success } } + + private fun notifyErrorToCallbacks(reason: DownloadError) { + pixel.fire(reason.pixelName) + } + + private enum class DownloadError(val pixelName: String) { + DOWNLOAD_ERROR("m_privacy_config_download_error"), + STORE_ERROR("m_privacy_config_store_error"), + EMPTY_CONFIG_ERROR("m_privacy_config_empty_error"), + } } diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloaderTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloaderTest.kt index 64f292b06133..54cc12c6e90d 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloaderTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloaderTest.kt @@ -17,6 +17,7 @@ package com.duckduckgo.privacy.config.impl import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FeatureExceptions.FeatureException import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Error @@ -27,6 +28,7 @@ import com.duckduckgo.privacy.config.impl.models.JsonPrivacyConfig import com.duckduckgo.privacy.config.impl.network.PrivacyConfigService import kotlinx.coroutines.test.runTest import org.json.JSONObject +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -35,6 +37,7 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import retrofit2.Response @RunWith(AndroidJUnit4::class) @@ -45,36 +48,71 @@ class RealPrivacyConfigDownloaderTest { lateinit var testee: RealPrivacyConfigDownloader private val mockPrivacyConfigPersister: PrivacyConfigPersister = mock() - private val pluginPoint = FakeFakePrivacyConfigCallbackPluginPoint(listOf(FakePrivacyConfigCallbackPlugin())) + private val pixel: Pixel = mock() + private val oneCallback = FakePrivacyConfigCallbackPlugin() + private val anotherCallback = FakePrivacyConfigCallbackPlugin() + private val callbacks = listOf(oneCallback, anotherCallback) + private val pluginPoint = FakeFakePrivacyConfigCallbackPluginPoint(callbacks) @Before fun before() { - testee = RealPrivacyConfigDownloader(TestPrivacyConfigService(), mockPrivacyConfigPersister, pluginPoint) + testee = RealPrivacyConfigDownloader(TestPrivacyConfigService(), mockPrivacyConfigPersister, pluginPoint, pixel) } @Test - fun whenDownloadIsNotSuccessfulThenReturnFalse() = - runTest { - testee = - RealPrivacyConfigDownloader( - TestFailingPrivacyConfigService(), - mockPrivacyConfigPersister, - pluginPoint, - ) - assertTrue(testee.download() is Error) - } + fun whenDownloadIsNotSuccessfulThenReturnAndPixelError() = runTest { + testee = + RealPrivacyConfigDownloader( + TestFailingPrivacyConfigService(), + mockPrivacyConfigPersister, + pluginPoint, + pixel, + ) + assertTrue(testee.download() is Error) + verify(pixel).fire("m_privacy_config_download_error") + } @Test - fun whenDownloadIsSuccessfulThenReturnTrue() = - runTest { assertTrue(testee.download() is Success) } + fun whenDownloadIsEmptyThenReturnAndPixelError() = runTest { + testee = + RealPrivacyConfigDownloader( + TestEmptyPrivacyConfigService(), + mockPrivacyConfigPersister, + pluginPoint, + pixel, + ) + assertTrue(testee.download() is Error) + verify(pixel).fire("m_privacy_config_empty_error") + } @Test - fun whenDownloadIsSuccessfulThenPersistPrivacyConfigCalled() = - runTest { - testee.download() + fun whenDownloadIsSuccessfulThenReturnTrue() = runTest { + assertTrue(testee.download() is Success) + } - verify(mockPrivacyConfigPersister).persistPrivacyConfig(any(), any()) + @Test + fun whenDownloadIsSuccessfulThenCallCallback() = runTest { + testee.download() + + callbacks.forEach { + assertEquals(1, it.downloadCallCount) } + } + + @Test + fun whenDownloadIsSuccessfulThenPersistPrivacyConfigCalled() = runTest { + testee.download() + + verify(mockPrivacyConfigPersister).persistPrivacyConfig(any(), any()) + } + + @Test + fun whenDownloadStoreErrorThenFireStoreErrorPixel() = runTest { + whenever(mockPrivacyConfigPersister.persistPrivacyConfig(any(), any())).thenThrow() + + testee.download() + verify(pixel).fire("m_privacy_config_store_error") + } class TestFailingPrivacyConfigService : PrivacyConfigService { override suspend fun privacyConfig(): Response { @@ -82,6 +120,12 @@ class RealPrivacyConfigDownloaderTest { } } + class TestEmptyPrivacyConfigService : PrivacyConfigService { + override suspend fun privacyConfig(): Response { + return Response.success(null) + } + } + class TestPrivacyConfigService : PrivacyConfigService { override suspend fun privacyConfig(): Response { return Response.success( diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt index f523c8a5484d..8b7cde4f2cb3 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt @@ -310,8 +310,12 @@ class RealPrivacyConfigPersisterTest { } } - class FakePrivacyConfigCallbackPlugin : PrivacyConfigCallbackPlugin { - override fun onPrivacyConfigDownloaded() {} + internal class FakePrivacyConfigCallbackPlugin : PrivacyConfigCallbackPlugin { + internal var downloadCallCount = 0 + + override fun onPrivacyConfigDownloaded() { + downloadCallCount++ + } } companion object { diff --git a/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardHybridScreenParams.kt b/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardHybridScreenParams.kt index dbfb8b105167..f559d2ba9341 100644 --- a/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardHybridScreenParams.kt +++ b/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardHybridScreenParams.kt @@ -32,5 +32,10 @@ sealed class PrivacyDashboardHybridScreenParams : GlobalActivityStarter.Activity * Use this parameter to launch the site breakage reporting form. * @param tabId The tab ID */ - data class BrokenSiteForm(override val tabId: String) : PrivacyDashboardHybridScreenParams() + data class BrokenSiteForm(override val tabId: String, val reportFlow: BrokenSiteFormReportFlow) : PrivacyDashboardHybridScreenParams() { + enum class BrokenSiteFormReportFlow { + MENU, + PROMPT, + } + } } diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridActivity.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridActivity.kt index 966abd1e39d4..f781328f44a1 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridActivity.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridActivity.kt @@ -94,9 +94,14 @@ class PrivacyDashboardHybridActivity : DuckDuckGoActivity() { onBrokenSiteClicked = { viewModel.onReportBrokenSiteSelected() }, onClose = { this@PrivacyDashboardHybridActivity.finish() }, onSubmitBrokenSiteReport = { payload -> - val reportFlow = when (params) { + val reportFlow = when (val params = params) { is PrivacyDashboardPrimaryScreen, null -> ReportFlow.DASHBOARD - is BrokenSiteForm -> ReportFlow.MENU + is BrokenSiteForm -> { + when (params.reportFlow) { + BrokenSiteForm.BrokenSiteFormReportFlow.MENU -> ReportFlow.MENU + BrokenSiteForm.BrokenSiteFormReportFlow.PROMPT -> ReportFlow.PROMPT + } + } } viewModel.onSubmitBrokenSiteReport(payload, reportFlow) }, diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFeature.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFeature.kt index 65afb6bf016b..371d191624f0 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFeature.kt +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFeature.kt @@ -20,7 +20,6 @@ import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue -import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled @ContributesRemoteFeature( scope = AppScope::class, @@ -28,7 +27,6 @@ import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled ) interface PrivacyProtectionsPopupFeature { @DefaultValue(false) - @InternalAlwaysEnabled fun self(): Toggle } diff --git a/remote-messaging/remote-messaging-api/src/main/java/com/duckduckgo/remote/messaging/api/RemoteMessagingRepository.kt b/remote-messaging/remote-messaging-api/src/main/java/com/duckduckgo/remote/messaging/api/RemoteMessagingRepository.kt index b8ad5a24c939..750fb05d02dc 100644 --- a/remote-messaging/remote-messaging-api/src/main/java/com/duckduckgo/remote/messaging/api/RemoteMessagingRepository.kt +++ b/remote-messaging/remote-messaging-api/src/main/java/com/duckduckgo/remote/messaging/api/RemoteMessagingRepository.kt @@ -19,6 +19,7 @@ package com.duckduckgo.remote.messaging.api import kotlinx.coroutines.flow.Flow interface RemoteMessagingRepository { + fun getMessageById(id: String): RemoteMessage? fun activeMessage(message: RemoteMessage?) fun message(): RemoteMessage? fun messageFlow(): Flow diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepository.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepository.kt index 3b1bad6b8a4c..17691f103204 100644 --- a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepository.kt +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepository.kt @@ -37,6 +37,12 @@ class AppRemoteMessagingRepository( private val messageMapper: MessageMapper, ) : RemoteMessagingRepository { + override fun getMessageById(id: String): RemoteMessage? { + return remoteMessagesDao.messagesById(id)?.let { + messageMapper.fromMessage(it.message) + } + } + override fun activeMessage(message: RemoteMessage?) { if (message == null) { remoteMessagesDao.updateActiveMessageStateAndDeleteNeverShownMessages() diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/matchers/ShownMessageMatcher.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/matchers/ShownMessageMatcher.kt new file mode 100644 index 000000000000..06defeaef4c8 --- /dev/null +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/matchers/ShownMessageMatcher.kt @@ -0,0 +1,80 @@ +/* + * 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.remote.messaging.impl.matchers + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.remote.messaging.api.AttributeMatcherPlugin +import com.duckduckgo.remote.messaging.api.JsonMatchingAttribute +import com.duckduckgo.remote.messaging.api.JsonToMatchingAttributeMapper +import com.duckduckgo.remote.messaging.api.MatchingAttribute +import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +@ContributesMultibinding( + scope = AppScope::class, + boundType = JsonToMatchingAttributeMapper::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = AttributeMatcherPlugin::class, +) +@SingleInstanceIn(AppScope::class) +class ShownMessageMatcher @Inject constructor( + private val remoteMessagingRepository: RemoteMessagingRepository, +) : JsonToMatchingAttributeMapper, AttributeMatcherPlugin { + override fun map( + key: String, + jsonMatchingAttribute: JsonMatchingAttribute, + ): MatchingAttribute? { + return when (key) { + "messageShown" -> { + val value = jsonMatchingAttribute.value + if (value is List<*>) { + val messageIds = value.filterIsInstance() + if (messageIds.isNotEmpty()) { + return ShownMessageMatchingAttribute(messageIds) + } + } + + return null + } + + else -> null + } + } + + override suspend fun evaluate(matchingAttribute: MatchingAttribute): Boolean? { + if (matchingAttribute is ShownMessageMatchingAttribute) { + assert(matchingAttribute.messageIds.isNotEmpty()) + + val currentMessage = remoteMessagingRepository.message() + matchingAttribute.messageIds.forEach { + if (currentMessage?.id != it) { + if (remoteMessagingRepository.didShow(it)) return true + } + } + return false + } + return null + } +} + +data class ShownMessageMatchingAttribute( + val messageIds: List, +) : MatchingAttribute diff --git a/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/matchers/ShownMessageMatcherTest.kt b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/matchers/ShownMessageMatcherTest.kt new file mode 100644 index 000000000000..67e18753cef9 --- /dev/null +++ b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/matchers/ShownMessageMatcherTest.kt @@ -0,0 +1,133 @@ +package com.duckduckgo.remote.messaging.impl.matchers + +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.remote.messaging.api.JsonMatchingAttribute +import com.duckduckgo.remote.messaging.api.RemoteMessage +import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository +import com.duckduckgo.remote.messaging.impl.AppRemoteMessagingRepositoryTest.Companion.aRemoteMessage +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ShownMessageMatcherTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val remoteMessagingRepository: RemoteMessagingRepository = mock() + + @Test + fun whenMapKeyIsMessageShownThenReturnMatchingAttribute() { + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val jsonMatchingAttribute = JsonMatchingAttribute(value = listOf("1", "2", "3")) + val result = matcher.map("messageShown", jsonMatchingAttribute) + assertTrue(result is ShownMessageMatchingAttribute) + assertEquals(listOf("1", "2", "3"), (result as ShownMessageMatchingAttribute).messageIds) + } + + @Test + fun whenJsonMatchingAttributeValueIsWrongTypeThenReturnNull() = runTest { + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val jsonMatchingAttribute = JsonMatchingAttribute(value = listOf(1, true, 23L)) + val result = matcher.map("messageShown", jsonMatchingAttribute) + assertNull(result) + } + + @Test + fun whenJsonMatchingAttributeValueContainsWrongTypesThenReturnOnlyStringOnes() = runTest { + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val jsonMatchingAttribute = JsonMatchingAttribute(value = listOf("1", true, 23L)) + val result = matcher.map("messageShown", jsonMatchingAttribute) + assertEquals(listOf("1"), (result as ShownMessageMatchingAttribute).messageIds) + } + + @Test + fun whenJsonMatchingAttributeValueIsNullThenReturnNull() = runTest { + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val jsonMatchingAttribute = JsonMatchingAttribute(value = null) + val result = matcher.map("messageShown", jsonMatchingAttribute) + assertNull(result) + } + + @Test + fun whenJsonMatchingAttributeValueIsEmptyThenReturnNull() = runTest { + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val jsonMatchingAttribute = JsonMatchingAttribute(value = emptyList()) + val result = matcher.map("messageShown", jsonMatchingAttribute) + assertNull(result) + } + + @Test + fun whenJsonMatchingAttributeValueIsNotListThenReturnNull() = runTest { + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val jsonMatchingAttribute = JsonMatchingAttribute(value = 1) + val result = matcher.map("messageShown", jsonMatchingAttribute) + assertNull(result) + } + + @Test + fun whenShownMessageIdMatchesThenReturnTrue() = runTest { + givenMessageIdShown(listOf("1", "2", "3")) + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val matchingAttribute = ShownMessageMatchingAttribute(listOf("1", "2", "3")) + val result = matcher.evaluate(matchingAttribute)!! + assertTrue(result) + } + + @Test + fun whenOneShownMessageMatchesThenReturnTrue() = runTest { + givenMessageIdShown(listOf("1", "2", "3")) + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val matchingAttribute = ShownMessageMatchingAttribute(listOf("0", "1", "4")) + val result = matcher.evaluate(matchingAttribute)!! + assertTrue(result) + } + + @Test + fun whenNoShownMessageMatchesThenReturnFalse() = runTest { + givenMessageIdShown(listOf("1", "2", "3")) + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val matchingAttribute = ShownMessageMatchingAttribute(listOf("0", "4", "5")) + val result = matcher.evaluate(matchingAttribute)!! + assertFalse(result) + } + + @Test + fun whenOnlyCurrentMessageIdMatchesThenReturnFalse() = runTest { + givenCurrentActiveMessage(aRemoteMessage("1")) + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val matchingAttribute = ShownMessageMatchingAttribute(listOf("1")) + val result = matcher.evaluate(matchingAttribute)!! + assertFalse(result) + } + + @Test + fun whenCurrentMessageAndOtherIdsMatchThenReturnTrue() = runTest { + givenMessageIdShown(listOf("2", "3")) + givenCurrentActiveMessage(aRemoteMessage("1")) + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val matchingAttribute = ShownMessageMatchingAttribute(listOf("1", "2", "3")) + val result = matcher.evaluate(matchingAttribute)!! + assertTrue(result) + } + + @Test(expected = AssertionError::class) + fun whenEmptyListThenThrowAssertionError() = runTest { + givenMessageIdShown(listOf("1", "2", "3")) + val matcher = ShownMessageMatcher(remoteMessagingRepository) + val matchingAttribute = ShownMessageMatchingAttribute(emptyList()) + matcher.evaluate(matchingAttribute) + } + + private fun givenCurrentActiveMessage(message: RemoteMessage) { + whenever(remoteMessagingRepository.message()).thenReturn(message) + } + + private fun givenMessageIdShown(listOf: List) { + listOf.forEach { + whenever(remoteMessagingRepository.didShow(it)).thenReturn(true) + } + } +} diff --git a/saved-sites/saved-sites-impl/src/main/res/values-it/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-it/strings-saved-sites.xml index 0ad827fba81c..1f9b66ee4709 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-it/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-it/strings-saved-sites.xml @@ -106,5 +106,5 @@ Eliminare questa cartella dei segnalibri? Questa operazione eliminerà la tua cartella dei segnalibri <b>%1$s</b>. Annulla - Cancella + Elimina \ No newline at end of file diff --git a/settings/settings-api/build.gradle b/settings/settings-api/build.gradle index 806b9cdd3d98..65619c220529 100644 --- a/settings/settings-api/build.gradle +++ b/settings/settings-api/build.gradle @@ -22,6 +22,9 @@ plugins { apply from: "$rootProject.projectDir/gradle/android-library.gradle" dependencies { + /* Temporary while developing new settings screen */ + implementation project(':feature-toggles-api') + implementation project(':navigation-api') implementation Google.dagger implementation AndroidX.core.ktx diff --git a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/NewSettingsFeature.kt b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/NewSettingsFeature.kt new file mode 100644 index 000000000000..31a7694db581 --- /dev/null +++ b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/NewSettingsFeature.kt @@ -0,0 +1,25 @@ +/* + * 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.settings.api + +import com.duckduckgo.feature.toggles.api.Toggle + +interface NewSettingsFeature { + + @Toggle.DefaultValue(false) + fun self(): Toggle +} diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/NewSettingsTrigger.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/NewSettingsTrigger.kt new file mode 100644 index 000000000000..86f4cd479db0 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/NewSettingsTrigger.kt @@ -0,0 +1,28 @@ +/* + * 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.settings.impl + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.settings.api.NewSettingsFeature + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "newSettings", + boundType = NewSettingsFeature::class, +) +private interface NewSettingsCodeGenTrigger diff --git a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index 8de07d743664..38d710cfab8e 100644 --- a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -61,6 +61,11 @@ interface Pixel { const val FROM_ONBOARDING = "from_onboarding" const val ADDRESS_BAR = "address_bar" const val LAUNCH_SCREEN = "launch_screen" + const val TAB_COUNT = "tab_count" + const val TAB_ACTIVE_7D = "tab_active_7d" + const val TAB_INACTIVE_1W = "tab_inactive_1w" + const val TAB_INACTIVE_2W = "tab_inactive_2w" + const val TAB_INACTIVE_3W = "tab_inactive_3w" } object PixelValues { diff --git a/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt index 9b13ea9144a3..b6675f38ca5f 100644 --- a/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt +++ b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt @@ -22,6 +22,15 @@ import kotlinx.coroutines.flow.Flow interface Subscriptions { + /** + * Checks if the user is currently signed in. + * + * Note: A signed-in user does not necessarily have an active subscription. + * + * @return `true` if the user is signed in; `false` otherwise + */ + suspend fun isSignedIn(): Boolean + /** * This method returns a [String] with the access token for the authenticated user or [null] if it doesn't exist * or any errors arise. @@ -64,6 +73,7 @@ interface Subscriptions { enum class Product(val value: String) { NetP("Network Protection"), ITR("Identity Theft Restoration"), + ROW_ITR("Global Identity Theft Restoration"), PIR("Data Broker Protection"), } diff --git a/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt b/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt index ce28bcf8b04a..267d2ca5835c 100644 --- a/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt +++ b/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt @@ -30,6 +30,8 @@ import kotlinx.coroutines.flow.flowOf @ContributesBinding(AppScope::class) class SubscriptionsDummy @Inject constructor() : Subscriptions { + override suspend fun isSignedIn(): Boolean = false + override suspend fun getAccessToken(): String? = null override fun getEntitlementStatus(): Flow> = flowOf(emptyList()) diff --git a/subscriptions/subscriptions-impl/build.gradle b/subscriptions/subscriptions-impl/build.gradle index a4f27febe3b8..ea120b157bc6 100644 --- a/subscriptions/subscriptions-impl/build.gradle +++ b/subscriptions/subscriptions-impl/build.gradle @@ -78,6 +78,12 @@ dependencies { implementation Square.okHttp3.okHttp implementation AndroidX.security.crypto + implementation "io.jsonwebtoken:jjwt-api:_" + runtimeOnly "io.jsonwebtoken:jjwt-impl:_" + runtimeOnly("io.jsonwebtoken:jjwt-orgjson:_") { + exclude(group: 'org.json', module: 'json') // provided by Android natively + } + // Testing dependencies testImplementation project(':feature-toggles-test') testImplementation project(path: ':common-test') diff --git a/subscriptions/subscriptions-impl/src/main/AndroidManifest.xml b/subscriptions/subscriptions-impl/src/main/AndroidManifest.xml index dfa9604cc912..ac8b1411beca 100644 --- a/subscriptions/subscriptions-impl/src/main/AndroidManifest.xml +++ b/subscriptions/subscriptions-impl/src/main/AndroidManifest.xml @@ -20,20 +20,20 @@ @@ -47,13 +47,13 @@ diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt index 90a71c43c099..3a45d29c08ab 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt @@ -59,6 +59,9 @@ class RealSubscriptions @Inject constructor( private val globalActivityStarter: GlobalActivityStarter, private val pixel: SubscriptionPixelSender, ) : Subscriptions { + override suspend fun isSignedIn(): Boolean = + subscriptionsManager.isSignedIn() + override suspend fun getAccessToken(): String? { return when (val result = subscriptionsManager.getAccessToken()) { is AccessTokenResult.Success -> result.accessToken @@ -137,8 +140,18 @@ interface PrivacyProFeature { @Toggle.DefaultValue(true) fun allowEmailFeedback(): Toggle - @Toggle.DefaultValue(true) + @Toggle.DefaultValue(false) fun serpPromoCookie(): Toggle + + @Toggle.DefaultValue(false) + fun authApiV2(): Toggle + + @Toggle.DefaultValue(false) + fun isLaunchedROW(): Toggle + + // Kill switch + @Toggle.DefaultValue(true) + fun featuresApi(): Toggle } @ContributesBinding(AppScope::class) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcher.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcher.kt new file mode 100644 index 000000000000..5a09af4e0685 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcher.kt @@ -0,0 +1,81 @@ +/* + * 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.subscriptions.impl + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION +import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager +import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import com.duckduckgo.subscriptions.impl.services.SubscriptionsService +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.logcat + +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +class SubscriptionFeaturesFetcher @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val playBillingManager: PlayBillingManager, + private val subscriptionsService: SubscriptionsService, + private val authRepository: AuthRepository, + private val privacyProFeature: PrivacyProFeature, + private val dispatcherProvider: DispatcherProvider, +) : MainProcessLifecycleObserver { + + override fun onCreate(owner: LifecycleOwner) { + super.onCreate(owner) + appCoroutineScope.launch { + try { + if (isFeaturesApiEnabled()) { + fetchSubscriptionFeatures() + } + } catch (e: Exception) { + logcat { "Failed to fetch subscription features" } + } + } + } + + private suspend fun isFeaturesApiEnabled(): Boolean = withContext(dispatcherProvider.io()) { + privacyProFeature.featuresApi().isEnabled() + } + + private suspend fun fetchSubscriptionFeatures() { + playBillingManager.productsFlow + .first { it.isNotEmpty() } + .find { it.productId == BASIC_SUBSCRIPTION } + ?.subscriptionOfferDetails + ?.map { it.basePlanId } + ?.filter { authRepository.getFeatures(it).isEmpty() } + ?.forEach { basePlanId -> + val features = subscriptionsService.features(basePlanId).features + logcat { "Subscription features for base plan $basePlanId fetched: $features" } + if (features.isNotEmpty()) { + authRepository.setFeatures(basePlanId, features.toSet()) + } + } + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt index bacbfe7c038c..61b5c52eebdc 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt @@ -23,6 +23,7 @@ import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters @@ -35,8 +36,8 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.impl.RealSubscriptionsChecker.Companion.TAG_WORKER_SUBSCRIPTION_CHECK import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding -import java.util.concurrent.TimeUnit.HOURS -import java.util.concurrent.TimeUnit.MINUTES +import java.time.Duration +import java.time.Instant import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -66,21 +67,13 @@ class RealSubscriptionsChecker @Inject constructor( } override suspend fun runChecker() { - if (subscriptionsManager.subscriptionStatus() != UNKNOWN) { - PeriodicWorkRequestBuilder(1, HOURS) - .addTag(TAG_WORKER_SUBSCRIPTION_CHECK) - .setConstraints( - Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), - ) - .setBackoffCriteria(BackoffPolicy.LINEAR, 10, MINUTES) - .build().run { - workManager.enqueueUniquePeriodicWork( - TAG_WORKER_SUBSCRIPTION_CHECK, - ExistingPeriodicWorkPolicy.REPLACE, - this, - ) - } - } + if (!subscriptionsManager.isSignedIn()) return + + workManager.enqueueUniquePeriodicWork( + TAG_WORKER_SUBSCRIPTION_CHECK, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + buildSubscriptionCheckPeriodicWorkRequest(), + ) } companion object { @@ -101,14 +94,31 @@ class SubscriptionsCheckWorker( override suspend fun doWork(): Result { return try { - if (subscriptionsManager.subscriptionStatus() != UNKNOWN) { - subscriptionsManager.fetchAndStoreAllData() - val subscription = subscriptionsManager.getSubscription() - if (subscription?.status == null || subscription.status == UNKNOWN) { - workManager.cancelAllWorkByTag(TAG_WORKER_SUBSCRIPTION_CHECK) + if (subscriptionsManager.isSignedIn()) { + if (subscriptionsManager.isSignedInV2()) { + subscriptionsManager.refreshSubscriptionData() + val subscription = subscriptionsManager.getSubscription() + if (subscription?.isActive() == true) { + // No need to refresh active subscription in the background. Delay next refresh to the expiry/renewal time. + // It will still get refreshed when the app goes to the foreground. + val expiresOrRenewsAt = Instant.ofEpochMilli(subscription.expiresOrRenewsAt) + if (expiresOrRenewsAt > Instant.now()) { + workManager.enqueueUniquePeriodicWork( + TAG_WORKER_SUBSCRIPTION_CHECK, + ExistingPeriodicWorkPolicy.UPDATE, + buildSubscriptionCheckPeriodicWorkRequest(nextScheduleTimeOverride = expiresOrRenewsAt), + ) + } + } + } else { + subscriptionsManager.fetchAndStoreAllData() + val subscription = subscriptionsManager.getSubscription() + if (subscription?.status == null || subscription.status == UNKNOWN) { + workManager.cancelUniqueWork(TAG_WORKER_SUBSCRIPTION_CHECK) + } } } else { - workManager.cancelAllWorkByTag(TAG_WORKER_SUBSCRIPTION_CHECK) + workManager.cancelUniqueWork(TAG_WORKER_SUBSCRIPTION_CHECK) } Result.success() } catch (e: Exception) { @@ -116,3 +126,16 @@ class SubscriptionsCheckWorker( } } } + +private fun buildSubscriptionCheckPeriodicWorkRequest(nextScheduleTimeOverride: Instant? = null): PeriodicWorkRequest = + PeriodicWorkRequestBuilder(Duration.ofHours(1)) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setBackoffCriteria(BackoffPolicy.LINEAR, Duration.ofMinutes(10)) + .apply { + if (nextScheduleTimeOverride != null) { + setNextScheduleTimeOverride(nextScheduleTimeOverride.toEpochMilli()) + } + } + .build() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt index 3ee363b41de4..b2e17927cbc9 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt @@ -16,6 +16,11 @@ package com.duckduckgo.subscriptions.impl +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US + object SubscriptionsConstants { // List of subscriptions @@ -23,13 +28,20 @@ object SubscriptionsConstants { val LIST_OF_PRODUCTS = listOf(BASIC_SUBSCRIPTION) // List of plans - const val YEARLY_PLAN = "ddg-privacy-pro-yearly-renews-us" - const val MONTHLY_PLAN = "ddg-privacy-pro-monthly-renews-us" + const val YEARLY_PLAN_US = "ddg-privacy-pro-yearly-renews-us" + const val MONTHLY_PLAN_US = "ddg-privacy-pro-monthly-renews-us" + const val YEARLY_PLAN_ROW = "ddg-privacy-pro-yearly-renews-row" + const val MONTHLY_PLAN_ROW = "ddg-privacy-pro-monthly-renews-row" // List of features - const val NETP = "vpn" - const val ITR = "identity-theft-restoration" - const val PIR = "personal-information-removal" + const val LEGACY_FE_NETP = "vpn" + const val LEGACY_FE_ITR = "identity-theft-restoration" + const val LEGACY_FE_PIR = "personal-information-removal" + + const val NETP = "Network Protection" + const val ITR = "Identity Theft Restoration" + const val ROW_ITR = "Global Identity Theft Restoration" + const val PIR = "Data Broker Protection" // Platform const val PLATFORM = "android" @@ -48,11 +60,9 @@ object SubscriptionsConstants { } internal fun String.productIdToBillingPeriod(): String? { - return if (this == SubscriptionsConstants.MONTHLY_PLAN) { - "monthly" - } else if (this == SubscriptionsConstants.YEARLY_PLAN) { - "annual" - } else { - null + return when (this) { + MONTHLY_PLAN_US, MONTHLY_PLAN_ROW -> "monthly" + YEARLY_PLAN_US, YEARLY_PLAN_ROW -> "annual" + else -> null } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index e4ac73f041e5..d60b85111d90 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -20,6 +20,7 @@ import android.app.Activity import android.content.Context import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.subscriptions.api.Product @@ -33,17 +34,32 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_ITR +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_NETP +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_PIR +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.NETP +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ROW_ITR +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US +import com.duckduckgo.subscriptions.impl.auth2.AccessTokenClaims +import com.duckduckgo.subscriptions.impl.auth2.AuthClient +import com.duckduckgo.subscriptions.impl.auth2.AuthJwtValidator +import com.duckduckgo.subscriptions.impl.auth2.BackgroundTokenRefresh +import com.duckduckgo.subscriptions.impl.auth2.PkceGenerator +import com.duckduckgo.subscriptions.impl.auth2.RefreshTokenClaims +import com.duckduckgo.subscriptions.impl.auth2.TokenPair import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState import com.duckduckgo.subscriptions.impl.billing.RetryPolicy import com.duckduckgo.subscriptions.impl.billing.retry import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender +import com.duckduckgo.subscriptions.impl.repository.AccessToken import com.duckduckgo.subscriptions.impl.repository.Account import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import com.duckduckgo.subscriptions.impl.repository.RefreshToken import com.duckduckgo.subscriptions.impl.repository.Subscription -import com.duckduckgo.subscriptions.impl.repository.isActive import com.duckduckgo.subscriptions.impl.repository.isActiveOrWaiting import com.duckduckgo.subscriptions.impl.repository.isExpired import com.duckduckgo.subscriptions.impl.repository.toProductList @@ -58,7 +74,10 @@ import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonEncodingException import com.squareup.moshi.Moshi +import dagger.Lazy import dagger.SingleInstanceIn +import java.time.Duration +import java.time.Instant import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope @@ -71,6 +90,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority import logcat.logcat import retrofit2.HttpException @@ -100,6 +120,7 @@ interface SubscriptionsManager { * * @return [true] if successful, [false] otherwise */ + @Deprecated("This method will be removed after migrating to auth v2") suspend fun fetchAndStoreAllData(): Boolean /** @@ -107,6 +128,17 @@ interface SubscriptionsManager { */ suspend fun getSubscription(): Subscription? + /** + * Fetches subscription information from BE and saves it in internal storage + */ + suspend fun refreshSubscriptionData() + + /** + * Gets new access token from BE and saves it in internal storage. + * This operation also updates account email and entitlements. + */ + suspend fun refreshAccessToken() + /** * Gets the account details from internal storage */ @@ -115,11 +147,13 @@ interface SubscriptionsManager { /** * Exchanges the auth token for an access token and stores both tokens */ + @Deprecated("This method will be removed after migrating to auth v2") suspend fun exchangeAuthToken(authToken: String): String /** * Returns the auth token and if expired, tries to refresh irt */ + @Deprecated("This method will be removed after migrating to auth v2") suspend fun getAuthToken(): AuthTokenResult /** @@ -132,6 +166,16 @@ interface SubscriptionsManager { */ suspend fun subscriptionStatus(): SubscriptionStatus + /** + * Checks if user is signed in or not (using either auth API v1 or v2) + */ + suspend fun isSignedIn(): Boolean + + /** + * Checks if user is signed in or not using auth API v2 + */ + suspend fun isSignedInV2(): Boolean + /** * Flow to know if a user is signed in or not */ @@ -177,6 +221,12 @@ class RealSubscriptionsManager @Inject constructor( @AppCoroutineScope private val coroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, private val pixelSender: SubscriptionPixelSender, + private val privacyProFeature: Lazy, + private val authClient: AuthClient, + private val authJwtValidator: AuthJwtValidator, + private val pkceGenerator: PkceGenerator, + private val timeProvider: CurrentTimeProvider, + private val backgroundTokenRefresh: BackgroundTokenRefresh, ) : SubscriptionsManager { private val adapter = Moshi.Builder().build().adapter(ResponseError::class.java) @@ -205,10 +255,22 @@ class RealSubscriptionsManager @Inject constructor( private var removeExpiredSubscriptionOnCancelledPurchase: Boolean = false - private suspend fun isSignedIn(): Boolean { + override suspend fun isSignedIn(): Boolean { + return isSignedInV1() || isSignedInV2() + } + + private suspend fun isSignedInV1(): Boolean { return !authRepository.getAuthToken().isNullOrBlank() && !authRepository.getAccessToken().isNullOrBlank() } + override suspend fun isSignedInV2(): Boolean { + return authRepository.getRefreshTokenV2() != null + } + + private suspend fun shouldUseAuthV2(): Boolean = withContext(dispatcherProvider.io()) { + privacyProFeature.get().authApiV2().isEnabled() || isSignedInV2() + } + private fun emitEntitlementsValues() { coroutineScope.launch(dispatcherProvider.io()) { val entitlements = if (authRepository.getSubscription()?.status?.isActiveOrWaiting() == true) { @@ -272,10 +334,16 @@ class RealSubscriptionsManager @Inject constructor( } override suspend fun signOut() { + authRepository.getAccessTokenV2()?.run { + coroutineScope.launch { authClient.tryLogout(accessTokenV2 = jwt) } + } + authRepository.setAccessTokenV2(null) + authRepository.setRefreshTokenV2(null) authRepository.setAuthToken(null) authRepository.setAccessToken(null) authRepository.setAccount(null) authRepository.setSubscription(null) + authRepository.setEntitlements(emptyList()) _isSignedIn.emit(false) _subscriptionStatus.emit(UNKNOWN) _entitlements.emit(emptyList()) @@ -317,38 +385,42 @@ class RealSubscriptionsManager @Inject constructor( purchaseToken: String, ): Boolean { return try { - subscriptionsService.confirm( + val confirmationResponse = subscriptionsService.confirm( ConfirmationBody( packageName = packageName, purchaseToken = purchaseToken, ), - ).also { confirmationResponse -> + ) + + val subscription = Subscription( + productId = confirmationResponse.subscription.productId, + startedAt = confirmationResponse.subscription.startedAt, + expiresOrRenewsAt = confirmationResponse.subscription.expiresOrRenewsAt, + status = confirmationResponse.subscription.status.toStatus(), + platform = confirmationResponse.subscription.platform, + ) + + authRepository.setSubscription(subscription) + + if (shouldUseAuthV2()) { + // existing access token has to be invalidated after the purchase, because it doesn't have up-to-date entitlements + authRepository.setAccessTokenV2(null) + refreshAccessToken() + } else { authRepository.getAccount() ?.copy(email = confirmationResponse.email) ?.let { authRepository.setAccount(it) } - val subscriptionStatus = confirmationResponse.subscription.status.toStatus() - - authRepository.setSubscription( - Subscription( - productId = confirmationResponse.subscription.productId, - startedAt = confirmationResponse.subscription.startedAt, - expiresOrRenewsAt = confirmationResponse.subscription.expiresOrRenewsAt, - status = subscriptionStatus, - platform = confirmationResponse.subscription.platform, - ), - ) - authRepository.setEntitlements(confirmationResponse.entitlements.toEntitlements()) + } - if (subscriptionStatus.isActive()) { - pixelSender.reportPurchaseSuccess() - pixelSender.reportSubscriptionActivated() - emitEntitlementsValues() - _currentPurchaseState.emit(CurrentPurchase.Success) - } else { - handlePurchaseFailed() - } + if (subscription.isActive()) { + pixelSender.reportPurchaseSuccess() + pixelSender.reportSubscriptionActivated() + emitEntitlementsValues() + _currentPurchaseState.emit(CurrentPurchase.Success) + } else { + handlePurchaseFailed() } _subscriptionStatus.emit(authRepository.getStatus()) @@ -374,6 +446,7 @@ class RealSubscriptionsManager @Inject constructor( } } + @Deprecated("This method will be removed after migrating to auth v2") override suspend fun exchangeAuthToken(authToken: String): String { val accessToken = authService.accessToken("Bearer $authToken").accessToken authRepository.setAccessToken(accessToken) @@ -381,9 +454,11 @@ class RealSubscriptionsManager @Inject constructor( return accessToken } + @Deprecated("This method will be removed after migrating to auth v2") override suspend fun fetchAndStoreAllData(): Boolean { try { - if (!isSignedIn()) return false + if (!isSignedInV1()) return false + val subscription = try { subscriptionsService.subscription() } catch (e: HttpException) { @@ -422,6 +497,72 @@ class RealSubscriptionsManager @Inject constructor( } } + override suspend fun refreshAccessToken() { + val refreshToken = checkNotNull(authRepository.getRefreshTokenV2()) + + val newTokens = try { + val tokens = authClient.getTokens(refreshToken.jwt) + validateTokens(tokens) + } catch (e: HttpException) { + if (e.code() == 401) { + // refresh token is invalid / expired -> try to get a new pair of tokens using store login + val account = checkNotNull(authRepository.getAccount()) { "Missing account info when refreshing access token" } + + when (val storeLoginResult = storeLogin(account.externalId)) { + is StoreLoginResult.Success -> storeLoginResult.tokens + StoreLoginResult.Failure.AccountExternalIdMismatch, + StoreLoginResult.Failure.PurchaseHistoryNotAvailable, + StoreLoginResult.Failure.AuthenticationError, + -> { + signOut() + throw e + } + + StoreLoginResult.Failure.Other -> throw e + } + } else { + throw e + } + } + + saveTokens(newTokens) + } + + override suspend fun refreshSubscriptionData() { + val subscription = subscriptionsService.subscription() + + authRepository.setSubscription( + Subscription( + productId = subscription.productId, + startedAt = subscription.startedAt, + expiresOrRenewsAt = subscription.expiresOrRenewsAt, + status = subscription.status.toStatus(), + platform = subscription.platform, + ), + ) + + _subscriptionStatus.emit(subscription.status.toStatus()) + } + + private suspend fun validateTokens(tokens: TokenPair): ValidatedTokenPair { + val jwks = authClient.getJwks() + + return ValidatedTokenPair( + accessToken = tokens.accessToken, + accessTokenClaims = authJwtValidator.validateAccessToken(tokens.accessToken, jwks), + refreshToken = tokens.refreshToken, + refreshTokenClaims = authJwtValidator.validateRefreshToken(tokens.refreshToken, jwks), + ) + } + + private suspend fun saveTokens(tokens: ValidatedTokenPair) = with(tokens) { + authRepository.setAccessTokenV2(AccessToken(accessToken, accessTokenClaims.expiresAt)) + authRepository.setRefreshTokenV2(RefreshToken(refreshToken, refreshTokenClaims.expiresAt)) + authRepository.setEntitlements(accessTokenClaims.entitlements) + authRepository.setAccount(Account(email = accessTokenClaims.email, externalId = accessTokenClaims.accountExternalId)) + backgroundTokenRefresh.schedule() + } + private fun extractError(e: Exception): String { return if (e is HttpException) { parseError(e)?.error ?: "An error happened" @@ -430,31 +571,76 @@ class RealSubscriptionsManager @Inject constructor( } } - override suspend fun recoverSubscriptionFromStore(externalId: String?): RecoverSubscriptionResult { + private suspend fun storeLogin(accountExternalId: String? = null): StoreLoginResult { return try { val purchase = playBillingManager.purchaseHistory.lastOrNull() - if (purchase != null) { - val signature = purchase.signature - val body = purchase.originalJson - val storeLoginBody = StoreLoginBody(signature = signature, signedData = body, packageName = context.packageName) - val response = authService.storeLogin(storeLoginBody) - if (externalId != null && externalId != response.externalId) return RecoverSubscriptionResult.Failure("") - authRepository.setAccount(Account(externalId = response.externalId, email = null)) - authRepository.setAuthToken(response.authToken) - exchangeAuthToken(response.authToken) - if (fetchAndStoreAllData()) { - logcat(LogPriority.DEBUG) { "Subs: store login succeeded" } - val subscription = authRepository.getSubscription() - if (subscription?.isActive() == true) { - RecoverSubscriptionResult.Success(subscription) + ?: return StoreLoginResult.Failure.PurchaseHistoryNotAvailable + + val codeVerifier = pkceGenerator.generateCodeVerifier() + val codeChallenge = pkceGenerator.generateCodeChallenge(codeVerifier) + val sessionId = authClient.authorize(codeChallenge) + val authorizationCode = authClient.storeLogin(sessionId, purchase.signature, purchase.originalJson) + val tokens = authClient.getTokens(sessionId, authorizationCode, codeVerifier) + val validatedTokens = validateTokens(tokens) + + if (accountExternalId != null && accountExternalId != validatedTokens.accessTokenClaims.accountExternalId) { + return StoreLoginResult.Failure.AccountExternalIdMismatch + } + + StoreLoginResult.Success(validatedTokens) + } catch (e: Exception) { + if (e is HttpException && e.code() == 400) { + StoreLoginResult.Failure.AuthenticationError + } else { + StoreLoginResult.Failure.Other + } + } + } + + override suspend fun recoverSubscriptionFromStore(externalId: String?): RecoverSubscriptionResult { + return try { + if (shouldUseAuthV2()) { + require(externalId == null) { "Use storeLogin() directly to re-authenticate using existing externalId" } + when (val storeLoginResult = storeLogin()) { + is StoreLoginResult.Success -> { + saveTokens(storeLoginResult.tokens) + refreshSubscriptionData() + val subscription = getSubscription() + if (subscription?.isActive() == true) { + RecoverSubscriptionResult.Success(subscription) + } else { + RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) + } + } + is StoreLoginResult.Failure -> { + RecoverSubscriptionResult.Failure("") + } + } + } else { + val purchase = playBillingManager.purchaseHistory.lastOrNull() + if (purchase != null) { + val signature = purchase.signature + val body = purchase.originalJson + val storeLoginBody = StoreLoginBody(signature = signature, signedData = body, packageName = context.packageName) + val response = authService.storeLogin(storeLoginBody) + if (externalId != null && externalId != response.externalId) return RecoverSubscriptionResult.Failure("") + authRepository.setAccount(Account(externalId = response.externalId, email = null)) + authRepository.setAuthToken(response.authToken) + exchangeAuthToken(response.authToken) + if (fetchAndStoreAllData()) { + logcat(LogPriority.DEBUG) { "Subs: store login succeeded" } + val subscription = authRepository.getSubscription() + if (subscription?.isActive() == true) { + RecoverSubscriptionResult.Success(subscription) + } else { + RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) + } } else { - RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) + RecoverSubscriptionResult.Failure("") } } else { - RecoverSubscriptionResult.Failure("") + RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) } - } else { - RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) } } catch (e: Exception) { logcat(LogPriority.DEBUG) { "Subs: Exception!" } @@ -470,15 +656,39 @@ class RealSubscriptionsManager @Inject constructor( override suspend fun getSubscriptionOffer(): SubscriptionOffer? = playBillingManager.products .find { it.productId == BASIC_SUBSCRIPTION } - ?.run { - val monthlyOffer = subscriptionOfferDetails?.find { it.basePlanId == MONTHLY_PLAN } ?: return@run null - val yearlyOffer = subscriptionOfferDetails?.find { it.basePlanId == YEARLY_PLAN } ?: return@run null + ?.subscriptionOfferDetails + .orEmpty() + .associateBy { it.basePlanId } + .let { availablePlans -> + when { + availablePlans.keys.containsAll(listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)) -> { + availablePlans.getValue(MONTHLY_PLAN_US) to availablePlans.getValue(YEARLY_PLAN_US) + } + availablePlans.keys.containsAll(listOf(MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW)) && isLaunchedRow() -> { + availablePlans.getValue(MONTHLY_PLAN_ROW) to availablePlans.getValue(YEARLY_PLAN_ROW) + } + else -> null + } + } + ?.let { (monthlyOffer, yearlyOffer) -> + val features = if (privacyProFeature.get().featuresApi().isEnabled()) { + authRepository.getFeatures(monthlyOffer.basePlanId) + } else { + when (monthlyOffer.basePlanId) { + MONTHLY_PLAN_US -> setOf(LEGACY_FE_NETP, LEGACY_FE_PIR, LEGACY_FE_ITR) + MONTHLY_PLAN_ROW -> setOf(NETP, ROW_ITR) + else -> throw IllegalStateException() + } + } + + if (features.isEmpty()) return@let null SubscriptionOffer( monthlyPlanId = monthlyOffer.basePlanId, monthlyFormattedPrice = monthlyOffer.pricingPhases.pricingPhaseList.first().formattedPrice, yearlyPlanId = yearlyOffer.basePlanId, yearlyFormattedPrice = yearlyOffer.pricingPhases.pricingPhaseList.first().formattedPrice, + features = features, ) } @@ -490,7 +700,10 @@ class RealSubscriptionsManager @Inject constructor( _currentPurchaseState.emit(CurrentPurchase.PreFlowInProgress) // refresh any existing account / subscription data - fetchAndStoreAllData() + when { + isSignedInV2() -> refreshSubscriptionData() + isSignedInV1() -> fetchAndStoreAllData() + } if (!isSignedIn()) { recoverSubscriptionFromStore() @@ -517,7 +730,9 @@ class RealSubscriptionsManager @Inject constructor( if (subscription == null && !isSignedIn()) { createAccount() - exchangeAuthToken(authRepository.getAuthToken()!!) + if (!shouldUseAuthV2()) { + exchangeAuthToken(authRepository.getAuthToken()!!) + } } logcat(LogPriority.DEBUG) { "Subs: external id is ${authRepository.getAccount()!!.externalId}" } @@ -535,9 +750,17 @@ class RealSubscriptionsManager @Inject constructor( } } + @Deprecated("This method will be removed after migrating to auth v2") override suspend fun getAuthToken(): AuthTokenResult { + if (isSignedInV2()) { + return when (val accessToken = getAccessToken()) { + is AccessTokenResult.Failure -> AuthTokenResult.Failure.UnknownError + is AccessTokenResult.Success -> AuthTokenResult.Success(accessToken.accessToken) + } + } + try { - return if (isSignedIn()) { + return if (isSignedInV1()) { logcat { "Subs auth token is ${authRepository.getAuthToken()}" } validateToken(authRepository.getAuthToken()!!) AuthTokenResult.Success(authRepository.getAuthToken()!!) @@ -563,25 +786,80 @@ class RealSubscriptionsManager @Inject constructor( } override suspend fun getAccessToken(): AccessTokenResult { - return if (isSignedIn()) { - AccessTokenResult.Success(authRepository.getAccessToken()!!) + return when { + isSignedIn() && shouldUseAuthV2() -> try { + AccessTokenResult.Success(getValidAccessTokenV2()) + } catch (e: Exception) { + AccessTokenResult.Failure("Token not found") + } + isSignedInV1() -> AccessTokenResult.Success(authRepository.getAccessToken()!!) + else -> AccessTokenResult.Failure("Token not found") + } + } + + private suspend fun getValidAccessTokenV2(): String { + check(isSignedIn()) + check(shouldUseAuthV2()) + + if (!isSignedInV2() && isSignedInV1()) { + migrateToAuthV2() + } + + val accessToken = authRepository.getAccessTokenV2() + ?.takeIf { isAccessTokenUsable(it) } + + return if (accessToken != null) { + accessToken.jwt } else { - AccessTokenResult.Failure("Token not found") + refreshAccessToken() + + // Rotating auth credentials didn't throw an exception, so a valid access token is expected to be available + val newAccessToken = authRepository.getAccessTokenV2() + checkNotNull(newAccessToken) + check(isAccessTokenUsable(newAccessToken)) + + newAccessToken.jwt } } + private suspend fun migrateToAuthV2() { + val accessTokenV1 = checkNotNull(authRepository.getAccessToken()) + val codeVerifier = pkceGenerator.generateCodeVerifier() + val codeChallenge = pkceGenerator.generateCodeChallenge(codeVerifier) + val sessionId = authClient.authorize(codeChallenge) + val authorizationCode = authClient.exchangeV1AccessToken(accessTokenV1, sessionId) + val tokens = authClient.getTokens(sessionId, authorizationCode, codeVerifier) + saveTokens(validateTokens(tokens)) + authRepository.setAccessToken(null) + authRepository.setAuthToken(null) + } + + private fun isAccessTokenUsable(accessToken: AccessToken): Boolean { + val currentTime = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) + return accessToken.expiresAt > currentTime + Duration.ofMinutes(1) + } + private suspend fun validateToken(token: String): ValidateTokenResponse { return authService.validateToken("Bearer $token") } private suspend fun createAccount() { try { - val account = authService.createAccount("Bearer ${emailManager.getToken()}") - if (account.authToken.isEmpty()) { - pixelSender.reportPurchaseFailureAccountCreation() + if (shouldUseAuthV2()) { + val codeVerifier = pkceGenerator.generateCodeVerifier() + val codeChallenge = pkceGenerator.generateCodeChallenge(codeVerifier) + val sessionId = authClient.authorize(codeChallenge) + val authorizationCode = authClient.createAccount(sessionId) + val tokens = authClient.getTokens(sessionId, authorizationCode, codeVerifier) + saveTokens(validateTokens(tokens)) } else { - authRepository.setAccount(Account(externalId = account.externalId, email = null)) - authRepository.setAuthToken(account.authToken) + val account = authService.createAccount("Bearer ${emailManager.getToken()}") + if (account.authToken.isEmpty()) { + pixelSender.reportPurchaseFailureAccountCreation() + } else { + authRepository.setAccount(Account(externalId = account.externalId, email = null)) + authRepository.setAuthToken(account.authToken) + } } } catch (e: Exception) { when (e) { @@ -593,6 +871,10 @@ class RealSubscriptionsManager @Inject constructor( } } + private suspend fun isLaunchedRow(): Boolean = withContext(dispatcherProvider.io()) { + privacyProFeature.get().isLaunchedROW().isEnabled() + } + private fun parseError(e: HttpException): ResponseError? { return try { val error = adapter.fromJson(e.response()?.errorBody()?.string().orEmpty()) @@ -602,6 +884,16 @@ class RealSubscriptionsManager @Inject constructor( } } + private sealed class StoreLoginResult { + data class Success(val tokens: ValidatedTokenPair) : StoreLoginResult() + sealed class Failure : StoreLoginResult() { + data object PurchaseHistoryNotAvailable : Failure() + data object AccountExternalIdMismatch : Failure() + data object AuthenticationError : Failure() + data object Other : Failure() + } + } + companion object { const val SUBSCRIPTION_NOT_FOUND_ERROR = "SubscriptionNotFound" } @@ -648,4 +940,12 @@ data class SubscriptionOffer( val monthlyFormattedPrice: String, val yearlyPlanId: String, val yearlyFormattedPrice: String, + val features: Set, +) + +data class ValidatedTokenPair( + val accessToken: String, + val accessTokenClaims: AccessTokenClaims, + val refreshToken: String, + val refreshTokenClaims: RefreshTokenClaims, ) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsToggleTargetMatcherPlugin.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsToggleTargetMatcherPlugin.kt new file mode 100644 index 000000000000..fe56824243f9 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsToggleTargetMatcherPlugin.kt @@ -0,0 +1,37 @@ +/* + * 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.subscriptions.impl + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle.State.Target +import com.duckduckgo.feature.toggles.api.Toggle.TargetMatcherPlugin +import com.duckduckgo.subscriptions.api.Subscriptions +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.runBlocking + +@ContributesMultibinding(AppScope::class) +class SubscriptionsToggleTargetMatcherPlugin @Inject constructor( + private val subscriptions: Subscriptions, +) : TargetMatcherPlugin { + override fun matchesTargetProperty(target: Target): Boolean { + return target.isPrivacyProEligible?.let { isPrivacyProEligible -> + // runBlocking sucks I know :shrug: + isPrivacyProEligible == runBlocking { subscriptions.isEligible() } + } ?: true + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthClient.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthClient.kt new file mode 100644 index 000000000000..c5516938985b --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthClient.kt @@ -0,0 +1,254 @@ +/* + * 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.subscriptions.impl.auth2 + +import android.net.Uri +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import logcat.logcat +import retrofit2.HttpException +import retrofit2.Response + +interface AuthClient { + /** + * Starts authorization session. + * + * @param codeChallenge code challenge derived from code verifier as described in RFC 7636 section 4.2 + * @return authorization session id + */ + suspend fun authorize(codeChallenge: String): String + + /** + * Creates an account linked to the active authorization session. + * + * @param sessionId authorization session id + * @return authorization code required to fetch access token + */ + suspend fun createAccount(sessionId: String): String + + /** + * Obtains new access and refresh tokens from BE. + * + * @param sessionId authorization session id + * @param authorizationCode authorization code obtained when creating account + * @param codeVerifier code verifier generated as described in RFC 7636 section 4.1 + * @return [TokenPair] instance containing access and refresh tokens + */ + suspend fun getTokens( + sessionId: String, + authorizationCode: String, + codeVerifier: String, + ): TokenPair + + /** + * Obtains new access and refresh tokens from BE. + * + * @param refreshToken refresh token + * @return [TokenPair] instance containing access and refresh tokens + */ + suspend fun getTokens(refreshToken: String): TokenPair + + /** + * Obtains set of public JWKs used to validate JWTs (JWEs) generated by Auth API V2. + * + * @return [String] containing JWK set in JSON format (RFC 7517 section 5) + */ + suspend fun getJwks(): String + + /** + * Attempts to sign in using Play Store purchase history data. + * + * @param sessionId authorization session id + * @param signature cryptographically signed string that can be verified by the Auth API with public keys published by the Play Store + * @param googleSignedData signed data that produced the cryptographic signature in the signature param + * @return authorization code required to fetch access token + */ + suspend fun storeLogin( + sessionId: String, + signature: String, + googleSignedData: String, + ): String + + /** + * Attempts to sign in using V1 access token. + * + * @param accessTokenV1 access token obtained from auth API v1 + * @param sessionId authorization session id + * @return authorization code required to fetch access token + */ + suspend fun exchangeV1AccessToken( + accessTokenV1: String, + sessionId: String, + ): String + + /** + * Invalidates the current access token + refresh token pair based on the access token provided. + * This is meant to work on a best-effort basis, so this method does not throw if the request fails. + * + * @param accessTokenV2 access token obtained from auth API v2 + */ + suspend fun tryLogout(accessTokenV2: String) +} + +data class TokenPair( + val accessToken: String, + val refreshToken: String, +) + +@ContributesBinding(AppScope::class) +class AuthClientImpl @Inject constructor( + private val authService: AuthService, + private val appBuildConfig: AppBuildConfig, +) : AuthClient { + + override suspend fun authorize(codeChallenge: String): String { + val response = authService.authorize( + responseType = AUTH_V2_RESPONSE_TYPE, + codeChallenge = codeChallenge, + codeChallengeMethod = AUTH_V2_CODE_CHALLENGE_METHOD, + clientId = AUTH_V2_CLIENT_ID, + redirectUri = AUTH_V2_REDIRECT_URI, + scope = AUTH_V2_SCOPE, + ) + + if (response.code() == 302) { + val sessionId = response.headers() + .values("Set-Cookie") + .firstOrNull { it.startsWith("ddg_auth_session_id=") } + ?.substringBefore(';') + ?.substringAfter('=') + + if (sessionId == null) { + throw RuntimeException("Failed to extract sessionId") + } + + return sessionId + } else { + throw HttpException(response) + } + } + + override suspend fun createAccount(sessionId: String): String { + val response = authService.createAccount("ddg_auth_session_id=$sessionId") + return response.getAuthorizationCode() + } + + override suspend fun getTokens( + sessionId: String, + authorizationCode: String, + codeVerifier: String, + ): TokenPair { + val tokensResponse = authService.token( + grantType = GRANT_TYPE_AUTHORIZATION_CODE, + clientId = AUTH_V2_CLIENT_ID, + codeVerifier = codeVerifier, + code = authorizationCode, + redirectUri = AUTH_V2_REDIRECT_URI, + refreshToken = null, + ) + return TokenPair( + accessToken = tokensResponse.accessToken, + refreshToken = tokensResponse.refreshToken, + ) + } + + override suspend fun getTokens(refreshToken: String): TokenPair { + val tokensResponse = authService.token( + grantType = GRANT_TYPE_REFRESH_TOKEN, + clientId = AUTH_V2_CLIENT_ID, + codeVerifier = null, + code = null, + redirectUri = null, + refreshToken = refreshToken, + ) + return TokenPair( + accessToken = tokensResponse.accessToken, + refreshToken = tokensResponse.refreshToken, + ) + } + + override suspend fun getJwks(): String = + authService.jwks().string() + + override suspend fun storeLogin( + sessionId: String, + signature: String, + googleSignedData: String, + ): String { + val response = authService.login( + cookie = "ddg_auth_session_id=$sessionId", + body = StoreLoginBody( + method = "signature", + signature = signature, + source = "google_play_store", + googleSignedData = googleSignedData, + googlePackageName = appBuildConfig.applicationId, + ), + ) + + return response.getAuthorizationCode() + } + + override suspend fun exchangeV1AccessToken( + accessTokenV1: String, + sessionId: String, + ): String { + val response = authService.exchange( + authorization = "Bearer $accessTokenV1", + cookie = "ddg_auth_session_id=$sessionId", + ) + return response.getAuthorizationCode() + } + + override suspend fun tryLogout(accessTokenV2: String) { + try { + authService.logout(authorization = "Bearer $accessTokenV2") + } catch (e: Exception) { + logcat { "Logout request failed" } + } + } + + private fun Response.getAuthorizationCode(): String { + if (code() == 302) { + val authorizationCode = headers() + .values("Location") + .firstOrNull() + ?.let { Uri.parse(it) } + ?.getQueryParameter("code") + + if (authorizationCode == null) { + throw RuntimeException("Failed to extract authorization code") + } + + return authorizationCode + } else { + throw HttpException(this) + } + } + + private companion object { + const val AUTH_V2_CLIENT_ID = "f4311287-0121-40e6-8bbd-85c36daf1837" + const val AUTH_V2_REDIRECT_URI = "com.duckduckgo:/authcb" + const val AUTH_V2_SCOPE = "privacypro" + const val AUTH_V2_CODE_CHALLENGE_METHOD = "S256" + const val AUTH_V2_RESPONSE_TYPE = "code" + const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" + const val GRANT_TYPE_REFRESH_TOKEN = "refresh_token" + } +} diff --git a/auth-jwt/auth-jwt-api/src/main/java/com/duckduckgo/authjwt/api/AuthJwtValidator.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidator.kt similarity index 92% rename from auth-jwt/auth-jwt-api/src/main/java/com/duckduckgo/authjwt/api/AuthJwtValidator.kt rename to subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidator.kt index fd7f90c53048..edb1a0fcb04d 100644 --- a/auth-jwt/auth-jwt-api/src/main/java/com/duckduckgo/authjwt/api/AuthJwtValidator.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidator.kt @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.duckduckgo.authjwt.api +package com.duckduckgo.subscriptions.impl.auth2 +import com.duckduckgo.subscriptions.impl.model.Entitlement import java.time.Instant /** @@ -76,15 +77,3 @@ data class RefreshTokenClaims( */ val accountExternalId: String, ) - -data class Entitlement( - /** - * Name of the product represented by this entitlement. - */ - val product: String, - - /** - * Name of the entitlement. - */ - val name: String, -) diff --git a/auth-jwt/auth-jwt-impl/src/main/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImpl.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImpl.kt similarity index 88% rename from auth-jwt/auth-jwt-impl/src/main/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImpl.kt rename to subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImpl.kt index 4929de35198f..7d1e35461e02 100644 --- a/auth-jwt/auth-jwt-impl/src/main/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImpl.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImpl.kt @@ -14,20 +14,14 @@ * limitations under the License. */ -package com.duckduckgo.authjwt.impl +package com.duckduckgo.subscriptions.impl.auth2 -import com.duckduckgo.authjwt.api.AccessTokenClaims -import com.duckduckgo.authjwt.api.AuthJwtValidator -import com.duckduckgo.authjwt.api.Entitlement -import com.duckduckgo.authjwt.api.RefreshTokenClaims import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.squareup.anvil.annotations.ContributesBinding import io.jsonwebtoken.Claims -import io.jsonwebtoken.JwsHeader -import io.jsonwebtoken.Jwts -import io.jsonwebtoken.security.Jwks -import java.util.Date +import java.util.* import javax.inject.Inject @ContributesBinding(AppScope::class) @@ -85,14 +79,14 @@ class AuthJwtValidatorImpl @Inject constructor( requiredAudience: String, requiredScope: String, ): Claims { - val jwks = Jwks.setParser() + val jwks = io.jsonwebtoken.security.Jwks.setParser() .build() .parse(jwkSet) .getKeys() - return Jwts.parser() + return io.jsonwebtoken.Jwts.parser() .keyLocator { header -> - val keyId = (header as JwsHeader).keyId + val keyId = (header as io.jsonwebtoken.JwsHeader).keyId jwks.first { it.id == keyId }.toKey() } .clock { Date(timeProvider.currentTimeMillis()) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthService.kt new file mode 100644 index 000000000000..45490b534f90 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthService.kt @@ -0,0 +1,84 @@ +/* + * 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.subscriptions.impl.auth2 + +import com.squareup.moshi.Json +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Query + +interface AuthService { + @GET("https://quack.duckduckgo.com/api/auth/v2/authorize") + suspend fun authorize( + @Query("response_type") responseType: String, + @Query("code_challenge") codeChallenge: String, + @Query("code_challenge_method") codeChallengeMethod: String, + @Query("client_id") clientId: String, + @Query("redirect_uri") redirectUri: String, + @Query("scope") scope: String, + ): Response + + @POST("https://quack.duckduckgo.com/api/auth/v2/account/create") + suspend fun createAccount( + @Header("Cookie") cookie: String, + ): Response + + @GET("https://quack.duckduckgo.com/api/auth/v2/token") + suspend fun token( + @Query("grant_type") grantType: String, + @Query("client_id") clientId: String, + @Query("code_verifier") codeVerifier: String?, + @Query("code") code: String?, + @Query("redirect_uri") redirectUri: String?, + @Query("refresh_token") refreshToken: String?, + ): TokensResponse + + @GET("https://quack.duckduckgo.com/api/auth/v2/.well-known/jwks.json") + suspend fun jwks(): ResponseBody + + @POST("https://quack.duckduckgo.com/api/auth/v2/login") + suspend fun login( + @Header("Cookie") cookie: String, + @Body body: StoreLoginBody, + ): Response + + @POST("https://quack.duckduckgo.com/api/auth/v2/exchange") + suspend fun exchange( + @Header("Authorization") authorization: String, + @Header("Cookie") cookie: String, + ): Response + + @POST("https://quack.duckduckgo.com/api/auth/v2/logout") + suspend fun logout(@Header("Authorization") authorization: String) +} + +data class TokensResponse( + @field:Json(name = "access_token") val accessToken: String, + @field:Json(name = "refresh_token") val refreshToken: String, +) + +data class StoreLoginBody( + @field:Json(name = "method") val method: String, + @field:Json(name = "signature") val signature: String, + @field:Json(name = "source") val source: String, + @field:Json(name = "google_signed_data") val googleSignedData: String, + @field:Json(name = "google_package_name") val googlePackageName: String, +) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthServiceModule.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthServiceModule.kt new file mode 100644 index 000000000000..c8bb4eba6369 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthServiceModule.kt @@ -0,0 +1,51 @@ +/* + * 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.subscriptions.impl.auth2 + +import android.annotation.SuppressLint +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn +import javax.inject.Named +import javax.inject.Provider +import okhttp3.OkHttpClient +import retrofit2.Retrofit + +@Module +@ContributesTo(AppScope::class) +object AuthServiceModule { + @SuppressLint("NoRetrofitCreateMethodCallDetector") + @Provides + @SingleInstanceIn(AppScope::class) + fun provideAuthService( + @Named("nonCaching") okHttpClient: Provider, + @Named("nonCaching") retrofit: Retrofit, + ): AuthService { + val okHttpClientWithoutRedirects = lazy { + okHttpClient.get().newBuilder() + .followRedirects(false) + .build() + } + + return retrofit.newBuilder() + .callFactory { okHttpClientWithoutRedirects.value.newCall(it) } + .build() + .create(AuthService::class.java) + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/BackgroundTokenRefresh.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/BackgroundTokenRefresh.kt new file mode 100644 index 000000000000..e57d85508a75 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/BackgroundTokenRefresh.kt @@ -0,0 +1,101 @@ +/* + * 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.subscriptions.impl.auth2 + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.duckduckgo.anvil.annotations.ContributesWorker +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.AccessTokenResult.Success +import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.auth2.TokenRefreshWorker.Companion.REFRESH_WORKER_NAME +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Duration +import java.util.concurrent.TimeUnit.MINUTES +import javax.inject.Inject + +/** + * The refresh token is valid for 1 month. We should ensure it doesn’t expire, as that could result in the user being signed out. + * In practice, if the app is actively used, tokens will be refreshed more frequently since the access token is only valid for 4 hours. + * Otherwise, this worker ensures that we obtain a new refresh token before the current one expires. + */ +interface BackgroundTokenRefresh { + fun schedule() +} + +@ContributesBinding(AppScope::class) +class BackgroundTokenRefreshImpl @Inject constructor( + val workManager: WorkManager, +) : BackgroundTokenRefresh { + override fun schedule() { + val workRequest = PeriodicWorkRequestBuilder(repeatInterval = Duration.ofDays(7)) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setInitialDelay(duration = Duration.ofDays(7)) + .setBackoffCriteria(BackoffPolicy.LINEAR, 10, MINUTES) + .build() + + workManager.enqueueUniquePeriodicWork( + REFRESH_WORKER_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + workRequest, + ) + } +} + +@ContributesWorker(AppScope::class) +class TokenRefreshWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + @Inject + lateinit var workManager: WorkManager + + @Inject + lateinit var subscriptionsManager: SubscriptionsManager + + override suspend fun doWork(): Result { + return try { + if (subscriptionsManager.isSignedInV2()) { + /* + Access tokens are valid for only a few hours. + Calling getAccessToken() will refresh the tokens if they haven’t been refreshed recently. + */ + val result = subscriptionsManager.getAccessToken() + check(result is Success) + } else { + workManager.cancelUniqueWork(REFRESH_WORKER_NAME) + } + Result.success() + } catch (e: Exception) { + Result.failure() + } + } + + companion object { + const val REFRESH_WORKER_NAME = "WORKER_TOKEN_REFRESH" + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/PkceGenerator.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/PkceGenerator.kt new file mode 100644 index 000000000000..7c57cf0c0146 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/PkceGenerator.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.subscriptions.impl.auth2 + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.security.MessageDigest +import java.security.SecureRandom +import javax.inject.Inject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +interface PkceGenerator { + fun generateCodeVerifier(): String + fun generateCodeChallenge(codeVerifier: String): String +} + +@ContributesBinding(AppScope::class) +class PkceGeneratorImpl @Inject constructor() : PkceGenerator { + + override fun generateCodeVerifier(): String { + val code = ByteArray(32) + .apply { SecureRandom().nextBytes(this) } + + return code.encodeBase64() + } + + override fun generateCodeChallenge(codeVerifier: String): String { + return MessageDigest.getInstance("SHA-256") + .digest(codeVerifier.toByteArray(Charsets.US_ASCII)) + .encodeBase64() + } + + @OptIn(ExperimentalEncodingApi::class) + private fun ByteArray.encodeBase64(): String { + return Base64.UrlSafe.encode(this).trimEnd('=') + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt index eb0b59e2b992..0bc34c4b15cf 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt @@ -51,7 +51,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -60,6 +62,7 @@ import logcat.logcat interface PlayBillingManager { val products: List + val productsFlow: Flow> val purchaseHistory: List val purchaseState: Flow @@ -94,7 +97,13 @@ class RealPlayBillingManager @Inject constructor( override val purchaseState = _purchaseState.asSharedFlow() // New Subscription ProductDetails - override var products = emptyList() + private var _products = MutableStateFlow(emptyList()) + + override val products: List + get() = _products.value + + override val productsFlow: Flow> + get() = _products.asStateFlow() // Purchase History override var purchaseHistory = emptyList() @@ -240,7 +249,7 @@ class RealPlayBillingManager @Inject constructor( if (result.products.isEmpty()) { logcat { "No products found" } } - this.products = result.products + _products.value = result.products } is SubscriptionsResult.Failure -> { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackCategoryFragment.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackCategoryFragment.kt index c84a65316701..aec433e71a6f 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackCategoryFragment.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackCategoryFragment.kt @@ -18,20 +18,31 @@ package com.duckduckgo.subscriptions.impl.feedback import android.os.Bundle import android.view.View +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withStarted import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.subscriptions.impl.R +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.databinding.ContentFeedbackCategoryBinding import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.ITR import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.PIR import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.VPN +import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import javax.inject.Inject +import kotlinx.coroutines.launch @InjectWith(FragmentScope::class) class SubscriptionFeedbackCategoryFragment : SubscriptionFeedbackFragment(R.layout.content_feedback_category) { private val binding: ContentFeedbackCategoryBinding by viewBinding() + @Inject + lateinit var authRepository: AuthRepository + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -51,6 +62,18 @@ class SubscriptionFeedbackCategoryFragment : SubscriptionFeedbackFragment(R.layo binding.categoryPir.setOnClickListener { listener.onUserClickedCategory(PIR) } + + lifecycleScope.launch { + val pirAvailable = isPirCategoryAvailable() + withStarted { + binding.categoryPir.isVisible = pirAvailable + } + } + } + + private suspend fun isPirCategoryAvailable(): Boolean { + val subscription = authRepository.getSubscription() ?: return false + return subscription.productId in listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US) } interface Listener { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt index fd4aba335146..d2bdc8b8396a 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt @@ -67,7 +67,7 @@ class SubscriptionMessagingInterface @Inject constructor( SubscriptionsHandler(), GetSubscriptionMessage(subscriptionsManager, dispatcherProvider), SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender, subscriptionsChecker), - InformationalEventsMessage(appCoroutineScope, pixelSender), + InformationalEventsMessage(subscriptionsManager, appCoroutineScope, pixelSender), GetAccessTokenMessage(subscriptionsManager), ) @@ -220,6 +220,7 @@ class SubscriptionMessagingInterface @Inject constructor( } private class InformationalEventsMessage( + private val subscriptionsManager: SubscriptionsManager, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val pixelSender: SubscriptionPixelSender, ) : JsMessageHandler { @@ -232,7 +233,11 @@ class SubscriptionMessagingInterface @Inject constructor( when (jsMessage.method) { "subscriptionsMonthlyPriceClicked" -> pixelSender.reportMonthlyPriceClick() "subscriptionsYearlyPriceClicked" -> pixelSender.reportYearlyPriceClick() - "subscriptionsAddEmailSuccess" -> pixelSender.reportAddEmailSuccess() + "subscriptionsAddEmailSuccess" -> { + pixelSender.reportAddEmailSuccess() + subscriptionsManager.tryRefreshAccessToken() + } + "subscriptionsEditEmailSuccess" -> subscriptionsManager.tryRefreshAccessToken() "subscriptionsWelcomeAddEmailClicked", "subscriptionsWelcomeFaqClicked", -> { @@ -244,6 +249,16 @@ class SubscriptionMessagingInterface @Inject constructor( } } + private suspend fun SubscriptionsManager.tryRefreshAccessToken() { + try { + if (subscriptionsManager.isSignedInV2()) { + refreshAccessToken() + } + } catch (e: Exception) { + logcat { e.stackTraceToString() } + } + } + override val allowedDomains: List = emptyList() override val featureName: String = "useSubscription" override val methods: List = listOf( @@ -251,6 +266,7 @@ class SubscriptionMessagingInterface @Inject constructor( "subscriptionsYearlyPriceClicked", "subscriptionsUnknownPriceClicked", "subscriptionsAddEmailSuccess", + "subscriptionsEditEmailSuccess", "subscriptionsWelcomeAddEmailClicked", "subscriptionsWelcomeFaqClicked", ) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/model/Entitlement.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/model/Entitlement.kt new file mode 100644 index 000000000000..a5b99bf7a1c1 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/model/Entitlement.kt @@ -0,0 +1,29 @@ +/* + * 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.subscriptions.impl.model + +data class Entitlement( + /** + * Name of the entitlement. + */ + val name: String, + + /** + * Name of the product represented by this entitlement. + */ + val product: String, +) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt index a589b1b1114a..ea94da63795b 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt @@ -28,6 +28,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.serp_promo.SerpPromo import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore import com.duckduckgo.subscriptions.impl.store.SubscriptionsEncryptedDataStore @@ -39,10 +40,14 @@ import com.squareup.moshi.Types import dagger.Module import dagger.Provides import dagger.SingleInstanceIn +import java.time.Instant import kotlinx.coroutines.withContext interface AuthRepository { - suspend fun getExternalID(): String? + suspend fun setAccessTokenV2(accessToken: AccessToken?) + suspend fun getAccessTokenV2(): AccessToken? + suspend fun setRefreshTokenV2(refreshToken: RefreshToken?) + suspend fun getRefreshTokenV2(): RefreshToken? suspend fun setAccessToken(accessToken: String?) suspend fun getAccessToken(): String? suspend fun setAuthToken(authToken: String?) @@ -56,6 +61,8 @@ interface AuthRepository { suspend fun purchaseToWaitingStatus() suspend fun getStatus(): SubscriptionStatus suspend fun canSupportEncryption(): Boolean + suspend fun setFeatures(basePlanId: String, features: Set) + suspend fun getFeatures(basePlanId: String): Set } @Module @@ -80,6 +87,15 @@ internal class RealAuthRepository constructor( private val moshi = Builder().build() + private val featuresAdapter by lazy { + val type = Types.newParameterizedType( + Map::class.java, + String::class.java, + Set::class.java, + ) + moshi.adapter>>(type) + } + private inline fun Moshi.listToJson(list: List): String { return adapter>(Types.newParameterizedType(List::class.java, T::class.java)).toJson(list) } @@ -87,21 +103,40 @@ internal class RealAuthRepository constructor( return adapter>(Types.newParameterizedType(List::class.java, T::class.java)).fromJson(jsonString) } - override suspend fun setEntitlements(entitlements: List) = withContext(dispatcherProvider.io()) { - subscriptionsDataStore.entitlements = moshi.listToJson(entitlements) + override suspend fun setAccessTokenV2(accessToken: AccessToken?) = withContext(dispatcherProvider.io()) { + subscriptionsDataStore.accessTokenV2 = accessToken?.jwt + subscriptionsDataStore.accessTokenV2ExpiresAt = accessToken?.expiresAt + updateSerpPromoCookie() + } + + override suspend fun getAccessTokenV2(): AccessToken? = withContext(dispatcherProvider.io()) { + val jwt = subscriptionsDataStore.accessTokenV2 ?: return@withContext null + val expiresAt = subscriptionsDataStore.accessTokenV2ExpiresAt ?: return@withContext null + AccessToken(jwt, expiresAt) } - override suspend fun getEntitlements(): List { - return subscriptionsDataStore.entitlements?.let { moshi.parseList(it) } ?: emptyList() + override suspend fun setRefreshTokenV2(refreshToken: RefreshToken?) = withContext(dispatcherProvider.io()) { + subscriptionsDataStore.refreshTokenV2 = refreshToken?.jwt + subscriptionsDataStore.refreshTokenV2ExpiresAt = refreshToken?.expiresAt } - override suspend fun getExternalID(): String? = withContext(dispatcherProvider.io()) { - return@withContext subscriptionsDataStore.externalId + override suspend fun getRefreshTokenV2(): RefreshToken? = withContext(dispatcherProvider.io()) { + val jwt = subscriptionsDataStore.refreshTokenV2 ?: return@withContext null + val expiresAt = subscriptionsDataStore.refreshTokenV2ExpiresAt ?: return@withContext null + RefreshToken(jwt, expiresAt) + } + + override suspend fun setEntitlements(entitlements: List) = withContext(dispatcherProvider.io()) { + subscriptionsDataStore.entitlements = moshi.listToJson(entitlements) + } + + override suspend fun getEntitlements(): List = withContext(dispatcherProvider.io()) { + subscriptionsDataStore.entitlements?.let { moshi.parseList(it) } ?: emptyList() } override suspend fun setAccessToken(accessToken: String?) = withContext(dispatcherProvider.io()) { subscriptionsDataStore.accessToken = accessToken - serpPromo.injectCookie(accessToken) + updateSerpPromoCookie() } override suspend fun setAuthToken(authToken: String?) = withContext(dispatcherProvider.io()) { @@ -167,8 +202,42 @@ internal class RealAuthRepository constructor( override suspend fun canSupportEncryption(): Boolean = withContext(dispatcherProvider.io()) { subscriptionsDataStore.canUseEncryption() } + + override suspend fun setFeatures( + basePlanId: String, + features: Set, + ) = withContext(dispatcherProvider.io()) { + val featuresMap = subscriptionsDataStore.subscriptionFeatures + ?.let(featuresAdapter::fromJson) + ?.toMutableMap() ?: mutableMapOf() + + featuresMap[basePlanId] = features + + subscriptionsDataStore.subscriptionFeatures = featuresAdapter.toJson(featuresMap) + } + + override suspend fun getFeatures(basePlanId: String): Set = withContext(dispatcherProvider.io()) { + subscriptionsDataStore.subscriptionFeatures + ?.let(featuresAdapter::fromJson) + ?.get(basePlanId) ?: emptySet() + } + + private suspend fun updateSerpPromoCookie() = withContext(dispatcherProvider.io()) { + val accessToken = subscriptionsDataStore.run { accessTokenV2 ?: accessToken } + serpPromo.injectCookie(accessToken) + } } +data class AccessToken( + val jwt: String, + val expiresAt: Instant, +) + +data class RefreshToken( + val jwt: String, + val expiresAt: Instant, +) + data class Account( val email: String?, val externalId: String, @@ -207,8 +276,3 @@ fun List.toProductList(): List { emptyList() } } - -data class Entitlement( - val name: String, - val product: String, -) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriberMatchingAttribute.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriberMatchingAttribute.kt index 63f6f13c33c6..e7811d19a339 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriberMatchingAttribute.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriberMatchingAttribute.kt @@ -40,7 +40,7 @@ class RMFPProSubscriberMatchingAttribute @Inject constructor( ) : JsonToMatchingAttributeMapper, AttributeMatcherPlugin { override suspend fun evaluate(matchingAttribute: MatchingAttribute): Boolean? { return when (matchingAttribute) { - is ProSubscriberMatchingAttribute -> subscriptions.isSubscribed() == matchingAttribute.remoteValue + is ProSubscriberMatchingAttribute -> subscriptions.isSignedIn() == matchingAttribute.remoteValue else -> null } } @@ -58,10 +58,6 @@ class RMFPProSubscriberMatchingAttribute @Inject constructor( else -> null } } - - private suspend fun Subscriptions.isSubscribed(): Boolean { - return getAccessToken() != null - } } private data class ProSubscriberMatchingAttribute( diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/serp_promo/SerpPromo.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/serp_promo/SerpPromo.kt index 578591c9f5eb..3b21cd4d87aa 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/serp_promo/SerpPromo.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/serp_promo/SerpPromo.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.cookies.api.CookieManagerProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.subscriptions.impl.PrivacyProFeature import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding @@ -55,6 +56,7 @@ class RealSerpPromo @Inject constructor( @InternalApi private val cookieManager: CookieManagerWrapper, private val dispatcherProvider: DispatcherProvider, private val privacyProFeature: Lazy, + private val subscriptions: Lazy, // break dep cycle ) : SerpPromo, MainProcessLifecycleObserver { override suspend fun injectCookie(cookieValue: String?) = withContext(dispatcherProvider.io()) { @@ -73,11 +75,8 @@ class RealSerpPromo @Inject constructor( owner.lifecycleScope.launch(dispatcherProvider.io()) { if (privacyProFeature.get().serpPromoCookie().isEnabled()) { kotlin.runCatching { - val cookies = cookieManager.getCookie(HTTPS_WWW_SUBSCRIPTION_DDG_COM) ?: "" - val pproCookies = cookies.split(";").filter { it.contains(SERP_PPRO_PROMO_COOKIE_NAME) } - if (pproCookies.isEmpty()) { - injectCookie("") - } + val accessToken = subscriptions.get().getAccessToken() ?: "" + injectCookie(accessToken) } } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/AuthService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/AuthService.kt index bdb8911b66fc..235737b96cf0 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/AuthService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/AuthService.kt @@ -18,7 +18,7 @@ package com.duckduckgo.subscriptions.impl.services import com.duckduckgo.anvil.annotations.ContributesNonCachingServiceApi import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.subscriptions.impl.repository.Entitlement +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.squareup.moshi.Json import retrofit2.http.Body import retrofit2.http.GET @@ -44,16 +44,8 @@ interface AuthService { */ @GET("https://quack.duckduckgo.com/api/auth/access-token") suspend fun accessToken(@Header("Authorization") authorization: String): AccessTokenResponse - - /** - * Deletes an account - */ - @POST("https://quack.duckduckgo.com/api/auth/account/delete") - suspend fun delete(@Header("Authorization") authorization: String): DeleteAccountResponse } -data class DeleteAccountResponse(val status: String) - data class StoreLoginBody( val signature: String, @field:Json(name = "signed_data") val signedData: String, diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt index ca6f293ba80e..e133741d11ac 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt @@ -19,10 +19,11 @@ package com.duckduckgo.subscriptions.impl.services import com.duckduckgo.anvil.annotations.ContributesNonCachingServiceApi import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.subscriptions.impl.auth.AuthRequired -import com.duckduckgo.subscriptions.impl.repository.Entitlement +import com.duckduckgo.subscriptions.impl.model.Entitlement import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Path @ContributesNonCachingServiceApi(AppScope::class) interface SubscriptionsService { @@ -45,6 +46,9 @@ interface SubscriptionsService { suspend fun feedback( @Body feedbackBody: FeedbackBody, ): FeedbackResponse + + @GET("https://subscriptions.duckduckgo.com/api/products/{sku}/features") + suspend fun features(@Path("sku") sku: String): FeaturesResponse } data class PortalResponse(val customerPortalUrl: String) @@ -92,3 +96,7 @@ data class FeedbackBody( data class FeedbackResponse( val message: String, ) + +data class FeaturesResponse( + val features: List, +) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt index afb5d5341e74..c44ce366ee81 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.subscriptions.api.Product.ITR +import com.duckduckgo.subscriptions.api.Product.ROW_ITR import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.Command.OpenItr @@ -64,7 +65,7 @@ class ItrSettingViewModel @Inject constructor( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) subscriptions.getEntitlementStatus().onEach { - _viewState.emit(viewState.value.copy(hasSubscription = it.contains(ITR))) + _viewState.emit(viewState.value.copy(hasSubscription = ITR in it || ROW_ITR in it)) }.launchIn(viewModelScope) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt index 6cb0f2fb7f12..1be5197b2a20 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt @@ -49,6 +49,8 @@ import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Comm import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenRestoreScreen import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenSettings import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.ViewState +import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.ViewState.SubscriptionRegion.ROW +import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.ViewState.SubscriptionRegion.US import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionActivity.Companion.RestoreSubscriptionScreenWithParams import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity.Companion.SubscriptionsSettingsScreenWithEmptyParams import com.duckduckgo.subscriptions.impl.ui.SubscriptionsWebViewActivityWithParams @@ -158,7 +160,13 @@ class ProSettingView @JvmOverloads constructor( } else -> { binding.subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingSubscribe)) - binding.subscriptionBuy.setSecondaryText(context.getString(R.string.subscriptionSettingSubscribeSubtitle)) + binding.subscriptionBuy.setSecondaryText( + when (viewState.region) { + ROW -> context.getString(R.string.subscriptionSettingSubscribeSubtitleRow) + US -> context.getString(R.string.subscriptionSettingSubscribeSubtitle) + else -> "" + }, + ) binding.subscriptionBuy.setItemStatus(DISABLED) binding.subscriptionGet.setText(R.string.subscriptionSettingGet) binding.subscriptionBuyContainer.show() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt index cf5989345b31..40bed01b9209 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt @@ -25,11 +25,14 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenBuyScreen import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenRestoreScreen import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenSettings +import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.ViewState.SubscriptionRegion import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -57,7 +60,12 @@ class ProSettingViewModel @Inject constructor( private val command = Channel(1, BufferOverflow.DROP_OLDEST) internal fun commands(): Flow = command.receiveAsFlow() - data class ViewState(val status: SubscriptionStatus = UNKNOWN) + data class ViewState( + val status: SubscriptionStatus = UNKNOWN, + val region: SubscriptionRegion? = null, + ) { + enum class SubscriptionRegion { US, ROW } + } private val _viewState = MutableStateFlow(ViewState()) val viewState = _viewState.asStateFlow() @@ -79,8 +87,13 @@ class ProSettingViewModel @Inject constructor( super.onCreate(owner) subscriptionsManager.subscriptionStatus .distinctUntilChanged() - .onEach { - _viewState.emit(viewState.value.copy(status = it)) + .onEach { subscriptionStatus -> + val region = when (subscriptionsManager.getSubscriptionOffer()?.monthlyPlanId) { + MONTHLY_PLAN_ROW -> SubscriptionRegion.ROW + MONTHLY_PLAN_US -> SubscriptionRegion.US + else -> null + } + _viewState.emit(viewState.value.copy(status = subscriptionStatus, region = region)) }.launchIn(viewModelScope) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt index 826007ef63ac..130dc0ba2a66 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt @@ -20,10 +20,15 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.duckduckgo.data.store.api.SharedPreferencesProvider import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import java.time.Instant interface SubscriptionsDataStore { // Auth + var accessTokenV2: String? + var accessTokenV2ExpiresAt: Instant? + var refreshTokenV2: String? + var refreshTokenV2ExpiresAt: Instant? var accessToken: String? var authToken: String? var email: String? @@ -37,6 +42,8 @@ interface SubscriptionsDataStore { var entitlements: String? var productId: String? + var subscriptionFeatures: String? + fun canUseEncryption(): Boolean } @@ -52,6 +59,46 @@ internal class SubscriptionsEncryptedDataStore constructor( return sharedPreferencesProvider.getEncryptedSharedPreferences(FILENAME, multiprocess = true) } + override var accessTokenV2: String? + get() = encryptedPreferences?.getString(KEY_ACCESS_TOKEN_V2, null) + set(value) { + encryptedPreferences?.edit(commit = true) { putString(KEY_ACCESS_TOKEN_V2, value) } + } + + override var accessTokenV2ExpiresAt: Instant? + get() = encryptedPreferences?.getLong(KEY_ACCESS_TOKEN_V2_EXPIRES_AT, 0) + ?.takeIf { it != 0L } + ?.let { Instant.ofEpochMilli(it) } + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_ACCESS_TOKEN_V2_EXPIRES_AT) + } else { + putLong(KEY_ACCESS_TOKEN_V2_EXPIRES_AT, value.toEpochMilli()) + } + } + } + + override var refreshTokenV2: String? + get() = encryptedPreferences?.getString(KEY_REFRESH_TOKEN_V2, null) + set(value) { + encryptedPreferences?.edit(commit = true) { putString(KEY_REFRESH_TOKEN_V2, value) } + } + + override var refreshTokenV2ExpiresAt: Instant? + get() = encryptedPreferences?.getLong(KEY_REFRESH_TOKEN_V2_EXPIRES_AT, 0) + ?.takeIf { it != 0L } + ?.let { Instant.ofEpochMilli(it) } + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_REFRESH_TOKEN_V2_EXPIRES_AT) + } else { + putLong(KEY_REFRESH_TOKEN_V2_EXPIRES_AT, value.toEpochMilli()) + } + } + } + override var productId: String? get() = encryptedPreferences?.getString(KEY_PRODUCT_ID, null) set(value) { @@ -140,6 +187,14 @@ internal class SubscriptionsEncryptedDataStore constructor( } } + override var subscriptionFeatures: String? + get() = encryptedPreferences?.getString(KEY_SUBSCRIPTION_FEATURES, null) + set(value) { + encryptedPreferences?.edit(commit = true) { + putString(KEY_SUBSCRIPTION_FEATURES, value) + } + } + override fun canUseEncryption(): Boolean { encryptedPreferences?.edit(commit = true) { putBoolean("test", true) } return encryptedPreferences?.getBoolean("test", false) == true @@ -147,6 +202,10 @@ internal class SubscriptionsEncryptedDataStore constructor( companion object { const val FILENAME = "com.duckduckgo.subscriptions.store" + const val KEY_ACCESS_TOKEN_V2 = "KEY_ACCESS_TOKEN_V2" + const val KEY_ACCESS_TOKEN_V2_EXPIRES_AT = "KEY_ACCESS_TOKEN_V2_EXPIRES_AT" + const val KEY_REFRESH_TOKEN_V2 = "KEY_REFRESH_TOKEN_V2" + const val KEY_REFRESH_TOKEN_V2_EXPIRES_AT = "KEY_REFRESH_TOKEN_V2_EXPIRES_AT" const val KEY_ACCESS_TOKEN = "KEY_ACCESS_TOKEN" const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN" const val KEY_PLATFORM = "KEY_PLATFORM" @@ -157,5 +216,6 @@ internal class SubscriptionsEncryptedDataStore constructor( const val KEY_ENTITLEMENTS = "KEY_ENTITLEMENTS" const val KEY_STATUS = "KEY_STATUS" const val KEY_PRODUCT_ID = "KEY_PRODUCT_ID" + const val KEY_SUBSCRIPTION_FEATURES = "KEY_SUBSCRIPTION_FEATURES" } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameters.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameters.kt index f66883889185..4133ab2f94e3 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameters.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameters.kt @@ -18,7 +18,6 @@ package com.duckduckgo.subscriptions.impl.survey import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.productIdToBillingPeriod import com.duckduckgo.survey.api.SurveyParameterPlugin @@ -45,8 +44,6 @@ class PproBillingParameterPlugin @Inject constructor( val productId = subscriptionsManager.getSubscription()?.productId return productId?.productIdToBillingPeriod() ?: "" } - - private fun String.isMonthly(): Boolean = this == MONTHLY_PLAN } @ContributesMultibinding(AppScope::class) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt index d67b5402f730..e6be6500a731 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt @@ -26,7 +26,8 @@ import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.SUBSCRIPTION_SETTINGS import com.duckduckgo.subscriptions.api.SubscriptionStatus -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut @@ -84,7 +85,10 @@ class SubscriptionSettingsViewModel @Inject constructor( val formatter = SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault()) val date = formatter.format(Date(subscription.expiresOrRenewsAt)) - val type = if (subscription.productId == MONTHLY_PLAN) Monthly else Yearly + val type = when (subscription.productId) { + MONTHLY_PLAN_US, MONTHLY_PLAN_ROW -> Monthly + else -> Yearly + } _viewState.emit( Ready( diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index 40f467cbbf26..30adf937beee 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -32,10 +32,14 @@ import com.duckduckgo.subscriptions.impl.JSONObjectAdapter import com.duckduckgo.subscriptions.impl.PrivacyProFeature import com.duckduckgo.subscriptions.impl.SubscriptionsChecker import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ITR +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_ITR +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_NETP +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_PIR import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.NETP import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.PIR import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.PLATFORM +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ROW_ITR import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender @@ -175,9 +179,9 @@ class SubscriptionWebViewViewModel @Inject constructor( val feature = runCatching { data.getString("feature") }.getOrNull() ?: return viewModelScope.launch { val commandToSend = when (feature) { - NETP -> networkProtectionAccessState.getScreenForCurrentState()?.let { GoToNetP(it) } - ITR -> GoToITR - PIR -> GoToPIR + NETP, LEGACY_FE_NETP -> networkProtectionAccessState.getScreenForCurrentState()?.let { GoToNetP(it) } + ITR, LEGACY_FE_ITR, ROW_ITR -> GoToITR + PIR, LEGACY_FE_PIR -> GoToPIR else -> null } if (hasPurchasedSubscription()) { @@ -245,7 +249,7 @@ class SubscriptionWebViewViewModel @Inject constructor( subscriptionOptions = SubscriptionOptionsJson( options = listOf(yearlyJson, monthlyJson), - features = listOf(FeatureJson(NETP), FeatureJson(ITR), FeatureJson(PIR)), + features = offer.features.map(::FeatureJson), ) } } diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml b/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml index 7c367f1b81a1..426a49d89793 100644 --- a/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml +++ b/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml @@ -135,7 +135,7 @@ + app:primaryText="@string/subscriptionSettingSectionSubscription" /> + app:primaryText="@string/subscriptionSettingRemoveFromDevice" /> + app:primaryText="@string/subscriptionSettingSectionHelpAndSupport" /> + app:primaryText="@string/subscriptionSettingSendFeedback" /> diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_category.xml b/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_category.xml index 1d3f71cb3a66..c76f907a188d 100644 --- a/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_category.xml +++ b/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_category.xml @@ -15,6 +15,7 @@ --> @@ -44,6 +45,8 @@ android:id="@+id/categoryPir" android:layout_width="match_parent" android:layout_height="wrap_content" + android:visibility="gone" + tools:visibility="visible" app:primaryText="@string/feedbackCategoryPir" /> + app:primaryTextTruncated="false" /> Use this email to activate your subscription in Settings > Privacy Pro in the DuckDuckGo app on your other devices. Learn More Need help with Privacy Pro? - Get answers to frequently asked questions or contact Privacy Pro support from our help pages. + Get answers to frequently asked questions or contact Privacy Pro support from our help pages. Feature availability varies by country. Subscribed Privacy Policy and Terms of Service @@ -66,6 +66,7 @@ Subscription Settings Protect your connection and identity with Privacy Pro Includes our VPN, Personal Information Removal, and Identity Theft Restoration. + Includes our VPN and Identity Theft Restoration. Get Privacy Pro I Have a Subscription Your subscription is being activated @@ -73,12 +74,15 @@ Your Privacy Pro subscription expired Subscribe again to continue using Privacy\u00A0Pro View Plans - + Subscription + Remove From This Device + Help and Support + Send Feedback You\'re all set. Your purchase was successful. - Your already had a subscription and we\'ve recovered that for you. + You already had a subscription and we\'ve recovered that for you. Something went wrong We\'re having trouble connecting. Please try again later. @@ -152,4 +156,11 @@ Please give us your feedback… What feature would you like to see? + + + Subscriptions + Subscription Settings + Activate Subscription + Privacy Pro + Send Feedback \ No newline at end of file diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 92e57134260c..71b82cf324b7 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -1,22 +1,38 @@ package com.duckduckgo.subscriptions.impl +import android.annotation.SuppressLint import android.content.Context -import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails.PricingPhase import com.android.billingclient.api.ProductDetails.PricingPhases +import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails import com.android.billingclient.api.PurchaseHistoryRecord import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.subscriptions.api.Product.NetP import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.SubscriptionStatus.* import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.NETP +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US +import com.duckduckgo.subscriptions.impl.auth2.AccessTokenClaims +import com.duckduckgo.subscriptions.impl.auth2.AuthClient +import com.duckduckgo.subscriptions.impl.auth2.AuthJwtValidator +import com.duckduckgo.subscriptions.impl.auth2.BackgroundTokenRefresh +import com.duckduckgo.subscriptions.impl.auth2.PkceGenerator +import com.duckduckgo.subscriptions.impl.auth2.PkceGeneratorImpl +import com.duckduckgo.subscriptions.impl.auth2.RefreshTokenClaims +import com.duckduckgo.subscriptions.impl.auth2.TokenPair import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AuthRepository import com.duckduckgo.subscriptions.impl.repository.FakeSubscriptionsDataStore @@ -36,6 +52,9 @@ import com.duckduckgo.subscriptions.impl.services.SubscriptionsService import com.duckduckgo.subscriptions.impl.services.ValidateTokenResponse import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore import java.lang.Exception +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -44,10 +63,13 @@ import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.* +import org.junit.Assume.assumeFalse +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.junit.runners.Parameterized import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -58,8 +80,8 @@ import org.mockito.kotlin.whenever import retrofit2.HttpException import retrofit2.Response -@RunWith(AndroidJUnit4::class) -class RealSubscriptionsManagerTest { +@RunWith(Parameterized::class) +class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { @get:Rule val coroutineRule = CoroutineTestRule() @@ -73,6 +95,15 @@ class RealSubscriptionsManagerTest { private val playBillingManager: PlayBillingManager = mock() private val context: Context = mock() private val pixelSender: SubscriptionPixelSender = mock() + + @SuppressLint("DenyListedApi") + private val privacyProFeature: PrivacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) + .apply { authApiV2().setRawStoredState(State(authApiV2Enabled)) } + private val authClient: AuthClient = mock() + private val pkceGenerator: PkceGenerator = PkceGeneratorImpl() + private val authJwtValidator: AuthJwtValidator = mock() + private val timeProvider = FakeTimeProvider() + private val backgroundTokenRefresh: BackgroundTokenRefresh = mock() private lateinit var subscriptionsManager: SubscriptionsManager @Before @@ -89,6 +120,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) } @@ -108,11 +145,18 @@ class RealSubscriptionsManagerTest { givenStoreLoginSucceeds() givenSubscriptionSucceedsWithEntitlements() givenAccessTokenSucceeds() + givenV2AccessTokenRefreshSucceeds() subscriptionsManager.recoverSubscriptionFromStore() as RecoverSubscriptionResult.Success - verify(authService).storeLogin(any()) - assertEquals("authToken", authDataStore.authToken) + if (authApiV2Enabled) { + verify(authClient).storeLogin(any(), any(), any()) + assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) + assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) + } else { + verify(authService).storeLogin(any()) + assertEquals("authToken", authDataStore.authToken) + } assertTrue(authRepository.getEntitlements().firstOrNull { it.product == NetP.value } != null) } @@ -144,6 +188,7 @@ class RealSubscriptionsManagerTest { givenStoreLoginSucceeds() givenSubscriptionSucceedsWithEntitlements() givenAccessTokenSucceeds() + givenV2AccessTokenRefreshSucceeds() subscriptionsManager.recoverSubscriptionFromStore() as RecoverSubscriptionResult.Success @@ -184,7 +229,12 @@ class RealSubscriptionsManagerTest { subscriptionsManager.recoverSubscriptionFromStore() subscriptionsManager.isSignedIn.test { assertTrue(awaitItem()) - assertEquals("accessToken", authDataStore.accessToken) + if (authApiV2Enabled) { + assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) + assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) + } else { + assertEquals("accessToken", authDataStore.accessToken) + } cancelAndConsumeRemainingEvents() } } @@ -200,6 +250,8 @@ class RealSubscriptionsManagerTest { @Test fun whenFetchAndStoreAllDataIfTokenIsValidThenReturnSubscription() = runTest { + assumeFalse(authApiV2Enabled) // fetchAndStoreAllData() is deprecated and won't be used with auth v2 enabled + givenUserIsSignedIn() givenSubscriptionSucceedsWithEntitlements() @@ -210,6 +262,8 @@ class RealSubscriptionsManagerTest { @Test fun whenFetchAndStoreAllDataIfTokenIsValidThenReturnEmitEntitlements() = runTest { + assumeFalse(authApiV2Enabled) // fetchAndStoreAllData() is deprecated and won't be used with auth v2 enabled + givenUserIsSignedIn() givenSubscriptionSucceedsWithEntitlements() @@ -230,6 +284,8 @@ class RealSubscriptionsManagerTest { @Test fun whenFetchAndStoreAllDataIfSubscriptionFailsWith401ThenSignOutAndReturnNull() = runTest { + assumeFalse(authApiV2Enabled) // fetchAndStoreAllData() is deprecated and won't be used with auth v2 enabled + givenUserIsSignedIn() givenSubscriptionFails(httpResponseCode = 401) @@ -246,14 +302,23 @@ class RealSubscriptionsManagerTest { @Test fun whenPurchaseFlowIfUserNotSignedInAndNotPurchaseStoredThenCreateAccount() = runTest { givenUserIsNotSignedIn() + givenCreateAccountSucceeds() subscriptionsManager.purchase(mock(), planId = "") - verify(authService).createAccount(any()) + if (authApiV2Enabled) { + verify(authClient).authorize(any()) + verify(authClient).createAccount(any()) + verify(authClient).getTokens(any(), any(), any()) + } else { + verify(authService).createAccount(any()) + } } @Test fun whenPurchaseFlowIfUserNotSignedInAndNotPurchaseStoredAndSignedInEmailThenCreateAccountWithEmailToken() = runTest { + assumeFalse(authApiV2Enabled) // passing email token when creating account is no longer a thing in api v2 + whenever(emailManager.getToken()).thenReturn("emailToken") givenUserIsNotSignedIn() @@ -334,6 +399,8 @@ class RealSubscriptionsManagerTest { @Test fun whenPurchaseFlowIfUserSignedInThenValidateToken() = runTest { + assumeFalse(authApiV2Enabled) // there is no /validate-token endpoint in v2 API + givenUserIsSignedIn() subscriptionsManager.purchase(mock(), planId = "") @@ -386,8 +453,17 @@ class RealSubscriptionsManagerTest { givenAccessTokenSucceeds() subscriptionsManager.purchase(mock(), planId = "") - assertEquals("accessToken", authDataStore.accessToken) - assertEquals("authToken", authDataStore.authToken) + if (authApiV2Enabled) { + assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) + assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) + assertNull(authDataStore.accessToken) + assertNull(authDataStore.authToken) + } else { + assertNull(authDataStore.accessTokenV2) + assertNull(authDataStore.refreshTokenV2) + assertEquals("accessToken", authDataStore.accessToken) + assertEquals("authToken", authDataStore.authToken) + } } @Test @@ -401,8 +477,17 @@ class RealSubscriptionsManagerTest { subscriptionsManager.purchase(mock(), planId = "") subscriptionsManager.isSignedIn.test { assertTrue(awaitItem()) - assertEquals("accessToken", authDataStore.accessToken) - assertEquals("authToken", authDataStore.authToken) + if (authApiV2Enabled) { + assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) + assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) + assertNull(authDataStore.accessToken) + assertNull(authDataStore.authToken) + } else { + assertNull(authDataStore.accessTokenV2) + assertNull(authDataStore.refreshTokenV2) + assertEquals("accessToken", authDataStore.accessToken) + assertEquals("authToken", authDataStore.authToken) + } cancelAndConsumeRemainingEvents() } } @@ -435,6 +520,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.subscriptionStatus.test { @@ -457,6 +548,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.subscriptionStatus.test { @@ -469,6 +566,7 @@ class RealSubscriptionsManagerTest { fun whenPurchaseSuccessfulThenPurchaseCheckedAndSuccessEmit() = runTest { givenUserIsSignedIn() givenConfirmPurchaseSucceeds() + givenV2AccessTokenRefreshSucceeds() val flowTest: MutableSharedFlow = MutableSharedFlow() whenever(playBillingManager.purchaseState).thenReturn(flowTest) @@ -483,6 +581,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.currentPurchaseState.test { @@ -524,6 +628,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.currentPurchaseState.test { @@ -555,6 +665,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.currentPurchaseState.test { @@ -571,7 +687,9 @@ class RealSubscriptionsManagerTest { val result = subscriptionsManager.getAccessToken() assertTrue(result is AccessTokenResult.Success) - assertEquals("accessToken", (result as AccessTokenResult.Success).accessToken) + val actualAccessToken = (result as AccessTokenResult.Success).accessToken + val expectedAccessToken = if (authApiV2Enabled) FAKE_ACCESS_TOKEN_V2 else "accessToken" + assertEquals(expectedAccessToken, actualAccessToken) } @Test @@ -583,6 +701,87 @@ class RealSubscriptionsManagerTest { assertTrue(result is AccessTokenResult.Failure) } + @Test + fun whenGetAccessTokenIfAccessTokenIsExpiredThenGetNewTokenAndReturnSuccess() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn() + givenAccessTokenIsExpired() + givenV2AccessTokenRefreshSucceeds(newAccessToken = "new access token") + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Success) + assertEquals("new access token", (result as AccessTokenResult.Success).accessToken) + } + + @Test + fun whenGetAccessTokenIfAccessTokenIsExpiredAndRefreshFailsThenGetNewTokenAndReturnFailure() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn() + givenAccessTokenIsExpired() + givenV2AccessTokenRefreshFails() + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Failure) + } + + @Test + fun whenGetAccessTokenIfAccessTokenIsExpiredAndRefreshFailsWithAuthErrorThenGetNewTokenUsingStoreLoginAndReturnSuccess() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn() + givenAccessTokenIsExpired() + givenV2AccessTokenRefreshFails(authenticationError = true) + givenPurchaseStored() + givenStoreLoginSucceeds(newAccessToken = "new access token") + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Success) + assertEquals("new access token", (result as AccessTokenResult.Success).accessToken) + } + + @Test + fun whenGetAccessTokenIfAccessTokenIsExpiredAndRefreshFailsWithAuthErrorAndStoreRecoveryNotPossibleThenSignOutAndReturnFailure() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn() + givenSubscriptionExists() + givenAccessTokenIsExpired() + givenV2AccessTokenRefreshFails(authenticationError = true) + givenPurchaseStored() + givenStoreLoginFails() + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Failure) + assertFalse(subscriptionsManager.isSignedIn()) + assertNull(authRepository.getAccessTokenV2()) + assertNull(authRepository.getRefreshTokenV2()) + assertNull(authRepository.getAccount()) + assertNull(authRepository.getSubscription()) + } + + @Test + fun whenGetAccessTokenIfSignedInWithV1ThenExchangesTokenForV2AndReturnsTrue() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn(useAuthV2 = false) + givenV1AccessTokenExchangeSuccess() + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Success) + assertEquals(FAKE_ACCESS_TOKEN_V2, (result as AccessTokenResult.Success).accessToken) + assertEquals(FAKE_ACCESS_TOKEN_V2, authRepository.getAccessTokenV2()?.jwt) + assertEquals(FAKE_REFRESH_TOKEN_V2, authRepository.getRefreshTokenV2()?.jwt) + assertNull(authRepository.getAccessToken()) + assertNull(authRepository.getAuthToken()) + } + @Test fun whenGetAuthTokenIfUserSignedInAndValidTokenThenReturnSuccess() = runTest { givenUserIsSignedIn() @@ -591,7 +790,10 @@ class RealSubscriptionsManagerTest { val result = subscriptionsManager.getAuthToken() assertTrue(result is AuthTokenResult.Success) - assertEquals("authToken", (result as AuthTokenResult.Success).authToken) + + val actualAuthToken = (result as AuthTokenResult.Success).authToken + val expectedAuthToken = if (authApiV2Enabled) FAKE_ACCESS_TOKEN_V2 else "authToken" + assertEquals(expectedAuthToken, actualAuthToken) } @Test @@ -605,6 +807,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInWithSubscriptionAndTokenExpiredAndEntitlementsExistsThenReturnSuccess() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + authDataStore.externalId = "1234" givenUserIsSignedIn() givenSubscriptionSucceedsWithEntitlements() @@ -622,6 +826,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInWithSubscriptionAndTokenExpiredAndEntitlementsExistsAndExternalIdDifferentThenReturnFailure() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + authDataStore.externalId = "test" givenUserIsSignedIn() givenSubscriptionSucceedsWithEntitlements() @@ -639,6 +845,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInWithSubscriptionAndTokenExpiredAndEntitlementsDoNotExistThenReturnFailure() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + givenUserIsSignedIn() givenValidateTokenSucceedsNoEntitlements() givenValidateTokenFailsAndThenSucceedsWithNoEntitlements("""{ "error": "expired_token" }""") @@ -655,6 +863,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInAndTokenExpiredAndNoPurchaseInTheStoreThenReturnFailure() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + givenUserIsSignedIn() givenValidateTokenFailsAndThenSucceeds("""{ "error": "expired_token" }""") @@ -667,6 +877,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInAndTokenExpiredAndPurchaseNotValidThenReturnFailure() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + givenUserIsSignedIn() givenValidateTokenFailsAndThenSucceeds("""{ "error": "expired_token" }""") givenStoreLoginFails() @@ -681,6 +893,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetSubscriptionThenReturnCorrectStatus() = runTest { + assumeFalse(authApiV2Enabled) // fetchAndStoreAllData() is deprecated and won't be used with auth v2 enabled + givenUserIsSignedIn() givenValidateTokenSucceedsWithEntitlements() @@ -745,12 +959,21 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.signOut() verify(mockRepo).setSubscription(null) verify(mockRepo).setAccount(null) verify(mockRepo).setAuthToken(null) verify(mockRepo).setAccessToken(null) + verify(mockRepo).setEntitlements(emptyList()) + verify(mockRepo).setAccessTokenV2(null) + verify(mockRepo).setRefreshTokenV2(null) } @Test @@ -781,6 +1004,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.subscriptionStatus.test { @@ -809,6 +1038,7 @@ class RealSubscriptionsManagerTest { givenUserIsSignedIn() givenValidateTokenSucceedsWithEntitlements() givenConfirmPurchaseSucceeds() + givenV2AccessTokenRefreshSucceeds() whenever(playBillingManager.purchaseState).thenReturn(flowOf(PurchaseState.Purchased("any", "any"))) @@ -884,44 +1114,54 @@ class RealSubscriptionsManagerTest { @Test fun whenGetSubscriptionOfferThenReturnValue() = runTest { - val productDetails: ProductDetails = mock { productDetails -> - whenever(productDetails.productId).thenReturn(SubscriptionsConstants.BASIC_SUBSCRIPTION) - - val pricingPhaseList: List = listOf( - mock { pricingPhase -> - whenever(pricingPhase.formattedPrice).thenReturn("1$") - }, - ) + authRepository.setFeatures(MONTHLY_PLAN_US, setOf(NETP)) + givenPlansAvailable(MONTHLY_PLAN_US, YEARLY_PLAN_US) - val pricingPhases: PricingPhases = mock { pricingPhases -> - whenever(pricingPhases.pricingPhaseList).thenReturn(pricingPhaseList) - } + val subscriptionOffer = subscriptionsManager.getSubscriptionOffer()!! - val monthlyOffer: ProductDetails.SubscriptionOfferDetails = mock { offer -> - whenever(offer.basePlanId).thenReturn(MONTHLY_PLAN) - whenever(offer.pricingPhases).thenReturn(pricingPhases) - } + with(subscriptionOffer) { + assertEquals(MONTHLY_PLAN_US, monthlyPlanId) + assertEquals("1$", monthlyFormattedPrice) + assertEquals(YEARLY_PLAN_US, yearlyPlanId) + assertEquals("1$", yearlyFormattedPrice) + assertEquals(setOf(NETP), features) + } + } - val yearlyOffer: ProductDetails.SubscriptionOfferDetails = mock { offer -> - whenever(offer.basePlanId).thenReturn(YEARLY_PLAN) - whenever(offer.pricingPhases).thenReturn(pricingPhases) - } + @Test + fun whenGetSubscriptionOfferAndNoFeaturesThenReturnNull() = runTest { + authRepository.setFeatures(MONTHLY_PLAN_US, emptySet()) + givenPlansAvailable(MONTHLY_PLAN_US, YEARLY_PLAN_US) - whenever(productDetails.subscriptionOfferDetails).thenReturn(listOf(monthlyOffer, yearlyOffer)) - } + assertNull(subscriptionsManager.getSubscriptionOffer()) + } - whenever(playBillingManager.products).thenReturn(listOf(productDetails)) + @Test + fun whenGetSubscriptionOfferAndRowPlansAvailableThenReturnValue() = runTest { + authRepository.setFeatures(MONTHLY_PLAN_ROW, setOf(NETP)) + givenPlansAvailable(MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW) + givenIsLaunchedRow(true) val subscriptionOffer = subscriptionsManager.getSubscriptionOffer()!! with(subscriptionOffer) { - assertEquals(MONTHLY_PLAN, monthlyPlanId) + assertEquals(MONTHLY_PLAN_ROW, monthlyPlanId) assertEquals("1$", monthlyFormattedPrice) - assertEquals(YEARLY_PLAN, yearlyPlanId) + assertEquals(YEARLY_PLAN_ROW, yearlyPlanId) assertEquals("1$", yearlyFormattedPrice) + assertEquals(setOf(NETP), features) } } + @Test + fun whenGetSubscriptionAndRowPlansAvailableAndFeatureDisabledThenReturnNull() = runTest { + authRepository.setFeatures(MONTHLY_PLAN_US, emptySet()) + givenPlansAvailable(MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW) + givenIsLaunchedRow(false) + + assertNull(subscriptionsManager.getSubscriptionOffer()) + } + @Test fun whenCanSupportEncryptionThenReturnTrue() = runTest { assertTrue(subscriptionsManager.canSupportEncryption()) @@ -941,6 +1181,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) assertFalse(subscriptionsManager.canSupportEncryption()) @@ -989,7 +1235,7 @@ class RealSubscriptionsManagerTest { givenValidateTokenSucceedsNoEntitlements() whenever(subscriptionsService.subscription()).thenReturn( SubscriptionResponse( - productId = MONTHLY_PLAN, + productId = MONTHLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1234, platform = "android", @@ -1002,7 +1248,7 @@ class RealSubscriptionsManagerTest { givenValidateTokenSucceedsWithEntitlements() whenever(subscriptionsService.subscription()).thenReturn( SubscriptionResponse( - productId = MONTHLY_PLAN, + productId = MONTHLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1234, platform = "android", @@ -1014,16 +1260,31 @@ class RealSubscriptionsManagerTest { private fun givenUserIsNotSignedIn() { authDataStore.accessToken = null authDataStore.authToken = null - } - - private fun givenUserIsSignedIn() { - authDataStore.accessToken = "accessToken" - authDataStore.authToken = "authToken" + authDataStore.accessTokenV2 = null + authDataStore.accessTokenV2ExpiresAt = null + authDataStore.refreshTokenV2 = null + authDataStore.refreshTokenV2ExpiresAt = null + } + + private fun givenUserIsSignedIn(useAuthV2: Boolean = authApiV2Enabled) { + if (useAuthV2) { + authDataStore.accessTokenV2 = FAKE_ACCESS_TOKEN_V2 + authDataStore.accessTokenV2ExpiresAt = timeProvider.currentTime + Duration.ofHours(4) + authDataStore.refreshTokenV2 = FAKE_REFRESH_TOKEN_V2 + authDataStore.refreshTokenV2ExpiresAt = timeProvider.currentTime + Duration.ofDays(30) + authDataStore.externalId = "1234" + } else { + authDataStore.accessToken = "accessToken" + authDataStore.authToken = "authToken" + } } private suspend fun givenCreateAccountFails() { val exception = "account_failure".toResponseBody("text/json".toMediaTypeOrNull()) whenever(authService.createAccount(any())).thenThrow(HttpException(Response.error(400, exception))) + + whenever(authClient.authorize(any())).thenThrow(HttpException(Response.error(400, exception))) + whenever(authClient.createAccount(any())).thenThrow(HttpException(Response.error(400, exception))) } private suspend fun givenCreateAccountSucceeds() { @@ -1034,6 +1295,13 @@ class RealSubscriptionsManagerTest { status = "ok", ), ) + + whenever(authClient.authorize(any())).thenReturn("fake session id") + whenever(authClient.createAccount(any())).thenReturn("fake authorization code") + whenever(authClient.getTokens(any(), any(), any())) + .thenReturn(TokenPair(FAKE_ACCESS_TOKEN_V2, FAKE_REFRESH_TOKEN_V2)) + + givenValidateV2TokensSucceeds() } private fun givenSubscriptionExists(status: SubscriptionStatus = AUTO_RENEWABLE) { @@ -1080,6 +1348,9 @@ class RealSubscriptionsManagerTest { private suspend fun givenStoreLoginFails() { val exception = "failure".toResponseBody("text/json".toMediaTypeOrNull()) whenever(authService.storeLogin(any())).thenThrow(HttpException(Response.error(400, exception))) + + whenever(authClient.authorize(any())).thenThrow(HttpException(Response.error(400, exception))) + whenever(authClient.storeLogin(any(), any(), any())).thenThrow(HttpException(Response.error(400, exception))) } private suspend fun givenValidateTokenSucceedsWithEntitlements() { @@ -1124,7 +1395,7 @@ class RealSubscriptionsManagerTest { whenever(playBillingManager.purchaseHistory).thenReturn(listOf(purchaseRecord)) } - private suspend fun givenStoreLoginSucceeds() { + private suspend fun givenStoreLoginSucceeds(newAccessToken: String = FAKE_ACCESS_TOKEN_V2) { whenever(authService.storeLogin(any())).thenReturn( StoreLoginResponse( authToken = "authToken", @@ -1133,6 +1404,22 @@ class RealSubscriptionsManagerTest { status = "ok", ), ) + + whenever(authClient.authorize(any())).thenReturn("fake session id") + whenever(authClient.storeLogin(any(), any(), any())).thenReturn("fake authorization code") + whenever(authClient.getTokens(any(), any(), any())) + .thenReturn(TokenPair(newAccessToken, FAKE_REFRESH_TOKEN_V2)) + whenever(authClient.getJwks()).thenReturn("fake jwks") + + givenValidateV2TokensSucceeds() + } + + private suspend fun givenV1AccessTokenExchangeSuccess() { + whenever(authClient.authorize(any())).thenReturn("fake session id") + whenever(authClient.exchangeV1AccessToken(any(), any())).thenReturn("fake authorization code") + whenever(authClient.getTokens(any(), any(), any())).thenReturn(TokenPair(FAKE_ACCESS_TOKEN_V2, FAKE_REFRESH_TOKEN_V2)) + whenever(authClient.getJwks()).thenReturn("fake jwks") + givenValidateV2TokensSucceeds() } private suspend fun givenAccessTokenSucceeds() { @@ -1166,4 +1453,97 @@ class RealSubscriptionsManagerTest { ), ) } + + private suspend fun givenV2AccessTokenRefreshSucceeds( + newAccessToken: String = FAKE_ACCESS_TOKEN_V2, + newRefreshToken: String = FAKE_REFRESH_TOKEN_V2, + ) { + whenever(authClient.getTokens(any())) + .thenReturn(TokenPair(newAccessToken, newRefreshToken)) + whenever(authClient.getJwks()).thenReturn("fake jwks") + + givenValidateV2TokensSucceeds() + } + + private suspend fun givenV2AccessTokenRefreshFails(authenticationError: Boolean = false) { + val exception = if (authenticationError) { + val responseBody = "failure".toResponseBody("text/json".toMediaTypeOrNull()) + HttpException(Response.error(401, responseBody)) + } else { + RuntimeException() + } + whenever(authClient.getTokens(any())).thenThrow(exception) + } + + private suspend fun givenValidateV2TokensSucceeds() { + whenever(authClient.getJwks()).thenReturn("fake jwks") + + whenever(authJwtValidator.validateAccessToken(any(), any())).thenReturn( + AccessTokenClaims( + expiresAt = Instant.now() + Duration.ofHours(4), + accountExternalId = "1234", + email = null, + entitlements = listOf(Entitlement(product = NetP.value, name = "subscriber")), + ), + ) + + whenever(authJwtValidator.validateRefreshToken(any(), any())).thenReturn( + RefreshTokenClaims( + expiresAt = Instant.now() + Duration.ofDays(30), + accountExternalId = "1234", + ), + ) + } + + private suspend fun givenAccessTokenIsExpired() { + val accessToken = authRepository.getAccessTokenV2() ?: return + authRepository.setAccessTokenV2(accessToken.copy(expiresAt = timeProvider.currentTime - Duration.ofHours(1))) + } + + private fun givenPlansAvailable(vararg basePlanIds: String) { + val productDetails: ProductDetails = mock { productDetails -> + whenever(productDetails.productId).thenReturn(SubscriptionsConstants.BASIC_SUBSCRIPTION) + + val pricingPhaseList: List = listOf( + mock { pricingPhase -> whenever(pricingPhase.formattedPrice).thenReturn("1$") }, + ) + + val pricingPhases: PricingPhases = mock { pricingPhases -> + whenever(pricingPhases.pricingPhaseList).thenReturn(pricingPhaseList) + } + + val offers = basePlanIds.map { basePlanId -> + mock { offer -> + whenever(offer.basePlanId).thenReturn(basePlanId) + whenever(offer.pricingPhases).thenReturn(pricingPhases) + } + } + + whenever(productDetails.subscriptionOfferDetails).thenReturn(offers) + } + + whenever(playBillingManager.products).thenReturn(listOf(productDetails)) + } + + @SuppressLint("DenyListedApi") + private fun givenIsLaunchedRow(value: Boolean) { + privacyProFeature.isLaunchedROW().setRawStoredState(State(remoteEnableState = value)) + } + + private class FakeTimeProvider : CurrentTimeProvider { + var currentTime: Instant = Instant.parse("2024-10-28T00:00:00Z") + + override fun elapsedRealtime(): Long = throw UnsupportedOperationException() + override fun currentTimeMillis(): Long = currentTime.toEpochMilli() + override fun localDateTimeNow(): LocalDateTime = throw UnsupportedOperationException() + } + + private companion object { + @JvmStatic + @Parameterized.Parameters(name = "authApiV2Enabled={0}") + fun data(): Collection> = listOf(arrayOf(true), arrayOf(false)) + + const val FAKE_ACCESS_TOKEN_V2 = "fake access token" + const val FAKE_REFRESH_TOKEN_V2 = "fake refresh token" + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsTest.kt index e756ce6c25e1..bd1462c68e29 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsTest.kt @@ -105,7 +105,13 @@ class RealSubscriptionsTest { fun whenIsEligibleIfOffersReturnedThenReturnTrueRegardlessOfStatus() = runTest { whenever(mockSubscriptionsManager.subscriptionStatus()).thenReturn(UNKNOWN) whenever(mockSubscriptionsManager.getSubscriptionOffer()).thenReturn( - SubscriptionOffer(monthlyPlanId = "test", yearlyFormattedPrice = "test", yearlyPlanId = "test", monthlyFormattedPrice = "test"), + SubscriptionOffer( + monthlyPlanId = "test", + yearlyFormattedPrice = "test", + yearlyPlanId = "test", + monthlyFormattedPrice = "test", + features = setOf(SubscriptionsConstants.NETP), + ), ) assertTrue(subscriptions.isEligible()) } @@ -136,7 +142,13 @@ class RealSubscriptionsTest { whenever(mockSubscriptionsManager.canSupportEncryption()).thenReturn(false) whenever(mockSubscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) whenever(mockSubscriptionsManager.getSubscriptionOffer()).thenReturn( - SubscriptionOffer(monthlyPlanId = "test", yearlyFormattedPrice = "test", yearlyPlanId = "test", monthlyFormattedPrice = "test"), + SubscriptionOffer( + monthlyPlanId = "test", + yearlyFormattedPrice = "test", + yearlyPlanId = "test", + monthlyFormattedPrice = "test", + features = setOf(SubscriptionsConstants.NETP), + ), ) assertTrue(subscriptions.isEligible()) } @@ -146,7 +158,13 @@ class RealSubscriptionsTest { whenever(mockSubscriptionsManager.canSupportEncryption()).thenReturn(false) whenever(mockSubscriptionsManager.subscriptionStatus()).thenReturn(UNKNOWN) whenever(mockSubscriptionsManager.getSubscriptionOffer()).thenReturn( - SubscriptionOffer(monthlyPlanId = "test", yearlyFormattedPrice = "test", yearlyPlanId = "test", monthlyFormattedPrice = "test"), + SubscriptionOffer( + monthlyPlanId = "test", + yearlyFormattedPrice = "test", + yearlyPlanId = "test", + monthlyFormattedPrice = "test", + features = setOf(SubscriptionsConstants.NETP), + ), ) assertFalse(subscriptions.isEligible()) } @@ -154,7 +172,13 @@ class RealSubscriptionsTest { @Test fun whenShouldLaunchPrivacyProForUrlThenReturnCorrectValue() = runTest { whenever(mockSubscriptionsManager.getSubscriptionOffer()).thenReturn( - SubscriptionOffer(monthlyPlanId = "test", yearlyFormattedPrice = "test", yearlyPlanId = "test", monthlyFormattedPrice = "test"), + SubscriptionOffer( + monthlyPlanId = "test", + yearlyFormattedPrice = "test", + yearlyPlanId = "test", + monthlyFormattedPrice = "test", + features = setOf(SubscriptionsConstants.NETP), + ), ) whenever(mockSubscriptionsManager.subscriptionStatus()).thenReturn(UNKNOWN) @@ -171,7 +195,13 @@ class RealSubscriptionsTest { @Test fun whenShouldLaunchPrivacyProForUrlThenReturnTrue() = runTest { whenever(mockSubscriptionsManager.getSubscriptionOffer()).thenReturn( - SubscriptionOffer(monthlyPlanId = "test", yearlyFormattedPrice = "test", yearlyPlanId = "test", monthlyFormattedPrice = "test"), + SubscriptionOffer( + monthlyPlanId = "test", + yearlyFormattedPrice = "test", + yearlyPlanId = "test", + monthlyFormattedPrice = "test", + features = setOf(SubscriptionsConstants.NETP), + ), ) whenever(mockSubscriptionsManager.subscriptionStatus()).thenReturn(UNKNOWN) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcherTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcherTest.kt new file mode 100644 index 000000000000..9d0e48b3eae5 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcherTest.kt @@ -0,0 +1,148 @@ +package com.duckduckgo.subscriptions.impl + +import android.annotation.SuppressLint +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.testing.TestLifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetails.PricingPhase +import com.android.billingclient.api.ProductDetails.PricingPhases +import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ITR +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.NETP +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US +import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager +import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import com.duckduckgo.subscriptions.impl.services.FeaturesResponse +import com.duckduckgo.subscriptions.impl.services.SubscriptionsService +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class SubscriptionFeaturesFetcherTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val processLifecycleOwner = TestLifecycleOwner(initialState = INITIALIZED) + private val playBillingManager: PlayBillingManager = mock() + private val subscriptionsService: SubscriptionsService = mock() + private val authRepository: AuthRepository = mock() + private val privacyProFeature: PrivacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) + + private val subscriptionFeaturesFetcher = SubscriptionFeaturesFetcher( + appCoroutineScope = coroutineRule.testScope, + playBillingManager = playBillingManager, + subscriptionsService = subscriptionsService, + authRepository = authRepository, + privacyProFeature = privacyProFeature, + dispatcherProvider = coroutineRule.testDispatcherProvider, + ) + + @Before + fun setUp() { + processLifecycleOwner.lifecycle.addObserver(subscriptionFeaturesFetcher) + } + + @Test + fun `when FF disabled then does not do anything`() = runTest { + givenIsFeaturesApiEnabled(false) + + processLifecycleOwner.currentState = CREATED + + verifyNoInteractions(playBillingManager) + verifyNoInteractions(authRepository) + verifyNoInteractions(subscriptionsService) + } + + @Test + fun `when products loaded then fetches and stores features`() = runTest { + givenIsFeaturesApiEnabled(true) + val productDetails = mockProductDetails() + whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails)) + whenever(authRepository.getFeatures(any())).thenReturn(emptySet()) + whenever(subscriptionsService.features(any())).thenReturn(FeaturesResponse(listOf(NETP, ITR))) + + processLifecycleOwner.currentState = CREATED + + verify(playBillingManager).productsFlow + verify(authRepository).getFeatures(MONTHLY_PLAN_US) + verify(authRepository).getFeatures(YEARLY_PLAN_US) + verify(authRepository).setFeatures(MONTHLY_PLAN_US, setOf(NETP, ITR)) + verify(authRepository).setFeatures(YEARLY_PLAN_US, setOf(NETP, ITR)) + } + + @Test + fun `when there are no products then does not store anything`() = runTest { + givenIsFeaturesApiEnabled(true) + whenever(playBillingManager.productsFlow).thenReturn(flowOf()) + + processLifecycleOwner.currentState = CREATED + + verify(playBillingManager).productsFlow + verifyNoInteractions(authRepository) + verifyNoInteractions(subscriptionsService) + } + + @Test + fun `when features already stored then does not fetch again`() = runTest { + givenIsFeaturesApiEnabled(true) + val productDetails = mockProductDetails() + whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails)) + whenever(authRepository.getFeatures(any())).thenReturn(setOf(NETP, ITR)) + + processLifecycleOwner.currentState = CREATED + + verify(playBillingManager).productsFlow + verify(authRepository).getFeatures(MONTHLY_PLAN_US) + verify(authRepository).getFeatures(YEARLY_PLAN_US) + verify(authRepository, never()).setFeatures(any(), any()) + verifyNoInteractions(subscriptionsService) + } + + @SuppressLint("DenyListedApi") + private fun givenIsFeaturesApiEnabled(value: Boolean) { + privacyProFeature.featuresApi().setRawStoredState(State(value)) + } + + private fun mockProductDetails(): List { + val productDetails: ProductDetails = mock { productDetails -> + whenever(productDetails.productId).thenReturn(SubscriptionsConstants.BASIC_SUBSCRIPTION) + + val pricingPhaseList: List = listOf( + mock { pricingPhase -> whenever(pricingPhase.formattedPrice).thenReturn("1$") }, + ) + + val pricingPhases: PricingPhases = mock { pricingPhases -> + whenever(pricingPhases.pricingPhaseList).thenReturn(pricingPhaseList) + } + + val offers = listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US) + .map { basePlanId -> + mock { offer -> + whenever(offer.basePlanId).thenReturn(basePlanId) + whenever(offer.pricingPhases).thenReturn(pricingPhases) + } + } + + whenever(productDetails.subscriptionOfferDetails).thenReturn(offers) + } + + return listOf(productDetails) + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionsToggleTargetMatcherPluginTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionsToggleTargetMatcherPluginTest.kt new file mode 100644 index 000000000000..7e1dcb8cca03 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionsToggleTargetMatcherPluginTest.kt @@ -0,0 +1,64 @@ +package com.duckduckgo.subscriptions.impl + +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.subscriptions.api.Subscriptions +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SubscriptionsToggleTargetMatcherPluginTest { + + private val subscriptions: Subscriptions = mock() + private val matcher = SubscriptionsToggleTargetMatcherPlugin(subscriptions) + + @Test + fun whenIsEligibleAndNullTargetThenReturnTrue() = runTest { + whenever(subscriptions.isEligible()).thenReturn(true) + + assertTrue(matcher.matchesTargetProperty(NULL_TARGET)) + } + + @Test + fun whenIsNotEligibleAndNullTargetThenReturnTrue() = runTest { + whenever(subscriptions.isEligible()).thenReturn(false) + + assertTrue(matcher.matchesTargetProperty(NULL_TARGET)) + } + + @Test + fun whenIsEligibleAndAndTargetMatchesThenTrue() = runTest { + whenever(subscriptions.isEligible()).thenReturn(true) + + assertTrue(matcher.matchesTargetProperty(ELIGIBLE_TARGET)) + } + + @Test + fun whenIsNotEligibleAndAndTargetMatchesThenTrue() = runTest { + whenever(subscriptions.isEligible()).thenReturn(false) + + assertTrue(matcher.matchesTargetProperty(NOT_ELIGIBLE_TARGET)) + } + + @Test + fun whenIsEligibleAndAndTargetNotMatchingThenTrue() = runTest { + whenever(subscriptions.isEligible()).thenReturn(true) + + assertFalse(matcher.matchesTargetProperty(NOT_ELIGIBLE_TARGET)) + } + + @Test + fun whenIsNotEligibleAndAndTargetNotMatchingThenTrue() = runTest { + whenever(subscriptions.isEligible()).thenReturn(false) + + assertFalse(matcher.matchesTargetProperty(ELIGIBLE_TARGET)) + } + + companion object { + private val NULL_TARGET = Toggle.State.Target(null, null, null, null, null) + private val ELIGIBLE_TARGET = NULL_TARGET.copy(isPrivacyProEligible = true) + private val NOT_ELIGIBLE_TARGET = NULL_TARGET.copy(isPrivacyProEligible = false) + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthClientImplTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthClientImplTest.kt new file mode 100644 index 000000000000..812a5c42b58b --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthClientImplTest.kt @@ -0,0 +1,267 @@ +package com.duckduckgo.subscriptions.impl.auth2 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import kotlinx.coroutines.test.runTest +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import retrofit2.HttpException +import retrofit2.Response + +@RunWith(AndroidJUnit4::class) +class AuthClientImplTest { + + private val authService: AuthService = mock() + private val appBuildConfig: AppBuildConfig = mock { config -> + whenever(config.applicationId).thenReturn("com.duckduckgo.android") + } + private val authClient = AuthClientImpl(authService, appBuildConfig) + + @Test + fun `when authorize success then returns sessionId parsed from Set-Cookie header`() = runTest { + val sessionId = "fake auth session id" + val codeChallenge = "fake code challenge" + + val mockResponse: Response = mock { + on { code() } doReturn 302 + on { headers() } doReturn Headers.headersOf("Set-Cookie", "ddg_auth_session_id=$sessionId; Path=/") + on { isSuccessful } doReturn false // Retrofit treats non-2xx responses as unsuccessful + } + + whenever(authService.authorize(any(), any(), any(), any(), any(), any())) + .thenReturn(mockResponse) + + val receivedSessionId = authClient.authorize(codeChallenge) + assertEquals(sessionId, receivedSessionId) + + verify(authService).authorize( + responseType = "code", + codeChallenge = codeChallenge, + codeChallengeMethod = "S256", + clientId = "f4311287-0121-40e6-8bbd-85c36daf1837", + redirectUri = "com.duckduckgo:/authcb", + scope = "privacypro", + ) + } + + @Test + fun `when authorize responds with non-302 code then throws HttpException`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.authorize(any(), any(), any(), any(), any(), any())) + .thenReturn(errorResponse) + + try { + authClient.authorize("fake code challenge") + fail("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertEquals(400, e.code()) + } + } + + @Test + fun `when createAccount success then returns authorization code parsed from Location header`() = runTest { + val authorizationCode = "fake_authorization_code" + + val mockResponse: Response = mock { + on { code() } doReturn 302 + on { headers() } doReturn Headers.headersOf("Location", "https://example.com?code=$authorizationCode") + on { isSuccessful } doReturn false // Retrofit treats non-2xx responses as unsuccessful + } + + whenever(authService.createAccount(any())).thenReturn(mockResponse) + + assertEquals(authorizationCode, authClient.createAccount("fake auth session id")) + } + + @Test + fun `when createAccount HTTP error then throws HttpException`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.createAccount(any())).thenReturn(errorResponse) + + try { + authClient.createAccount("fake auth session id") + fail("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertEquals(400, e.code()) + } + } + + @Test + fun `when getTokens success then returns AuthenticationCredentials`() = runTest { + val sessionId = "fake auth session id" + val authorizationCode = "fake authorization code" + val codeVerifier = "fake code verifier" + val accessToken = "fake access token" + val refreshToken = "fake refresh token" + + whenever(authService.token(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(TokensResponse(accessToken, refreshToken)) + + val tokens = authClient.getTokens(sessionId, authorizationCode, codeVerifier) + + assertEquals(accessToken, tokens.accessToken) + assertEquals(refreshToken, tokens.refreshToken) + + verify(authService).token( + grantType = "authorization_code", + clientId = "f4311287-0121-40e6-8bbd-85c36daf1837", + codeVerifier = codeVerifier, + code = authorizationCode, + redirectUri = "com.duckduckgo:/authcb", + refreshToken = null, + ) + } + + @Test + fun `when getTokens with refresh token then return AuthenticationCredentials`() = runTest { + val accessToken = "fake access token" + val refreshToken = "fake refresh token" + + whenever(authService.token(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(TokensResponse(accessToken, refreshToken)) + + val credentials = authClient.getTokens("fake refresh token") + + assertEquals(accessToken, credentials.accessToken) + assertEquals(refreshToken, credentials.refreshToken) + + verify(authService).token( + grantType = "refresh_token", + clientId = "f4311287-0121-40e6-8bbd-85c36daf1837", + codeVerifier = null, + code = null, + redirectUri = null, + refreshToken = refreshToken, + ) + } + + @Test + fun `when getJwks then return JWK set in JSON format`() = runTest { + val jwks = """{"keys": [{"kty": "RSA", "kid": "fakeKeyId"}]}""" + val jwksResponse = jwks.toResponseBody("application/json".toMediaTypeOrNull()) + + whenever(authService.jwks()).thenReturn(jwksResponse) + + assertEquals(jwks, authClient.getJwks()) + } + + @Test + fun `when login success then returns authorization code`() = runTest { + val sessionId = "fake auth session id" + val authorizationCode = "fake_authorization_code" + val signature = "fake signature" + val googleSignedData = "fake signed data" + + val mockResponse: Response = mock { + on { code() } doReturn 302 + on { headers() } doReturn Headers.headersOf("Location", "https://example.com?code=$authorizationCode") + on { isSuccessful } doReturn false // Retrofit treats non-2xx responses as unsuccessful + } + + whenever(authService.login(any(), any())).thenReturn(mockResponse) + + val storeLoginResponse = authClient.storeLogin(sessionId, signature, googleSignedData) + + assertEquals(authorizationCode, storeLoginResponse) + + verify(authService).login( + cookie = "ddg_auth_session_id=$sessionId", + body = StoreLoginBody( + method = "signature", + signature = signature, + source = "google_play_store", + googleSignedData = googleSignedData, + googlePackageName = appBuildConfig.applicationId, + ), + ) + } + + @Test + fun `when login HTTP error then throws HttpException`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.login(any(), any())).thenReturn(errorResponse) + + try { + authClient.storeLogin("fake auth session id", "fake signature", "fake signed data") + fail("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertEquals(400, e.code()) + } + } + + @Test + fun `when exchange token success then returns authorization code`() = runTest { + val sessionId = "fake auth session id" + val authorizationCode = "fake_authorization_code" + val accessTokenV1 = "fake v1 access token" + + val mockResponse: Response = mock { + on { code() } doReturn 302 + on { headers() } doReturn Headers.headersOf("Location", "https://example.com?code=$authorizationCode") + on { isSuccessful } doReturn false // Retrofit treats non-2xx responses as unsuccessful + } + + whenever(authService.exchange(any(), any())).thenReturn(mockResponse) + + val response = authClient.exchangeV1AccessToken(accessTokenV1, sessionId) + + assertEquals(authorizationCode, response) + + verify(authService).exchange( + authorization = "Bearer $accessTokenV1", + cookie = "ddg_auth_session_id=$sessionId", + ) + } + + @Test + fun `when exchange token HTTP error then throws HttpException`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.exchange(any(), any())).thenReturn(errorResponse) + + try { + authClient.exchangeV1AccessToken("fake v1 access token", "fake auth session id") + fail("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertEquals(400, e.code()) + } + } + + @Test + fun `when logout error then does not throw any exception`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.logout(any())).thenThrow(HttpException(errorResponse)) + + authClient.tryLogout("fake v2 access token") + } +} diff --git a/auth-jwt/auth-jwt-impl/src/test/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImplTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImplTest.kt similarity index 95% rename from auth-jwt/auth-jwt-impl/src/test/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImplTest.kt rename to subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImplTest.kt index 2477ba741833..b4da57135b22 100644 --- a/auth-jwt/auth-jwt-impl/src/test/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImplTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImplTest.kt @@ -1,8 +1,24 @@ -package com.duckduckgo.authjwt.impl +/* + * 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.subscriptions.impl.auth2 import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.authjwt.api.Entitlement import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.subscriptions.impl.model.Entitlement import java.time.Instant import java.time.LocalDateTime import org.junit.Assert.assertEquals diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/PkceGeneratorImplTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/PkceGeneratorImplTest.kt new file mode 100644 index 000000000000..4ad13c3f873c --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/PkceGeneratorImplTest.kt @@ -0,0 +1,17 @@ +package com.duckduckgo.subscriptions.impl.auth2 + +import org.junit.Assert.* +import org.junit.Test + +class PkceGeneratorImplTest { + + @Test + fun `should generate correct code challenge`() { + val codeVerifier = "oas6Ov1EcKzKjM-w9Q97cs6bYDU1cCI_hQwhAt0mLiE" + + assertEquals( + "JP1GpQFSca0OUo-5Xxe5fzu2K_Sa84q2yCeHb-bw1zM", + PkceGeneratorImpl().generateCodeChallenge(codeVerifier), + ) + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt index b68a997be6af..323efd7f92d1 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt @@ -11,8 +11,8 @@ import com.android.billingclient.api.PurchaseHistoryRecord import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LIST_OF_PRODUCTS -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.billing.BillingError.BILLING_UNAVAILABLE import com.duckduckgo.subscriptions.impl.billing.BillingError.NETWORK_ERROR import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.Connect @@ -105,7 +105,7 @@ class RealPlayBillingManagerTest { subject.purchaseState.test { expectNoEvents() - subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN, externalId) + subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN_US, externalId) assertEquals(InProgress, awaitItem()) } @@ -126,7 +126,7 @@ class RealPlayBillingManagerTest { subject.purchaseState.test { expectNoEvents() - subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN, externalId) + subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN_US, externalId) assertEquals(Canceled, awaitItem()) } @@ -194,12 +194,12 @@ class FakeBillingClientAdapter : BillingClientAdapter { whenever(it.productId).thenReturn(BASIC_SUBSCRIPTION) val monthlyOffer: ProductDetails.SubscriptionOfferDetails = mock { offer -> - whenever(offer.basePlanId).thenReturn(MONTHLY_PLAN) + whenever(offer.basePlanId).thenReturn(MONTHLY_PLAN_US) whenever(offer.offerToken).thenReturn("monthly_offer_token") } val yearlyOffer: ProductDetails.SubscriptionOfferDetails = mock { offer -> - whenever(offer.basePlanId).thenReturn(YEARLY_PLAN) + whenever(offer.basePlanId).thenReturn(YEARLY_PLAN_US) whenever(offer.offerToken).thenReturn("yearly_offer_token") } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt index 100463820058..3c055c0d2b5f 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt @@ -628,6 +628,62 @@ class SubscriptionMessagingInterfaceTest { verify(jsMessageCallback).process(eq("useSubscription"), eq("subscriptionsWelcomeAddEmailClicked"), any(), any()) } + @Test + fun whenProcessAndAddEmailSuccessAnIsSignedInUsingAuthV2AThenAccessTokenIsRefreshed() = runTest { + givenInterfaceIsRegistered() + whenever(subscriptionsManager.isSignedInV2()).thenReturn(true) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsAddEmailSuccess","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager).refreshAccessToken() + } + + @Test + fun whenProcessAndEditEmailSuccessAnIsSignedInUsingAuthV2AThenAccessTokenIsRefreshed() = runTest { + givenInterfaceIsRegistered() + whenever(subscriptionsManager.isSignedInV2()).thenReturn(true) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsEditEmailSuccess","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager).refreshAccessToken() + } + + @Test + fun whenProcessAndAddEmailSuccessAnIsNotSignedInUsingAuthV2AThenAccessTokenIsNotRefreshed() = runTest { + givenInterfaceIsRegistered() + whenever(subscriptionsManager.isSignedInV2()).thenReturn(false) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsAddEmailSuccess","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager, never()).refreshAccessToken() + } + + @Test + fun whenProcessAndEditEmailSuccessAnIsNotSignedInUsingAuthV2AThenAccessTokenIsNotRefreshed() = runTest { + givenInterfaceIsRegistered() + whenever(subscriptionsManager.isSignedInV2()).thenReturn(false) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsEditEmailSuccess","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager, never()).refreshAccessToken() + } + private fun givenInterfaceIsRegistered() { messagingInterface.register(webView, callback) whenever(webView.url).thenReturn("https://duckduckgo.com/test") diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt index 6f98a57226ff..622d55e4262f 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt @@ -17,10 +17,15 @@ package com.duckduckgo.subscriptions.impl.repository import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore +import java.time.Instant class FakeSubscriptionsDataStore(private val supportEncryption: Boolean = true) : SubscriptionsDataStore { // Auth + override var accessTokenV2: String? = null + override var accessTokenV2ExpiresAt: Instant? = null + override var refreshTokenV2: String? = null + override var refreshTokenV2ExpiresAt: Instant? = null override var accessToken: String? = null override var authToken: String? = null override var email: String? = null @@ -34,4 +39,5 @@ class FakeSubscriptionsDataStore(private val supportEncryption: Boolean = true) override var entitlements: String? = null override var productId: String? = null override fun canUseEncryption(): Boolean = supportEncryption + override var subscriptionFeatures: String? = null } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt index 1cc33824a8b2..de3305765a38 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt @@ -9,6 +9,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING import com.duckduckgo.subscriptions.impl.serp_promo.FakeSerpPromo +import java.time.Instant import kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Rule @@ -80,12 +81,23 @@ class RealAuthRepositoryTest { fun whenTokensThenReturnTokens() = runTest { assertNull(authStore.authToken) assertNull(authStore.accessToken) + assertNull(authStore.accessTokenV2) + assertNull(authStore.accessTokenV2ExpiresAt) + assertNull(authStore.refreshTokenV2) + assertNull(authStore.refreshTokenV2ExpiresAt) authStore.accessToken = "accessToken" authStore.authToken = "authToken" + val accessTokenV2 = AccessToken(jwt = "jwt-access", expiresAt = Instant.parse("2024-10-21T10:15:30.00Z")) + val refreshTokenV2 = RefreshToken(jwt = "jwt-refresh", expiresAt = Instant.parse("2024-10-21T10:15:30.00Z")) + authRepository.setAccessTokenV2(accessTokenV2) + authRepository.setRefreshTokenV2(refreshTokenV2) + assertEquals("authToken", authRepository.getAuthToken()) assertEquals("accessToken", authRepository.getAccessToken()) + assertEquals(accessTokenV2, authRepository.getAccessTokenV2()) + assertEquals(refreshTokenV2, authRepository.getRefreshTokenV2()) } @Test @@ -126,4 +138,43 @@ class RealAuthRepositoryTest { ) assertFalse(repository.canSupportEncryption()) } + + @Test + fun whenSetFeaturesAndStoredValueIsNullThenSaveJson() = runTest { + authStore.subscriptionFeatures = null + + authRepository.setFeatures(basePlanId = "plan1", features = setOf("feature1", "feature2")) + + assertEquals("""{"plan1":["feature1","feature2"]}""", authStore.subscriptionFeatures) + } + + @Test + fun whenSetFeaturesAndStoredValueIsNotNullThenUpdateJson() = runTest { + authStore.subscriptionFeatures = """{"plan1":["feature1","feature2"]}""" + + authRepository.setFeatures(basePlanId = "plan2", features = setOf("feature1", "feature2")) + + assertEquals( + """{"plan1":["feature1","feature2"],"plan2":["feature1","feature2"]}""", + authStore.subscriptionFeatures, + ) + } + + @Test + fun whenGetFeaturesThenReturnsCorrectValue() = runTest { + authStore.subscriptionFeatures = """{"plan1":["feature1","feature2"],"plan2":["feature3"]}""" + + val result = authRepository.getFeatures(basePlanId = "plan1") + + assertEquals(setOf("feature1", "feature2"), result) + } + + @Test + fun whenGetFeaturesAndBasePlanNotFoundThenReturnEmptySet() = runTest { + authStore.subscriptionFeatures = """{"plan1":["feature1","feature2"]}""" + + val result = authRepository.getFeatures(basePlanId = "plan2") + + assertEquals(emptySet(), result) + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProBillingPeriodMatchingAttributeTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProBillingPeriodMatchingAttributeTest.kt index 7ddb447e752a..6ebce71c69e8 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProBillingPeriodMatchingAttributeTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProBillingPeriodMatchingAttributeTest.kt @@ -19,7 +19,7 @@ class RMFPProBillingPeriodMatchingAttributeTest { private lateinit var matcher: RMFPProBillingPeriodMatchingAttribute private val testSubscription = Subscription( - productId = SubscriptionsConstants.YEARLY_PLAN, + productId = SubscriptionsConstants.YEARLY_PLAN_US, startedAt = 10000L, expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, @@ -85,7 +85,7 @@ class RMFPProBillingPeriodMatchingAttributeTest { @Test fun whenMatchingAttributeHasMonthlyValueAndSubscriptionIsMonthlyThenEvaluateToTrue() = runTest { - whenever(subscriptionsManager.getSubscription()).thenReturn(testSubscription.copy(productId = SubscriptionsConstants.MONTHLY_PLAN)) + whenever(subscriptionsManager.getSubscription()).thenReturn(testSubscription.copy(productId = SubscriptionsConstants.MONTHLY_PLAN_US)) val result = matcher.evaluate(PProBillingPeriodMatchingAttribute("monthly")) assertNotNull(result) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriberMatchingAttributeTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriberMatchingAttributeTest.kt index fad49904f5ae..e4b3cfc14914 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriberMatchingAttributeTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriberMatchingAttributeTest.kt @@ -16,10 +16,10 @@ class RMFPProSubscriberMatchingAttributeTest { @Test fun evaluateWithWrongAttributeThenNull() = runTest { - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) Assert.assertNull(attribute.evaluate(FakeStringMatchingAttribute { "" })) - whenever(subscriptions.getAccessToken()).thenReturn("token") + whenever(subscriptions.isSignedIn()).thenReturn(true) Assert.assertNull(attribute.evaluate(FakeStringMatchingAttribute { "" })) Assert.assertNull(attribute.map("wrong", JsonMatchingAttribute(value = false))) @@ -28,16 +28,16 @@ class RMFPProSubscriberMatchingAttributeTest { @Test fun evaluateWithProEligibleMatchingAttributeThenValue() = runTest { - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) Assert.assertTrue(attribute.evaluate(attribute.map("pproSubscriber", JsonMatchingAttribute(value = false))!!)!!) - whenever(subscriptions.getAccessToken()).thenReturn("token") + whenever(subscriptions.isSignedIn()).thenReturn(true) Assert.assertTrue(attribute.evaluate(attribute.map("pproSubscriber", JsonMatchingAttribute(value = true))!!)!!) - whenever(subscriptions.getAccessToken()).thenReturn(null) + whenever(subscriptions.isSignedIn()).thenReturn(false) Assert.assertFalse(attribute.evaluate(attribute.map("pproSubscriber", JsonMatchingAttribute(value = true))!!)!!) - whenever(subscriptions.getAccessToken()).thenReturn("token") + whenever(subscriptions.isSignedIn()).thenReturn(true) Assert.assertFalse(attribute.evaluate(attribute.map("pproSubscriber", JsonMatchingAttribute(value = false))!!)!!) } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriptionStatusMatchingAttributeTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriptionStatusMatchingAttributeTest.kt index 67098d420c36..354ce9231fae 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriptionStatusMatchingAttributeTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriptionStatusMatchingAttributeTest.kt @@ -25,7 +25,7 @@ class RMFPProSubscriptionStatusMatchingAttributeTest { private lateinit var matcher: RMFPProSubscriptionStatusMatchingAttribute private val testSubscription = Subscription( - productId = SubscriptionsConstants.YEARLY_PLAN, + productId = SubscriptionsConstants.YEARLY_PLAN_US, startedAt = 10000L, expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/serp_promo/SerpPromoTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/serp_promo/SerpPromoTest.kt index 22c545b277a6..d7620a98e25a 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/serp_promo/SerpPromoTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/serp_promo/SerpPromoTest.kt @@ -1,10 +1,12 @@ package com.duckduckgo.subscriptions.impl.serp_promo +import android.annotation.SuppressLint import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.testing.TestLifecycleOwner import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.subscriptions.impl.PrivacyProFeature import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -15,15 +17,17 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") class SerpPromoTest { @get:Rule val coroutineRule = CoroutineTestRule() private val cookieManager: CookieManagerWrapper = mock() + private val subscriptions: Subscriptions = mock() private val lifecycleOwner: LifecycleOwner = TestLifecycleOwner() private var privacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) - private val serpPromo = RealSerpPromo(cookieManager, coroutineRule.testDispatcherProvider, { privacyProFeature }) + private val serpPromo = RealSerpPromo(cookieManager, coroutineRule.testDispatcherProvider, { privacyProFeature }, { subscriptions }) @Test fun whenInjectCookieThenSetCookie() = runTest { @@ -62,12 +66,22 @@ class SerpPromoTest { } @Test - fun whenOnStartAndPromoCookieSetThenNoop() = runTest { + fun whenOnStartAndPromoCookieSetThenSetEmptyCookie() = runTest { privacyProFeature.serpPromoCookie().setRawStoredState(Toggle.State(enable = true)) whenever(cookieManager.getCookie(any())).thenReturn("privacy_pro_access_token=value;") serpPromo.onStart(lifecycleOwner) - verify(cookieManager, never()).setCookie(any(), any()) + verify(cookieManager).setCookie(".subscriptions.duckduckgo.com", "privacy_pro_access_token=;HttpOnly;Path=/;") + } + + @Test + fun whenOnStartAndPromoCookieSetAndAccessTokenThenSetEmptyCookie() = runTest { + privacyProFeature.serpPromoCookie().setRawStoredState(Toggle.State(enable = true)) + whenever(cookieManager.getCookie(any())).thenReturn("privacy_pro_access_token=value;") + whenever(subscriptions.getAccessToken()).thenReturn("value") + + serpPromo.onStart(lifecycleOwner) + verify(cookieManager).setCookie(".subscriptions.duckduckgo.com", "privacy_pro_access_token=value;HttpOnly;Path=/;") } @Test @@ -88,6 +102,16 @@ class SerpPromoTest { verify(cookieManager).setCookie(".subscriptions.duckduckgo.com", "privacy_pro_access_token=;HttpOnly;Path=/;") } + @Test + fun whenOnStartAndOtherCookiesSetAndAccessTokenThenSetAccessTokenCookie() = runTest { + privacyProFeature.serpPromoCookie().setRawStoredState(Toggle.State(enable = true)) + whenever(cookieManager.getCookie(any())).thenReturn("another_cookie=value;") + whenever(subscriptions.getAccessToken()).thenReturn("value") + + serpPromo.onStart(lifecycleOwner) + verify(cookieManager).setCookie(".subscriptions.duckduckgo.com", "privacy_pro_access_token=value;HttpOnly;Path=/;") + } + @Test fun whenKillSwitchedOnStartAndOtherCookiesSetThenNoop() = runTest { privacyProFeature.serpPromoCookie().setRawStoredState(Toggle.State(enable = false)) @@ -96,4 +120,14 @@ class SerpPromoTest { serpPromo.onStart(lifecycleOwner) verify(cookieManager, never()).setCookie(any(), any()) } + + @Test + fun whenKillSwitchedOnStartAndOtherCookiesSetAndAccessTokenThenNoop() = runTest { + privacyProFeature.serpPromoCookie().setRawStoredState(Toggle.State(enable = false)) + whenever(cookieManager.getCookie(any())).thenReturn("another_cookie=value;") + whenever(subscriptions.getAccessToken()).thenReturn("value") + + serpPromo.onStart(lifecycleOwner) + verify(cookieManager, never()).setCookie(any(), any()) + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt index dc002a8faa2e..5e764fc9e966 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt @@ -24,7 +24,7 @@ class PproSurveyParameterPluginTest { private lateinit var currentTimeProvider: CurrentTimeProvider private val testSubscription = Subscription( - productId = SubscriptionsConstants.MONTHLY_PLAN, + productId = SubscriptionsConstants.MONTHLY_PLAN_US, startedAt = 1717797600000, // June 07 UTC expiresOrRenewsAt = 1719525600000, // June 27 UTC status = AUTO_RENEWABLE, @@ -67,7 +67,7 @@ class PproSurveyParameterPluginTest { fun whenSubscriptionIsYearlyThenBillingParamEvaluatesToSubscriptionBilling() = runTest { whenever(subscriptionsManager.getSubscription()).thenReturn( testSubscription.copy( - productId = SubscriptionsConstants.YEARLY_PLAN, + productId = SubscriptionsConstants.YEARLY_PLAN_US, ), ) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt index b00545927be1..3a91b57325eb 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt @@ -58,7 +58,7 @@ class SubscriptionSettingsViewModelTest { whenever(privacyProUnifiedFeedback.shouldUseUnifiedFeedback(any())).thenReturn(true) whenever(subscriptionsManager.getSubscription()).thenReturn( Subscription( - productId = SubscriptionsConstants.MONTHLY_PLAN, + productId = SubscriptionsConstants.MONTHLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, @@ -85,7 +85,7 @@ class SubscriptionSettingsViewModelTest { whenever(privacyProUnifiedFeedback.shouldUseUnifiedFeedback(any())).thenReturn(false) whenever(subscriptionsManager.getSubscription()).thenReturn( Subscription( - productId = SubscriptionsConstants.MONTHLY_PLAN, + productId = SubscriptionsConstants.MONTHLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, @@ -112,7 +112,7 @@ class SubscriptionSettingsViewModelTest { whenever(privacyProUnifiedFeedback.shouldUseUnifiedFeedback(any())).thenReturn(false) whenever(subscriptionsManager.getSubscription()).thenReturn( Subscription( - productId = SubscriptionsConstants.MONTHLY_PLAN, + productId = SubscriptionsConstants.MONTHLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, @@ -139,7 +139,7 @@ class SubscriptionSettingsViewModelTest { whenever(privacyProUnifiedFeedback.shouldUseUnifiedFeedback(any())).thenReturn(false) whenever(subscriptionsManager.getSubscription()).thenReturn( Subscription( - productId = SubscriptionsConstants.YEARLY_PLAN, + productId = SubscriptionsConstants.YEARLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, @@ -191,7 +191,7 @@ class SubscriptionSettingsViewModelTest { whenever(subscriptionsManager.getSubscription()).thenReturn( Subscription( - productId = SubscriptionsConstants.MONTHLY_PLAN, + productId = SubscriptionsConstants.MONTHLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, @@ -219,7 +219,7 @@ class SubscriptionSettingsViewModelTest { whenever(subscriptionsManager.getSubscription()).thenReturn( Subscription( - productId = SubscriptionsConstants.MONTHLY_PLAN, + productId = SubscriptionsConstants.MONTHLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, @@ -247,7 +247,7 @@ class SubscriptionSettingsViewModelTest { whenever(subscriptionsManager.getSubscription()).thenReturn( Subscription( - productId = SubscriptionsConstants.MONTHLY_PLAN, + productId = SubscriptionsConstants.MONTHLY_PLAN_US, startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt index 6fba33ebefaf..287e0dbf0fed 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt @@ -204,6 +204,7 @@ class SubscriptionWebViewViewModelTest { monthlyFormattedPrice = "$1", yearlyPlanId = "yearly", yearlyFormattedPrice = "$10", + features = setOf(SubscriptionsConstants.NETP), ), ) @@ -252,6 +253,7 @@ class SubscriptionWebViewViewModelTest { monthlyFormattedPrice = "$1", yearlyPlanId = "yearly", yearlyFormattedPrice = "$10", + features = setOf(SubscriptionsConstants.NETP), ), ) diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandler.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandler.kt deleted file mode 100644 index 98b0e45d3322..000000000000 --- a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandler.kt +++ /dev/null @@ -1,57 +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.subscriptions.internal.settings - -import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.subscriptions.impl.AuthTokenResult -import com.duckduckgo.subscriptions.impl.SubscriptionsManager -import com.duckduckgo.subscriptions.impl.services.AuthService -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject - -interface AccountDeletionHandler { - suspend fun deleteAccountAndSignOut(): Boolean -} - -@ContributesBinding(AppScope::class) -class AccountDeletionHandlerImpl @Inject constructor( - private val subscriptionsManager: SubscriptionsManager, - private val authService: AuthService, -) : AccountDeletionHandler { - - override suspend fun deleteAccountAndSignOut(): Boolean { - val accountDeleted = deleteAccount() - if (accountDeleted) { - subscriptionsManager.signOut() - } - return accountDeleted - } - - private suspend fun deleteAccount(): Boolean { - return try { - val token = subscriptionsManager.getAuthToken() - if (token is AuthTokenResult.Success) { - val state = authService.delete("Bearer ${token.authToken}") - (state.status == "deleted") - } else { - false - } - } catch (e: Exception) { - false - } - } -} diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/CopySubscriptionDataView.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/CopySubscriptionDataView.kt index 9b6fcc793b31..c104f1d402a9 100644 --- a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/CopySubscriptionDataView.kt +++ b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/CopySubscriptionDataView.kt @@ -23,6 +23,8 @@ import android.util.AttributeSet import android.view.View import android.widget.FrameLayout import android.widget.Toast +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.findViewTreeLifecycleOwner import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.common.ui.viewbinding.viewBinding @@ -35,7 +37,9 @@ import com.duckduckgo.subscriptions.internal.databinding.SubsSimpleViewBinding import com.squareup.anvil.annotations.ContributesMultibinding import dagger.android.support.AndroidSupportInjection import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -62,8 +66,14 @@ class CopySubscriptionDataView @JvmOverloads constructor( AndroidSupportInjection.inject(this) super.onAttachedToWindow() - binding.root.setPrimaryText("Copy subscriptions data") - binding.root.setSecondaryText("Copies your data to the clipboard") + binding.root.setPrimaryText("Subscriptions data") + + findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(dispatcherProvider.main()) { + while (true) { + binding.root.setSecondaryText(getData()) + delay(1.seconds) + } + } binding.root.setClickListener { copyDataToClipboard() @@ -74,35 +84,27 @@ class CopySubscriptionDataView @JvmOverloads constructor( val clipboardManager = context.getSystemService(ClipboardManager::class.java) appCoroutineScope.launch(dispatcherProvider.io()) { - val auth = authRepository.getAuthToken() - val authToken = if (auth.isNullOrBlank()) { - "No auth token found" - } else { - auth - } - - val access = authRepository.getAccessToken() - val accessToken = if (access.isNullOrBlank()) { - "No access token found" - } else { - access - } - - val external = authRepository.getExternalID() - val externalId = if (external.isNullOrBlank()) { - "No external id found" - } else { - external - } - val text = "Auth token is $authToken || Access token is $accessToken || External id is $externalId" - - clipboardManager.setPrimaryClip(ClipData.newPlainText("", text)) + clipboardManager.setPrimaryClip(ClipData.newPlainText("", getData())) withContext(dispatcherProvider.main()) { Toast.makeText(context, "Data copied to clipboard", Toast.LENGTH_SHORT).show() } } } + + private suspend fun getData(): String { + val textParts = listOf( + "Account: ${authRepository.getAccount()}", + "Subscription: ${authRepository.getSubscription()}", + "Entitlements: ${authRepository.getEntitlements()}", + "Access token (V2): ${authRepository.getAccessTokenV2()}", + "Refresh token (V2): ${authRepository.getRefreshTokenV2()}", + "Auth token (V1): ${authRepository.getAuthToken()}", + "Access token (V1): ${authRepository.getAccessToken()}", + ) + + return textParts.joinToString(separator = "\n---\n") + } } @ContributesMultibinding(ActivityScope::class) diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/DeleteSubscriptionView.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/DeleteSubscriptionView.kt deleted file mode 100644 index 52979e910499..000000000000 --- a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/DeleteSubscriptionView.kt +++ /dev/null @@ -1,89 +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.subscriptions.internal.settings - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.widget.FrameLayout -import android.widget.Toast -import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.di.scopes.ViewScope -import com.duckduckgo.subscriptions.internal.SubsSettingPlugin -import com.duckduckgo.subscriptions.internal.databinding.SubsSimpleViewBinding -import com.squareup.anvil.annotations.ContributesMultibinding -import dagger.android.support.AndroidSupportInjection -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@InjectWith(ViewScope::class) -class DeleteSubscriptionView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0, -) : FrameLayout(context, attrs, defStyle) { - - @Inject - lateinit var dispatcherProvider: DispatcherProvider - - @Inject - @AppCoroutineScope - lateinit var appCoroutineScope: CoroutineScope - - @Inject - lateinit var accountDeletionHandler: AccountDeletionHandler - - private val binding: SubsSimpleViewBinding by viewBinding() - - override fun onAttachedToWindow() { - AndroidSupportInjection.inject(this) - super.onAttachedToWindow() - - binding.root.setPrimaryText("Delete Subscription Account") - binding.root.setSecondaryText("Deletes your subscription account") - - binding.root.setClickListener { - deleteSubscription() - } - } - - private fun deleteSubscription() { - appCoroutineScope.launch(dispatcherProvider.io()) { - val message = if (accountDeletionHandler.deleteAccountAndSignOut()) { - "Account deleted" - } else { - "We could not delete your account" - } - withContext(dispatcherProvider.main()) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - } -} - -@ContributesMultibinding(ActivityScope::class) -class DeleteSubscriptionViewPlugin @Inject constructor() : SubsSettingPlugin { - override fun getView(context: Context): View { - return DeleteSubscriptionView(context) - } -} diff --git a/subscriptions/subscriptions-internal/src/test/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandlerImplTest.kt b/subscriptions/subscriptions-internal/src/test/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandlerImplTest.kt deleted file mode 100644 index ba8edbbe873e..000000000000 --- a/subscriptions/subscriptions-internal/src/test/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandlerImplTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.duckduckgo.subscriptions.internal.settings - -import com.duckduckgo.subscriptions.impl.AuthTokenResult -import com.duckduckgo.subscriptions.impl.SubscriptionsManager -import com.duckduckgo.subscriptions.impl.services.AuthService -import com.duckduckgo.subscriptions.impl.services.DeleteAccountResponse -import kotlinx.coroutines.test.runTest -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import retrofit2.HttpException -import retrofit2.Response - -class AccountDeletionHandlerImplTest { - - private val subscriptionsManager: SubscriptionsManager = mock() - private val authService: AuthService = mock() - - private val subject = AccountDeletionHandlerImpl(subscriptionsManager, authService) - - @Test - fun whenDeleteAccountIfUserAuthenticatedAndValidTokenThenReturnTrue() = runTest { - givenUserIsAuthenticated() - givenDeleteAccountSucceeds() - - val accountDeleted = subject.deleteAccountAndSignOut() - - assertTrue(accountDeleted) - verify(authService).delete(eq("Bearer token")) - verify(subscriptionsManager).signOut() - } - - @Test - fun whenDeleteAccountIfUserNotAuthenticatedThenReturnFalse() = runTest { - givenUserIsNotAuthenticated() - givenDeleteAccountFails() - - val accountDeleted = subject.deleteAccountAndSignOut() - - assertFalse(accountDeleted) - verify(subscriptionsManager).getAuthToken() - verify(subscriptionsManager, never()).signOut() - verify(authService, never()).delete(any()) - } - - @Test - fun whenDeleteAccountFailsThenReturnFalse() = runTest { - givenUserIsAuthenticated() - givenDeleteAccountFails() - - val accountDeleted = subject.deleteAccountAndSignOut() - - assertFalse(accountDeleted) - verify(subscriptionsManager).getAuthToken() - verify(authService).delete(eq("Bearer token")) - verify(subscriptionsManager, never()).signOut() - } - - private suspend fun givenUserIsAuthenticated() { - whenever(subscriptionsManager.getAuthToken()).thenReturn(AuthTokenResult.Success("token")) - } - - private suspend fun givenUserIsNotAuthenticated() { - whenever(subscriptionsManager.getAuthToken()).thenReturn(null) - } - - private suspend fun givenDeleteAccountSucceeds() { - whenever(authService.delete(any())).thenReturn(DeleteAccountResponse(status = "deleted")) - } - - private suspend fun givenDeleteAccountFails() { - val exception = "failure".toResponseBody("text/json".toMediaTypeOrNull()) - whenever(authService.delete(any())).thenThrow(HttpException(Response.error(400, exception))) - } -} diff --git a/sync/sync-impl/lint-baseline.xml b/sync/sync-impl/lint-baseline.xml index 6e9665b4268b..96f067463a0d 100644 --- a/sync/sync-impl/lint-baseline.xml +++ b/sync/sync-impl/lint-baseline.xml @@ -34,6 +34,17 @@ message="Defined here, included via layout/activity_sync.xml => layout/view_sync_disabled.xml defines @+id/syncSetupOtherPlatforms"/> + + + + + + + + + + + + + + + + + + + + fun pollConnectionKeys(): Result fun renameDevice(device: ConnectedDevice): Result + fun logoutAndJoinNewAccount(stringCode: String): Result } @ContributesBinding(AppScope::class) @@ -73,8 +74,11 @@ class AppSyncAccountRepository @Inject constructor( private val syncPixels: SyncPixels, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, + private val syncFeature: SyncFeature, ) : SyncAccountRepository { + private val connectedDevicesCached: MutableList = mutableListOf() + override fun isSyncSupported(): Boolean { return syncStore.isEncryptionSupported() } @@ -107,9 +111,20 @@ class AppSyncAccountRepository @Inject constructor( } private fun login(recoveryCode: RecoveryCode): Result { + var wasUserLogout = false if (isSignedIn()) { - return Error(code = ALREADY_SIGNED_IN.code, reason = "Already signed in") - .alsoFireAlreadySignedInErrorPixel() + val allowSwitchAccount = syncFeature.seamlessAccountSwitching().isEnabled() + val error = Error(code = ALREADY_SIGNED_IN.code, reason = "Already signed in").alsoFireAlreadySignedInErrorPixel() + if (allowSwitchAccount && connectedDevicesCached.size == 1) { + val thisDeviceId = syncStore.deviceId.orEmpty() + val result = logout(thisDeviceId) + if (result is Error) { + return result + } + wasUserLogout = true + } else { + return error + } } val primaryKey = recoveryCode.primaryKey @@ -119,6 +134,9 @@ class AppSyncAccountRepository @Inject constructor( return performLogin(userId, deviceId, deviceName, primaryKey).onFailure { it.alsoFireLoginErrorPixel() + if (wasUserLogout) { + syncPixels.fireUserSwitchedLoginError() + } return it.copy(code = LOGIN_FAILED.code) } } @@ -287,6 +305,7 @@ class AppSyncAccountRepository @Inject constructor( return when (val result = syncApi.getDevices(token)) { is Error -> { + connectedDevicesCached.clear() result.alsoFireAccountErrorPixel().copy(code = GENERIC_ERROR.code) } @@ -314,6 +333,11 @@ class AppSyncAccountRepository @Inject constructor( } }.sortedWith { a, b -> if (a.thisDevice) -1 else 1 + }.also { + connectedDevicesCached.apply { + clear() + addAll(it) + } }, ) } @@ -322,6 +346,23 @@ class AppSyncAccountRepository @Inject constructor( override fun isSignedIn() = syncStore.isSignedIn() + override fun logoutAndJoinNewAccount(stringCode: String): Result { + val thisDeviceId = syncStore.deviceId.orEmpty() + return when (val result = logout(thisDeviceId)) { + is Error -> { + syncPixels.fireUserSwitchedLogoutError() + result + } + is Result.Success -> { + val loginResult = processCode(stringCode) + if (loginResult is Error) { + syncPixels.fireUserSwitchedLoginError() + } + loginResult + } + } + } + private fun performCreateAccount(): Result { val userId = syncDeviceIds.userId() val account: AccountKeys = kotlin.runCatching { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt index 9ec266495910..f91c261e0083 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt @@ -19,6 +19,7 @@ package com.duckduckgo.sync.impl import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled @ContributesRemoteFeature( scope = AppScope::class, @@ -43,4 +44,7 @@ interface SyncFeature { @Toggle.DefaultValue(true) fun gzipPatchRequests(): Toggle + + @Toggle.DefaultValue(true) + fun seamlessAccountSwitching(): Toggle } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixelParamRemovalPlugin.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixelParamRemovalPlugin.kt index 7fca1dd76f17..620cf2b0eb88 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixelParamRemovalPlugin.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixelParamRemovalPlugin.kt @@ -35,6 +35,13 @@ class SyncPixelParamRemovalPlugin @Inject constructor() : PixelParamRemovalPlugi SyncPixelName.SYNC_GET_OTHER_DEVICES_SCREEN_SHOWN.pixelName to PixelParameter.removeAtb(), SyncPixelName.SYNC_GET_OTHER_DEVICES_LINK_COPIED.pixelName to PixelParameter.removeAtb(), SyncPixelName.SYNC_GET_OTHER_DEVICES_LINK_SHARED.pixelName to PixelParameter.removeAtb(), + + SyncPixelName.SYNC_ASK_USER_TO_SWITCH_ACCOUNT.pixelName to PixelParameter.removeAtb(), + SyncPixelName.SYNC_USER_ACCEPTED_SWITCHING_ACCOUNT.pixelName to PixelParameter.removeAtb(), + SyncPixelName.SYNC_USER_CANCELLED_SWITCHING_ACCOUNT.pixelName to PixelParameter.removeAtb(), + SyncPixelName.SYNC_USER_SWITCHED_ACCOUNT.pixelName to PixelParameter.removeAtb(), + SyncPixelName.SYNC_USER_SWITCHED_LOGOUT_ERROR.pixelName to PixelParameter.removeAtb(), + SyncPixelName.SYNC_USER_SWITCHED_LOGIN_ERROR.pixelName to PixelParameter.removeAtb(), ) } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt index 6a9b55c0fb2b..a52493645079 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt @@ -81,6 +81,13 @@ interface SyncPixels { feature: SyncableType, apiError: Error, ) + + fun fireAskUserToSwitchAccount() + fun fireUserAcceptedSwitchingAccount() + fun fireUserCancelledSwitchingAccount() + fun fireUserSwitchedAccount() + fun fireUserSwitchedLogoutError() + fun fireUserSwitchedLoginError() } @ContributesBinding(AppScope::class) @@ -255,6 +262,30 @@ class RealSyncPixels @Inject constructor( } } + override fun fireUserSwitchedAccount() { + pixel.fire(SyncPixelName.SYNC_USER_SWITCHED_ACCOUNT) + } + + override fun fireAskUserToSwitchAccount() { + pixel.fire(SyncPixelName.SYNC_ASK_USER_TO_SWITCH_ACCOUNT) + } + + override fun fireUserAcceptedSwitchingAccount() { + pixel.fire(SyncPixelName.SYNC_USER_ACCEPTED_SWITCHING_ACCOUNT) + } + + override fun fireUserCancelledSwitchingAccount() { + pixel.fire(SyncPixelName.SYNC_USER_CANCELLED_SWITCHING_ACCOUNT) + } + + override fun fireUserSwitchedLoginError() { + pixel.fire(SyncPixelName.SYNC_USER_SWITCHED_LOGIN_ERROR) + } + + override fun fireUserSwitchedLogoutError() { + pixel.fire(SyncPixelName.SYNC_USER_SWITCHED_LOGOUT_ERROR) + } + companion object { private const val SYNC_PIXELS_PREF_FILE = "com.duckduckgo.sync.pixels.v1" } @@ -302,6 +333,12 @@ enum class SyncPixelName(override val pixelName: String) : Pixel.PixelName { SYNC_GET_OTHER_DEVICES_SCREEN_SHOWN("sync_get_other_devices"), SYNC_GET_OTHER_DEVICES_LINK_COPIED("sync_get_other_devices_copy"), SYNC_GET_OTHER_DEVICES_LINK_SHARED("sync_get_other_devices_share"), + SYNC_ASK_USER_TO_SWITCH_ACCOUNT("sync_ask_user_to_switch_account"), + SYNC_USER_ACCEPTED_SWITCHING_ACCOUNT("sync_user_accepted_switching_account"), + SYNC_USER_CANCELLED_SWITCHING_ACCOUNT("sync_user_cancelled_switching_account"), + SYNC_USER_SWITCHED_ACCOUNT("sync_user_switched_account"), + SYNC_USER_SWITCHED_LOGOUT_ERROR("sync_user_switched_logout_error"), + SYNC_USER_SWITCHED_LOGIN_ERROR("sync_user_switched_login_error"), } object SyncPixelParameters { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeActivity.kt index 7b00d093f98a..63054c345580 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeActivity.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeActivity.kt @@ -36,8 +36,10 @@ import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState.Idle import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState.Loading import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command -import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSucess +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.AskToSwitchAccount +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSuccess import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.ShowError +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.SwitchAccountSuccess import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.ViewState import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -96,7 +98,7 @@ class EnterCodeActivity : DuckDuckGoActivity() { private fun processCommand(command: Command) { when (command) { - LoginSucess -> { + LoginSuccess -> { setResult(RESULT_OK) finish() } @@ -104,6 +106,14 @@ class EnterCodeActivity : DuckDuckGoActivity() { is ShowError -> { showError(command) } + + is AskToSwitchAccount -> askUserToSwitchAccount(command) + SwitchAccountSuccess -> { + val resultIntent = Intent() + resultIntent.putExtra(EXTRA_USER_SWITCHED_ACCOUNT, true) + setResult(RESULT_OK, resultIntent) + finish() + } } } @@ -120,6 +130,26 @@ class EnterCodeActivity : DuckDuckGoActivity() { ).show() } + private fun askUserToSwitchAccount(it: AskToSwitchAccount) { + viewModel.onUserAskedToSwitchAccount() + TextAlertDialogBuilder(this) + .setTitle(R.string.sync_dialog_switch_account_header) + .setMessage(R.string.sync_dialog_switch_account_description) + .setPositiveButton(R.string.sync_dialog_switch_account_primary_button) + .setNegativeButton(R.string.sync_dialog_switch_account_secondary_button) + .addEventListener( + object : TextAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked() { + viewModel.onUserAcceptedJoiningNewAccount(it.encodedStringCode) + } + + override fun onNegativeButtonClicked() { + viewModel.onUserCancelledJoiningNewAccount() + } + }, + ).show() + } + companion object { enum class Code { RECOVERY_CODE, @@ -128,6 +158,8 @@ class EnterCodeActivity : DuckDuckGoActivity() { private const val EXTRA_CODE_TYPE = "codeType" + const val EXTRA_USER_SWITCHED_ACCOUNT = "userSwitchedAccount" + internal fun intent(context: Context, codeType: Code): Intent { return Intent(context, EnterCodeActivity::class.java).apply { putExtra(EXTRA_CODE_TYPE, codeType) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt index 03ab613d98a2..7a505bca3fa2 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt @@ -30,8 +30,14 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Clipboard import com.duckduckgo.sync.impl.R import com.duckduckgo.sync.impl.Result +import com.duckduckgo.sync.impl.Result.Error import com.duckduckgo.sync.impl.SyncAccountRepository +import com.duckduckgo.sync.impl.SyncFeature +import com.duckduckgo.sync.impl.pixels.SyncPixels +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.AskToSwitchAccount +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSuccess import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.ShowError +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.SwitchAccountSuccess import javax.inject.* import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel @@ -45,6 +51,8 @@ class EnterCodeViewModel @Inject constructor( private val syncAccountRepository: SyncAccountRepository, private val clipboard: Clipboard, private val dispatchers: DispatcherProvider, + private val syncFeature: SyncFeature, + private val syncPixels: SyncPixels, ) : ViewModel() { private val command = Channel(1, DROP_OLDEST) @@ -66,8 +74,10 @@ class EnterCodeViewModel @Inject constructor( } sealed class Command { - object LoginSucess : Command() + data object LoginSuccess : Command() + data class AskToSwitchAccount(val encodedStringCode: String) : Command() data class ShowError(@StringRes val message: Int, val reason: String = "") : Command() + data object SwitchAccountSuccess : Command() } fun onPasteCodeClicked() { @@ -81,36 +91,79 @@ class EnterCodeViewModel @Inject constructor( private suspend fun authFlow( pastedCode: String, ) { - val result = syncAccountRepository.processCode(pastedCode) - when (result) { - is Result.Success -> command.send(Command.LoginSucess) + val userSignedIn = syncAccountRepository.isSignedIn() + when (val result = syncAccountRepository.processCode(pastedCode)) { + is Result.Success -> { + val commandSuccess = if (userSignedIn) { + syncPixels.fireUserSwitchedAccount() + SwitchAccountSuccess + } else { + LoginSuccess + } + command.send(commandSuccess) + } is Result.Error -> { + processError(result, pastedCode) + } + } + } + + private suspend fun processError(result: Error, pastedCode: String) { + if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) { + command.send(AskToSwitchAccount(pastedCode)) + } else { + if (result.code == INVALID_CODE.code) { + viewState.value = viewState.value.copy(authState = AuthState.Error) + return + } + + when (result.code) { + ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error + LOGIN_FAILED.code -> R.string.sync_connect_login_error + CONNECT_FAILED.code -> R.string.sync_connect_generic_error + CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error + INVALID_CODE.code -> R.string.sync_invalid_code_error + else -> null + }?.let { message -> + command.send( + ShowError( + message = message, + reason = result.reason, + ), + ) + } + } + } + + fun onUserAcceptedJoiningNewAccount(encodedStringCode: String) { + viewModelScope.launch(dispatchers.io()) { + syncPixels.fireUserAcceptedSwitchingAccount() + val result = syncAccountRepository.logoutAndJoinNewAccount(encodedStringCode) + if (result is Error) { when (result.code) { - ALREADY_SIGNED_IN.code -> { - showError(R.string.sync_login_authenticated_device_error, result.reason) - } - LOGIN_FAILED.code -> { - showError(R.string.sync_connect_login_error, result.reason) - } - CONNECT_FAILED.code -> { - showError(R.string.sync_connect_generic_error, result.reason) - } - CREATE_ACCOUNT_FAILED.code -> { - showError(R.string.sync_create_account_generic_error, result.reason) - } - INVALID_CODE.code -> { - viewState.value = viewState.value.copy(authState = AuthState.Error) - } - else -> {} + ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error + LOGIN_FAILED.code -> R.string.sync_connect_login_error + CONNECT_FAILED.code -> R.string.sync_connect_generic_error + CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error + INVALID_CODE.code -> R.string.sync_invalid_code_error + else -> null + }?.let { message -> + command.send( + ShowError(message = message, reason = result.reason), + ) } + } else { + syncPixels.fireUserSwitchedAccount() + command.send(SwitchAccountSuccess) } } } - private suspend fun showError( - message: Int, - reason: String, - ) { - command.send(ShowError(message = message, reason = reason)) + fun onUserCancelledJoiningNewAccount() { + syncPixels.fireUserCancelledSwitchingAccount() + } + + fun onUserAskedToSwitchAccount() { + syncPixels.fireAskUserToSwitchAccount() } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivity.kt index 5ced0c583f27..4289a04f4979 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivity.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivity.kt @@ -72,6 +72,8 @@ import com.duckduckgo.sync.impl.ui.setup.SetupAccountActivity.Companion.Screen.S import com.duckduckgo.sync.impl.ui.setup.SyncIntroContract import com.duckduckgo.sync.impl.ui.setup.SyncIntroContractInput import com.duckduckgo.sync.impl.ui.setup.SyncWithAnotherDeviceContract +import com.duckduckgo.sync.impl.ui.setup.SyncWithAnotherDeviceContract.SyncWithAnotherDeviceContractOutput.DeviceConnected +import com.duckduckgo.sync.impl.ui.setup.SyncWithAnotherDeviceContract.SyncWithAnotherDeviceContractOutput.SwitchAccountSuccess import com.google.android.material.snackbar.Snackbar import javax.inject.Inject import kotlinx.coroutines.flow.launchIn @@ -134,9 +136,11 @@ class SyncActivity : DuckDuckGoActivity() { } } - private val syncWithAnotherDeviceFlow = registerForActivityResult(SyncWithAnotherDeviceContract()) { resultOk -> - if (resultOk) { - viewModel.onDeviceConnected() + private val syncWithAnotherDeviceFlow = registerForActivityResult(SyncWithAnotherDeviceContract()) { result -> + when (result) { + DeviceConnected -> viewModel.onDeviceConnected() + SwitchAccountSuccess -> viewModel.onLoginSuccess() + else -> {} } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectActivity.kt index 94309baaf06b..5c03827df8bc 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectActivity.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectActivity.kt @@ -39,6 +39,7 @@ import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ShowError import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ShowMessage import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.ViewState import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract +import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -50,8 +51,8 @@ class SyncConnectActivity : DuckDuckGoActivity() { private val enterCodeLauncher = registerForActivityResult( EnterCodeContract(), - ) { resultOk -> - if (resultOk) { + ) { result -> + if (result != EnterCodeContractOutput.Error) { viewModel.onLoginSuccess() } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginActivity.kt index ae5cfb12be85..cff4d99d1ef3 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginActivity.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginActivity.kt @@ -36,6 +36,7 @@ import com.duckduckgo.sync.impl.ui.SyncLoginViewModel.Command.LoginSucess import com.duckduckgo.sync.impl.ui.SyncLoginViewModel.Command.ReadTextCode import com.duckduckgo.sync.impl.ui.SyncLoginViewModel.Command.ShowError import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract +import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -46,8 +47,8 @@ class SyncLoginActivity : DuckDuckGoActivity() { private val enterCodeLauncher = registerForActivityResult( EnterCodeContract(), - ) { resultOk -> - if (resultOk) { + ) { result -> + if (result != EnterCodeContractOutput.Error) { viewModel.onLoginSuccess() } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt index e2d6fd4e871f..0c186be3ea44 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt @@ -36,16 +36,21 @@ import com.duckduckgo.sync.impl.R.string import com.duckduckgo.sync.impl.Result.Error import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.SyncAccountRepository +import com.duckduckgo.sync.impl.SyncFeature import com.duckduckgo.sync.impl.getOrNull import com.duckduckgo.sync.impl.onFailure import com.duckduckgo.sync.impl.onSuccess import com.duckduckgo.sync.impl.pixels.SyncPixels +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.AskToSwitchAccount import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.FinishWithError import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.LoginSuccess import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ReadTextCode import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowError import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowMessage -import javax.inject.* +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess +import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput +import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -62,6 +67,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor( private val clipboard: Clipboard, private val syncPixels: SyncPixels, private val dispatchers: DispatcherProvider, + private val syncFeature: SyncFeature, ) : ViewModel() { private val command = Channel(1, DROP_OLDEST) fun commands(): Flow = command.receiveAsFlow() @@ -111,9 +117,15 @@ class SyncWithAnotherActivityViewModel @Inject constructor( sealed class Command { object ReadTextCode : Command() object LoginSuccess : Command() + object SwitchAccountSuccess : Command() data class ShowMessage(val messageId: Int) : Command() object FinishWithError : Command() - data class ShowError(@StringRes val message: Int, val reason: String = "") : Command() + data class ShowError( + @StringRes val message: Int, + val reason: String = "", + ) : Command() + + data class AskToSwitchAccount(val encodedStringCode: String) : Command() } fun onReadTextCodeClicked() { @@ -124,32 +136,96 @@ class SyncWithAnotherActivityViewModel @Inject constructor( fun onQRCodeScanned(qrCode: String) { viewModelScope.launch(dispatchers.io()) { + val userSignedIn = syncAccountRepository.isSignedIn() when (val result = syncAccountRepository.processCode(qrCode)) { is Error -> { - when (result.code) { - ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error - LOGIN_FAILED.code -> R.string.sync_connect_login_error - CONNECT_FAILED.code -> R.string.sync_connect_generic_error - CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error - INVALID_CODE.code -> R.string.sync_invalid_code_error - else -> null - }?.let { message -> - command.send(ShowError(message = message, reason = result.reason)) - } + emitError(result, qrCode) } is Success -> { syncPixels.fireLoginPixel() - command.send(LoginSuccess) + val commandSuccess = if (userSignedIn) { + syncPixels.fireUserSwitchedAccount() + SwitchAccountSuccess + } else { + LoginSuccess + } + command.send(commandSuccess) } } } } + private suspend fun emitError(result: Error, qrCode: String) { + if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) { + command.send(AskToSwitchAccount(qrCode)) + } else { + when (result.code) { + ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error + LOGIN_FAILED.code -> R.string.sync_connect_login_error + CONNECT_FAILED.code -> R.string.sync_connect_generic_error + CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error + INVALID_CODE.code -> R.string.sync_invalid_code_error + else -> null + }?.let { message -> + command.send(ShowError(message = message, reason = result.reason)) + } + } + } + fun onLoginSuccess() { viewModelScope.launch { syncPixels.fireLoginPixel() command.send(LoginSuccess) } } + + fun onUserAcceptedJoiningNewAccount(encodedStringCode: String) { + viewModelScope.launch(dispatchers.io()) { + syncPixels.fireUserAcceptedSwitchingAccount() + val result = syncAccountRepository.logoutAndJoinNewAccount(encodedStringCode) + if (result is Error) { + when (result.code) { + ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error + LOGIN_FAILED.code -> R.string.sync_connect_login_error + CONNECT_FAILED.code -> R.string.sync_connect_generic_error + CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error + INVALID_CODE.code -> R.string.sync_invalid_code_error + else -> null + }?.let { message -> + command.send( + ShowError(message = message, reason = result.reason), + ) + } + } else { + syncPixels.fireLoginPixel() + syncPixels.fireUserSwitchedAccount() + command.send(SwitchAccountSuccess) + } + } + } + + fun onEnterCodeResult(result: EnterCodeContractOutput) { + viewModelScope.launch { + when (result) { + EnterCodeContractOutput.Error -> {} + EnterCodeContractOutput.LoginSuccess -> { + syncPixels.fireLoginPixel() + command.send(LoginSuccess) + } + EnterCodeContractOutput.SwitchAccountSuccess -> { + syncPixels.fireLoginPixel() + command.send(SwitchAccountSuccess) + } + } + } + } + + fun onUserCancelledJoiningNewAccount() { + syncPixels.fireUserCancelledSwitchingAccount() + } + + fun onUserAskedToSwitchAccount() { + syncPixels.fireAskUserToSwitchAccount() + } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceActivity.kt index 5faaef44f77d..3a51582e64f9 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceActivity.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceActivity.kt @@ -32,6 +32,13 @@ import com.duckduckgo.sync.impl.R import com.duckduckgo.sync.impl.databinding.ActivityConnectSyncBinding import com.duckduckgo.sync.impl.ui.EnterCodeActivity.Companion.Code.RECOVERY_CODE import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.AskToSwitchAccount +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.FinishWithError +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.LoginSuccess +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ReadTextCode +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowError +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowMessage +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.ViewState import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract import com.google.android.material.snackbar.Snackbar @@ -45,10 +52,8 @@ class SyncWithAnotherDeviceActivity : DuckDuckGoActivity() { private val enterCodeLauncher = registerForActivityResult( EnterCodeContract(), - ) { resultOk -> - if (resultOk) { - viewModel.onLoginSuccess() - } + ) { result -> + viewModel.onEnterCodeResult(result) } override fun onCreate(savedInstanceState: Bundle?) { @@ -95,20 +100,26 @@ class SyncWithAnotherDeviceActivity : DuckDuckGoActivity() { private fun processCommand(it: Command) { when (it) { - Command.ReadTextCode -> { + ReadTextCode -> { enterCodeLauncher.launch(RECOVERY_CODE) } - Command.LoginSuccess -> { + is LoginSuccess -> { setResult(RESULT_OK) finish() } - Command.FinishWithError -> { + FinishWithError -> { setResult(RESULT_CANCELED) finish() } - - is Command.ShowMessage -> Snackbar.make(binding.root, it.messageId, Snackbar.LENGTH_SHORT).show() - is Command.ShowError -> showError(it) + is ShowMessage -> Snackbar.make(binding.root, it.messageId, Snackbar.LENGTH_SHORT).show() + is ShowError -> showError(it) + is AskToSwitchAccount -> askUserToSwitchAccount(it) + SwitchAccountSuccess -> { + val resultIntent = Intent() + resultIntent.putExtra(EXTRA_USER_SWITCHED_ACCOUNT, true) + setResult(RESULT_OK, resultIntent) + finish() + } } } @@ -121,7 +132,27 @@ class SyncWithAnotherDeviceActivity : DuckDuckGoActivity() { } } - private fun showError(it: Command.ShowError) { + private fun askUserToSwitchAccount(it: AskToSwitchAccount) { + viewModel.onUserAskedToSwitchAccount() + TextAlertDialogBuilder(this) + .setTitle(R.string.sync_dialog_switch_account_header) + .setMessage(R.string.sync_dialog_switch_account_description) + .setPositiveButton(R.string.sync_dialog_switch_account_primary_button) + .setNegativeButton(R.string.sync_dialog_switch_account_secondary_button) + .addEventListener( + object : TextAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked() { + viewModel.onUserAcceptedJoiningNewAccount(it.encodedStringCode) + } + + override fun onNegativeButtonClicked() { + viewModel.onUserCancelledJoiningNewAccount() + } + }, + ).show() + } + + private fun showError(it: ShowError) { TextAlertDialogBuilder(this) .setTitle(R.string.sync_dialog_error_title) .setMessage(getString(it.message) + "\n" + it.reason) @@ -136,6 +167,8 @@ class SyncWithAnotherDeviceActivity : DuckDuckGoActivity() { } companion object { + const val EXTRA_USER_SWITCHED_ACCOUNT = "userSwitchedAccount" + internal fun intent(context: Context): Intent { return Intent(context, SyncWithAnotherDeviceActivity::class.java) } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/EnterCodeContract.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/EnterCodeContract.kt index fb7a05f5ea98..649971994dc1 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/EnterCodeContract.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/EnterCodeContract.kt @@ -22,8 +22,10 @@ import android.content.Intent import androidx.activity.result.contract.ActivityResultContract import com.duckduckgo.sync.impl.ui.EnterCodeActivity import com.duckduckgo.sync.impl.ui.EnterCodeActivity.Companion.Code +import com.duckduckgo.sync.impl.ui.EnterCodeActivity.Companion.EXTRA_USER_SWITCHED_ACCOUNT +import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput -class EnterCodeContract : ActivityResultContract() { +class EnterCodeContract : ActivityResultContract() { override fun createIntent( context: Context, codeType: Code, @@ -34,7 +36,24 @@ class EnterCodeContract : ActivityResultContract() { override fun parseResult( resultCode: Int, intent: Intent?, - ): Boolean { - return resultCode == Activity.RESULT_OK + ): EnterCodeContractOutput { + when { + resultCode == Activity.RESULT_OK -> { + val userSwitchedAccount = intent?.getBooleanExtra(EXTRA_USER_SWITCHED_ACCOUNT, false) ?: false + return if (userSwitchedAccount) { + EnterCodeContractOutput.SwitchAccountSuccess + } else { + EnterCodeContractOutput.LoginSuccess + } + } + + else -> return EnterCodeContractOutput.Error + } + } + + sealed class EnterCodeContractOutput { + data object LoginSuccess : EnterCodeContractOutput() + data object SwitchAccountSuccess : EnterCodeContractOutput() + data object Error : EnterCodeContractOutput() } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SyncWithAnotherDeviceContract.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SyncWithAnotherDeviceContract.kt index e59a35a556b8..cb43d3fa4901 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SyncWithAnotherDeviceContract.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SyncWithAnotherDeviceContract.kt @@ -21,8 +21,10 @@ import android.content.Context import android.content.Intent import androidx.activity.result.contract.ActivityResultContract import com.duckduckgo.sync.impl.ui.SyncWithAnotherDeviceActivity +import com.duckduckgo.sync.impl.ui.SyncWithAnotherDeviceActivity.Companion.EXTRA_USER_SWITCHED_ACCOUNT +import com.duckduckgo.sync.impl.ui.setup.SyncWithAnotherDeviceContract.SyncWithAnotherDeviceContractOutput -internal class SyncWithAnotherDeviceContract : ActivityResultContract() { +internal class SyncWithAnotherDeviceContract : ActivityResultContract() { override fun createIntent( context: Context, input: Void?, @@ -33,7 +35,23 @@ internal class SyncWithAnotherDeviceContract : ActivityResultContract { + val userSwitchedAccount = intent?.getBooleanExtra(EXTRA_USER_SWITCHED_ACCOUNT, false) ?: false + return if (userSwitchedAccount) { + SyncWithAnotherDeviceContractOutput.SwitchAccountSuccess + } else { + SyncWithAnotherDeviceContractOutput.DeviceConnected + } + } + else -> return SyncWithAnotherDeviceContractOutput.Error + } + } + + sealed class SyncWithAnotherDeviceContractOutput { + data object DeviceConnected : SyncWithAnotherDeviceContractOutput() + data object SwitchAccountSuccess : SyncWithAnotherDeviceContractOutput() + data object Error : SyncWithAnotherDeviceContractOutput() } } diff --git a/sync/sync-impl/src/main/res/values-bg/strings-sync.xml b/sync/sync-impl/src/main/res/values-bg/strings-sync.xml index 5dff0c863453..b5afaf788373 100644 --- a/sync/sync-impl/src/main/res/values-bg/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-bg/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Споделяне на връзка за изтегляне Връзката е копирана + + + Превключване към друго синхронизиране? + Превключване на синхронизирането + Отмени + Това устройство вече е синхронизирано, сигурни ли сте, че искате да го синхронизирате с друго резервно копие или устройство? Превключването няма да изтрие данните, които вече са синхронизирани с това устройство. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-cs/strings-sync.xml b/sync/sync-impl/src/main/res/values-cs/strings-sync.xml index 959bc6c627e7..dcfb361e9f59 100644 --- a/sync/sync-impl/src/main/res/values-cs/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-cs/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Sdílet odkaz ke stažení Odkaz se zkopíroval + + + Přepnout synchronizaci? + Přepnout synchronizaci + Zrušit + Tohle zařízení už je synchronizované. Opravdu ho chceš synchronizovat s jinou zálohou nebo zařízením? Přepnutím se nesmažou žádná data, která už s tímto zařízením byla synchronizována. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-da/strings-sync.xml b/sync/sync-impl/src/main/res/values-da/strings-sync.xml index cd858839150f..1d36bc89c513 100644 --- a/sync/sync-impl/src/main/res/values-da/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-da/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Del downloadlink Link kopieret + + + Skift til en anden synkronisering? + Skift synkronisering + Annuller + Denne enhed er allerede synkroniseret. Er du sikker på, at du vil synkronisere den med en anden sikkerhedskopi eller enhed? Et skifte fjerner ikke data, der allerede er synkroniseret til denne enhed. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-de/strings-sync.xml b/sync/sync-impl/src/main/res/values-de/strings-sync.xml index 609b1fe0a9e3..0e38b8e352ef 100644 --- a/sync/sync-impl/src/main/res/values-de/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-de/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Download-Link teilen Link kopiert + + + Zu einer anderen Synchronisierung wechseln? + Synchronisierung wechseln + Abbrechen + Dieses Gerät ist bereits synchronisiert. Bist du sicher, dass du es mit einem anderen Back-up oder Gerät synchronisieren möchtest? Beim Wechsel werden keine bereits mit diesem Gerät synchronisierten Daten entfernt. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-el/strings-sync.xml b/sync/sync-impl/src/main/res/values-el/strings-sync.xml index c6c9ca1875df..bc32dce68ac9 100644 --- a/sync/sync-impl/src/main/res/values-el/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-el/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Κοινή χρήση συνδέσμου λήψης Ο σύνδεσμος αντιγράφηκε + + + Αλλαγή σε διαφορετικό συγχρονισμό; + Αλλαγή συγχρονισμού + Ακύρωση + Η συσκευή αυτή είναι ήδη συγχρονισμένη. Θέλετε σίγουρα να τη συγχρονίσετε με ένα διαφορετικό αντίγραφο ασφαλείας ή συσκευή; Η αλλαγή δεν θα αφαιρέσει δεδομένα που έχουν ήδη συγχρονιστεί σε αυτήν τη συσκευή. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-es/strings-sync.xml b/sync/sync-impl/src/main/res/values-es/strings-sync.xml index a3afb20bb24e..be159e5e766e 100644 --- a/sync/sync-impl/src/main/res/values-es/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-es/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Compartir enlace de descarga Enlace copiado + + + ¿Cambiar a una sincronización diferente? + Cambiar sincronización + Cancelar + Este dispositivo ya está sincronizado. ¿Seguro que deseas sincronizarlo con una copia de seguridad o con un dispositivo diferente? Cambiar no eliminará ningún dato ya sincronizado en este dispositivo. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-et/strings-sync.xml b/sync/sync-impl/src/main/res/values-et/strings-sync.xml index 2bea13ffe81b..7e5e234fa626 100644 --- a/sync/sync-impl/src/main/res/values-et/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-et/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Jaga allalaadimise linki Link kopeeritud + + + Kas lülituda teisele Sync\'ile? + Synci lülitamine + Loobu + See seade on juba sünkroonitud, kas soovid kindlasti seda sünkroonida teise varukoopia või seadmega? Vahetamine ei eemalda selle seadmega juba sünkroonitud andmeid. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-fi/strings-sync.xml b/sync/sync-impl/src/main/res/values-fi/strings-sync.xml index 8b9a153e1d2c..98552040c3cc 100644 --- a/sync/sync-impl/src/main/res/values-fi/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-fi/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Jaa latauslinkki Linkki kopioitu + + + Vaihdetaanko toiseen synkronointiin? + Vaihda synkronointi + Peruuta + Tämä laite on jo synkronoitu. Haluatko varmasti synkronoida sen toisen varmuuskopion tai laitteen kanssa? Vaihtaminen ei poista tähän laitteeseen jo synkronoituja tietoja. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-fr/strings-sync.xml b/sync/sync-impl/src/main/res/values-fr/strings-sync.xml index b15d5d41bbc0..ad02b0dada0d 100644 --- a/sync/sync-impl/src/main/res/values-fr/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-fr/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Partager le lien de téléchargement Lien copié + + + Passer à une autre synchronisation ? + Changer de synchronisation + Annuler + Cet appareil est déjà synchronisé. Voulez-vous vraiment le synchroniser avec une autre sauvegarde ou un autre appareil ? Ce changement ne supprimera aucune donnée déjà synchronisée sur cet appareil. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-hr/strings-sync.xml b/sync/sync-impl/src/main/res/values-hr/strings-sync.xml index 6a4b7e77c837..f6a06ea20b05 100644 --- a/sync/sync-impl/src/main/res/values-hr/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-hr/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Podijeli poveznicu za preuzimanje Poveznica je kopirana + + + Prijeđi na drugu sinkronizaciju? + Prebaci sinkronizaciju + Odustani + Ovaj je uređaj već sinkroniziran. Jesi li siguran da ga želiš sinkronizirati s drugom sigurnosnom kopijom ili uređajem? Prebacivanjem se neće ukloniti nijedan podatak koji je već sinkroniziran s ovim uređajem. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-hu/strings-sync.xml b/sync/sync-impl/src/main/res/values-hu/strings-sync.xml index e29175559c85..8ccedbbbb874 100644 --- a/sync/sync-impl/src/main/res/values-hu/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-hu/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Letöltési link megosztása Link másolva + + + Átváltasz egy másik szinkronizációs lehetőségre? + Váltás másik szinkronizálási lehetőségre + Mégsem + Ez az eszköz már szinkronizálva van. Biztosan szinkronizálni szeretnéd egy másik biztonsági mentéssel vagy eszközzel? A váltás nem távolítja el ezzel az eszközzel már szinkronizált adatokat. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-it/strings-sync.xml b/sync/sync-impl/src/main/res/values-it/strings-sync.xml index 3ee3aefd862a..0931a9743e8e 100644 --- a/sync/sync-impl/src/main/res/values-it/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-it/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Condividi link per il download Link copiato + + + Vuoi passare a una sincronizzazione diversa? + Cambia sincronizzazione + Annulla + Questo dispositivo è già sincronizzato. Vuoi davvero sincronizzarlo con un backup o un dispositivo diverso? L\'operazione non rimuoverà alcun dato già sincronizzato su questo dispositivo. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-lt/strings-sync.xml b/sync/sync-impl/src/main/res/values-lt/strings-sync.xml index f668a54ff8ad..c88597ab0a7a 100644 --- a/sync/sync-impl/src/main/res/values-lt/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-lt/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Bendrinti atsisiuntimo nuorodą Nuoroda nukopijuota + + + Perjungti į kitą sinchronizaciją? + Perjungti sinchronizavimą + Atšaukti + Šis įrenginys jau sinchronizuotas, ar tikrai norite jį sinchronizuoti su kita atsargine kopija ar įrenginiu? Perjungiant nebus pašalinti jokie duomenys, jau sinchronizuoti su šiuo įrenginiu. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-lv/strings-sync.xml b/sync/sync-impl/src/main/res/values-lv/strings-sync.xml index 929c3c4fbab1..c0ebba5ea5f1 100644 --- a/sync/sync-impl/src/main/res/values-lv/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-lv/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Kopīgot lejupielādes saiti Saite nokopēta + + + Vai pārslēgt uz citu sinhronizāciju? + Pārslēgt sinhronizāciju + Atcelt + Šī ierīce jau ir sinhronizēta, vai tiešām vēlies to sinhronizēt ar citu dublējumu vai ierīci? Pārslēdzot netiks dzēsti dati, kas jau ir sinhronizēti ar šo ierīci. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-nb/strings-sync.xml b/sync/sync-impl/src/main/res/values-nb/strings-sync.xml index 261ab670d71e..91f93b60a481 100644 --- a/sync/sync-impl/src/main/res/values-nb/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-nb/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Del nedlastingslenke Lenken er kopiert + + + Vil du bytte til en annen synkronisering? + Bytt synkronisering + Avbryt + Denne enheten er allerede synkronisert. Er du sikker på at du vil synkronisere den med en annen sikkerhetskopi eller enhet? Data som allerede er synkronisert med denne enheten, fjernes ikke hvis du bytter. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-nl/strings-sync.xml b/sync/sync-impl/src/main/res/values-nl/strings-sync.xml index 4f287ad936b1..7cfb20184e2c 100644 --- a/sync/sync-impl/src/main/res/values-nl/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-nl/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Downloadlink delen Link gekopieerd + + + Overschakelen naar een andere synchronisatie? + Schakelen tussen synchronisatie + Annuleren + Dit apparaat is al gesynchroniseerd, weet je zeker dat je het met een andere back-up of een ander apparaat wilt synchroniseren? Als je overschakelt, worden er geen gegevens verwijderd die al met dit apparaat zijn gesynchroniseerd. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-pl/strings-sync.xml b/sync/sync-impl/src/main/res/values-pl/strings-sync.xml index 6104018b82be..88dac3d33bf5 100644 --- a/sync/sync-impl/src/main/res/values-pl/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-pl/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Udostępnij link pobierania Skopiowano łącze + + + Przełączyć na inną synchronizację? + Przełącz synchronizację + Anuluj + To urządzenie jest już synchronizowane, czy na pewno chcesz je synchronizować z inną kopią zapasową lub urządzeniem? Przełączenie nie usunie żadnych danych już zsynchronizowanych z tym urządzeniem. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-pt/strings-sync.xml b/sync/sync-impl/src/main/res/values-pt/strings-sync.xml index a71d2eefef96..b242876358b7 100644 --- a/sync/sync-impl/src/main/res/values-pt/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-pt/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Partilhar link de transferência Link copiado + + + Mudar para uma sincronização diferente? + Mudar sincronização + Cancelar + Este dispositivo já está sincronizado, tens a certeza de que queres sincronizá-lo com uma cópia de segurança ou um dispositivo diferente? A mudança não vai remover nenhum dado já sincronizado com este dispositivo. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-ro/strings-sync.xml b/sync/sync-impl/src/main/res/values-ro/strings-sync.xml index 79db2a47c8bc..41aed8875aa1 100644 --- a/sync/sync-impl/src/main/res/values-ro/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-ro/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Trimite linkul de descărcare Link copiat + + + Treci la o altă sincronizare? + Comută sincronizarea + Anulare + Acest dispozitiv este deja sincronizat, sigur dorești să-l sincronizezi cu o altă copie de rezervă sau alt dispozitiv? Schimbarea nu va șterge datele deja sincronizate cu acest dispozitiv. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-ru/strings-sync.xml b/sync/sync-impl/src/main/res/values-ru/strings-sync.xml index da204a3d415d..20f6b6911b18 100644 --- a/sync/sync-impl/src/main/res/values-ru/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-ru/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Поделиться ссылкой для загрузки Ссылка скопирована + + + Переключиться на другую синхронизацию? + Переключить синхронизацию + Отменить + Это устройство уже синхронизировано. Действительно синхронизировать его с другой резервной копией или устройством? Переключение не приведет к удалению данных, синхронизированных с этим устройством ранее. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-sk/strings-sync.xml b/sync/sync-impl/src/main/res/values-sk/strings-sync.xml index f6bc755ee1f8..5b7e7b5ba6c1 100644 --- a/sync/sync-impl/src/main/res/values-sk/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-sk/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Zdieľať odkaz na stiahnutie Odkaz bol skopírovaný + + + Prepnúť na inú synchronizáciu? + Prepnutie synchronizácie + Zrušiť + Toto zariadenie je už synchronizované. Naozaj ho že chceš synchronizovať s inou zálohou alebo zariadením? Prepínanie neodstráni žiadne údaje, ktoré už boli synchronizované s týmto zariadením. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-sl/strings-sync.xml b/sync/sync-impl/src/main/res/values-sl/strings-sync.xml index 63b2c8a00719..0bafabefeb29 100644 --- a/sync/sync-impl/src/main/res/values-sl/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-sl/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Deli povezavo za prenos Povezava je kopirana + + + Želite preklopiti na drugo sinhronizacijo? + Preklop sinhronizacije + Prekliči + Ta naprava je že sinhronizirana, ste prepričani, da jo želite sinhronizirati z drugo varnostno kopijo ali napravo? Preklop ne bo odstranil nobenih podatkov, ki so že sinhronizirani s to napravo. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-sv/strings-sync.xml b/sync/sync-impl/src/main/res/values-sv/strings-sync.xml index c8759c83ba7a..9892d6396431 100644 --- a/sync/sync-impl/src/main/res/values-sv/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-sv/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Dela nedladdningslänk Länken har kopierats + + + Byt till en annan synkronisering? + Byt synkronisering + Avbryt + Den här enheten är redan synkroniserad. Är du säker på att du vill synkronisera den med en annan säkerhetskopia eller enhet? Att byta tar inte bort några data som redan har synkroniserats med den här enheten. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values-tr/strings-sync.xml b/sync/sync-impl/src/main/res/values-tr/strings-sync.xml index a29ae88e18ad..118916a03640 100644 --- a/sync/sync-impl/src/main/res/values-tr/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-tr/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app İndirme Bağlantısını Paylaş Bağlantı kopyalandı + + + Farklı bir Senkronizasyona mı geçmek istiyorsunuz? + Senkronizasyonu Değiştir + Vazgeç + Bu cihaz zaten senkronize edilmiş. Farklı bir yedekleme veya cihazla senkronize etmek istediğinden emin misin? Senkronizasyonu değiştirmek, bu cihazla önceden senkronize edilmiş verileri kaldırmayacaktır. \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values/donottranslate.xml b/sync/sync-impl/src/main/res/values/donottranslate.xml index ab52625f0197..7a2b43d5f560 100644 --- a/sync/sync-impl/src/main/res/values/donottranslate.xml +++ b/sync/sync-impl/src/main/res/values/donottranslate.xml @@ -29,5 +29,4 @@ User ID Device Id Device Name - \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values/strings-sync.xml b/sync/sync-impl/src/main/res/values/strings-sync.xml index 71a368d0b545..7930a7742880 100644 --- a/sync/sync-impl/src/main/res/values/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values/strings-sync.xml @@ -190,4 +190,10 @@ duckduckgo.com/app Share Download Link Link copied + + + Switch to a different Sync? + Switch Sync + Cancel + This device is already synced, are you sure you want to sync it with a different back up or device? Switching won\'t remove any data already synced to this device. \ No newline at end of file diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt index 9bbdc1f225a7..120d523b784c 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt @@ -140,7 +140,8 @@ object TestSyncFixtures { ) val loginSuccessResponse: Response = Response.success(loginResponseBody) - val listOfDevices = listOf(Device(deviceId = deviceId, deviceName = deviceName, jwIat = "", deviceType = deviceFactor)) + val aDevice = Device(deviceId = deviceId, deviceName = deviceName, jwIat = "", deviceType = deviceFactor) + val listOfDevices = listOf(aDevice) val deviceResponse = DeviceResponse(DeviceEntries(listOfDevices)) val getDevicesBodySuccessResponse: Response = Response.success(deviceResponse) val getDevicesBodyErrorResponse: Response = Response.error( diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt index 762a021f8b94..09c5d17cfc96 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt @@ -18,6 +18,9 @@ package com.duckduckgo.sync.impl import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.utils.DefaultDispatcherProvider +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.sync.TestSyncFixtures.aDevice import com.duckduckgo.sync.TestSyncFixtures.accountCreatedFailDupUser import com.duckduckgo.sync.TestSyncFixtures.accountCreatedSuccess import com.duckduckgo.sync.TestSyncFixtures.accountKeys @@ -65,9 +68,8 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Result.Error import com.duckduckgo.sync.impl.Result.Success -import com.duckduckgo.sync.impl.pixels.* +import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.store.SyncStore -import java.lang.RuntimeException import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -77,6 +79,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.times @@ -93,13 +96,25 @@ class AppSyncAccountRepositoryTest { private var syncStore: SyncStore = mock() private var syncEngine: SyncEngine = mock() private var syncPixels: SyncPixels = mock() + private val syncFeature = FakeFeatureToggleFactory.create(SyncFeature::class.java).apply { + this.seamlessAccountSwitching().setRawStoredState(State(true)) + } private lateinit var syncRepo: SyncAccountRepository @Before fun before() { - syncRepo = - AppSyncAccountRepository(syncDeviceIds, nativeLib, syncApi, syncStore, syncEngine, syncPixels, TestScope(), DefaultDispatcherProvider()) + syncRepo = AppSyncAccountRepository( + syncDeviceIds, + nativeLib, + syncApi, + syncStore, + syncEngine, + syncPixels, + TestScope(), + DefaultDispatcherProvider(), + syncFeature, + ) } @Test @@ -230,6 +245,62 @@ class AppSyncAccountRepositoryTest { ) } + @Test + fun whenSignedInAndProcessRecoveryCodeIfAllowSwitchAccountTrueThenSwitchAccountIfOnly1DeviceConnected() { + givenAuthenticatedDevice() + givenAccountWithConnectedDevices(1) + doAnswer { + givenUnauthenticatedDevice() // simulate logout locally + logoutSuccess + }.`when`(syncApi).logout(token, deviceId) + prepareForLoginSuccess() + + val result = syncRepo.processCode(jsonRecoveryKeyEncoded) + + verify(syncApi).logout(token, deviceId) + verify(syncApi).login(userId, hashedPassword, deviceId, deviceName, deviceFactor) + + assertTrue(result is Success) + } + + @Test + fun whenSignedInAndProcessRecoveryCodeIfAllowSwitchAccountTrueThenReturnErrorIfMultipleDevicesConnected() { + givenAuthenticatedDevice() + givenAccountWithConnectedDevices(2) + doAnswer { + givenUnauthenticatedDevice() // simulate logout locally + logoutSuccess + }.`when`(syncApi).logout(token, deviceId) + prepareForLoginSuccess() + + val result = syncRepo.processCode(jsonRecoveryKeyEncoded) + + assertEquals((result as Error).code, ALREADY_SIGNED_IN.code) + } + + @Test + fun whenLogoutAndJoinNewAccountSucceedsThenReturnSuccess() { + givenAuthenticatedDevice() + doAnswer { + givenUnauthenticatedDevice() // simulate logout locally + logoutSuccess + }.`when`(syncApi).logout(token, deviceId) + prepareForLoginSuccess() + + val result = syncRepo.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded) + + assertTrue(result is Result.Success) + verify(syncStore).clearAll() + verify(syncStore).storeCredentials( + userId = userId, + deviceId = deviceId, + deviceName = deviceName, + primaryKey = primaryKey, + secretKey = secretKey, + token = token, + ) + } + @Test fun whenGenerateKeysFromRecoveryCodeFailsThenReturnLoginFailedError() { prepareToProvideDeviceIds() @@ -522,6 +593,10 @@ class AppSyncAccountRepositoryTest { whenever(syncStore.isSignedIn()).thenReturn(true) } + private fun givenUnauthenticatedDevice() { + whenever(syncStore.isSignedIn()).thenReturn(false) + } + private fun prepareToProvideDeviceIds() { whenever(syncDeviceIds.userId()).thenReturn(userId) whenever(syncDeviceIds.deviceId()).thenReturn(deviceId) @@ -545,4 +620,15 @@ class AppSyncAccountRepositoryTest { EncryptResult(0, it.arguments.first() as String) } } + + private fun givenAccountWithConnectedDevices(size: Int) { + prepareForEncryption() + val listOfDevices = mutableListOf() + for (i in 0 until size) { + listOfDevices.add(aDevice.copy(deviceId = "device$i")) + } + whenever(syncApi.getDevices(anyString())).thenReturn(Success(listOfDevices)) + + syncRepo.getConnectedDevices() as Success + } } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt index 4c1434a83d6e..4979289f791b 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt @@ -19,6 +19,8 @@ package com.duckduckgo.sync.impl.ui import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.sync.TestSyncFixtures.jsonConnectKeyEncoded import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN @@ -31,16 +33,21 @@ import com.duckduckgo.sync.impl.Clipboard import com.duckduckgo.sync.impl.Result.Error import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.SyncAccountRepository +import com.duckduckgo.sync.impl.SyncFeature +import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState.Idle -import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSucess +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.AskToSwitchAccount +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSuccess import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.ShowError +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.SwitchAccountSuccess import kotlinx.coroutines.test.runTest 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.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -52,11 +59,17 @@ internal class EnterCodeViewModelTest { private val syncAccountRepository: SyncAccountRepository = mock() private val clipboard: Clipboard = mock() + private val syncFeature = FakeFeatureToggleFactory.create(SyncFeature::class.java).apply { + this.seamlessAccountSwitching().setRawStoredState(State(true)) + } + private val syncPixels: SyncPixels = mock() private val testee = EnterCodeViewModel( syncAccountRepository, clipboard, coroutineTestRule.testDispatcherProvider, + syncFeature = syncFeature, + syncPixels = syncPixels, ) @Test @@ -86,7 +99,7 @@ internal class EnterCodeViewModelTest { testee.commands().test { val command = awaitItem() - assertTrue(command is LoginSucess) + assertTrue(command is LoginSuccess) cancelAndIgnoreRemainingEvents() } } @@ -100,7 +113,7 @@ internal class EnterCodeViewModelTest { testee.commands().test { val command = awaitItem() - assertTrue(command is LoginSucess) + assertTrue(command is LoginSuccess) cancelAndIgnoreRemainingEvents() } } @@ -121,6 +134,7 @@ internal class EnterCodeViewModelTest { @Test fun whenProcessCodeButUserSignedInThenShowError() = runTest { + syncFeature.seamlessAccountSwitching().setRawStoredState(State(false)) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = ALREADY_SIGNED_IN.code)) @@ -133,6 +147,61 @@ internal class EnterCodeViewModelTest { } } + @Test + fun whenProcessCodeButUserSignedInThenOfferToSwitchAccount() = runTest { + whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) + whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = ALREADY_SIGNED_IN.code)) + + testee.onPasteCodeClicked() + + testee.commands().test { + val command = awaitItem() + assertTrue(command is AskToSwitchAccount) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest { + whenever(syncAccountRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + + testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded) + + testee.commands().test { + val command = awaitItem() + assertTrue(command is SwitchAccountSuccess) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest { + whenever(syncAccountRepository.isSignedIn()).thenReturn(true) + whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) + + testee.commands().test { + testee.onPasteCodeClicked() + val command = awaitItem() + assertTrue(command is SwitchAccountSuccess) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenSignedOutUserScansRecoveryCodeAndLoginSucceedsThenReturnLoginSuccess() = runTest { + whenever(syncAccountRepository.isSignedIn()).thenReturn(false) + whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) + + testee.commands().test { + testee.onPasteCodeClicked() + val command = awaitItem() + assertTrue(command is LoginSuccess) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenProcessCodeAndLoginFailsThenShowError() = runTest { whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt index 341736152591..9fd327b4edd6 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt @@ -19,6 +19,8 @@ package com.duckduckgo.sync.impl.ui import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.sync.TestSyncFixtures import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN @@ -26,10 +28,13 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Clipboard import com.duckduckgo.sync.impl.QREncoder import com.duckduckgo.sync.impl.Result +import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.SyncAccountRepository +import com.duckduckgo.sync.impl.SyncFeature import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.LoginSuccess +import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Assert.assertTrue @@ -53,6 +58,9 @@ class SyncWithAnotherDeviceViewModelTest { private val clipboard: Clipboard = mock() private val qrEncoder: QREncoder = mock() private val syncPixels: SyncPixels = mock() + private val syncFeature = FakeFeatureToggleFactory.create(SyncFeature::class.java).apply { + this.seamlessAccountSwitching().setRawStoredState(State(true)) + } private val testee = SyncWithAnotherActivityViewModel( syncRepository, @@ -60,6 +68,7 @@ class SyncWithAnotherDeviceViewModelTest { clipboard, syncPixels, coroutineTestRule.testDispatcherProvider, + syncFeature, ) @Test @@ -123,6 +132,7 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenUserScansRecoveryCodeButSignedInThenCommandIsError() = runTest { + syncFeature.seamlessAccountSwitching().setRawStoredState(State(false)) whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code)) testee.commands().test { testee.onQRCodeScanned(jsonRecoveryKeyEncoded) @@ -133,6 +143,59 @@ class SyncWithAnotherDeviceViewModelTest { } } + @Test + fun whenUserScansRecoveryCodeButSignedInThenCommandIsAskToSwitchAccount() = runTest { + whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code)) + testee.commands().test { + testee.onQRCodeScanned(jsonRecoveryKeyEncoded) + val command = awaitItem() + assertTrue(command is Command.AskToSwitchAccount) + verifyNoInteractions(syncPixels) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest { + whenever(syncRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + + testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded) + + testee.commands().test { + val command = awaitItem() + assertTrue(command is SwitchAccountSuccess) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest { + whenever(syncRepository.isSignedIn()).thenReturn(true) + whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + + testee.commands().test { + testee.onQRCodeScanned(jsonRecoveryKeyEncoded) + val command = awaitItem() + assertTrue(command is SwitchAccountSuccess) + verify(syncPixels, times(1)).fireLoginPixel() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenSignedOutUserScansRecoveryCodeAndLoginSucceedsThenReturnLoginSuccess() = runTest { + whenever(syncRepository.isSignedIn()).thenReturn(false) + whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + + testee.commands().test { + testee.onQRCodeScanned(jsonRecoveryKeyEncoded) + val command = awaitItem() + assertTrue(command is LoginSuccess) + verify(syncPixels, times(1)).fireLoginPixel() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenUserScansRecoveryQRCodeAndConnectDeviceFailsThenCommandIsError() = runTest { whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = LOGIN_FAILED.code))