From de3d33f20b0fce5599e911f1ee226f7bd744660b Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Fri, 23 Aug 2024 14:48:36 +0200 Subject: [PATCH] Integrate web version of the breakage reporting form (#4927) Task/Issue URL: https://app.asana.com/0/1205648422731273/1208115613742938/f ### Description This PR adds web implementation of the site breakage reporting form. The web form needs adjustments before it's ready to ship, so it should be hidden behind a feature flag for now. ### Steps to test this PR #### Native form Privacy Dashboard flow: - [x] Navigate to a website - [x] Tap on the shield icon to open Privacy Dashboard - [x] Tap "Website not working?" to open broken site form - [x] Verify that the native form is opened - [x] Select category, enter description and submit report - [x] Verify that both `epbf` and `m_bsr` pixels are sent - [x] Verify that `reportFlow` param on the `epbf` pixel is set to `dashboard` Browser menu flow: - [x] Navigate to a website - [x] Tap on "Report Broken Site" in the options menu - [x] Verify that the native form is opened - [x] Select category, enter description and submit report - [x] Verify that `reportFlow` param on the `epbf` pixel is set to `menu` #### Web form - [x] To enable web form, override remote config url with this: https://jsonblob.com/api/1276082387480862720 Privacy Dashboard flow: - [x] Navigate to a website - [x] Tap on the shield icon to open Privacy Dashboard - [x] Tap "Website not working?" to open broken site form - [x] Verify that the web form is opened - [x] Select category, enter description and submit report - [x] Verify that both `epbf` and `m_bsr` pixels are sent - [x] Verify that `reportFlow` param on the epbf pixel is set to `dashboard` Browser menu flow: - [x] Navigate to a website - [x] Tap on "Report Broken Site" in the options menu - [x] Verify that the web form is opened - [x] Select category, enter description and submit report - [x] Verify that `reportFlow` param on the epbf pixel is set to `menu` ### UI changes There are no changes to the native form UI. Web UI remains disabled until design is finalized. --- .../app/brokensite/BrokenSiteActivity.kt | 6 +- .../app/brokensite/BrokenSiteViewModel.kt | 32 ++-- .../app/brokensite/api/BrokenSiteSender.kt | 40 +++-- .../app/brokensite/model/BrokenSite.kt | 25 --- .../duckduckgo/app/browser/BrowserActivity.kt | 4 +- .../app/browser/BrowserTabFragment.kt | 18 +- .../brokensite/api/BrokenSiteSubmitterTest.kt | 101 ++++++++++- .../app/feedback/BrokenSiteViewModelTest.kt | 168 ++---------------- .../BrokenSitesMultipleReportReferenceTest.kt | 12 +- .../brokensites/BrokenSitesReferenceTest.kt | 18 +- .../brokensite/api/BrokenSiteSender.kt | 44 +++++ .../build/app/public/js/base.js | 134 +++++++++++--- package-lock.json | 98 ++++++++-- package.json | 2 +- ... => PrivacyDashboardHybridScreenParams.kt} | 13 +- .../dashboard/api/ui/WebBrokenSiteForm.kt | 24 +++ .../privacy-dashboard-impl/build.gradle | 1 + .../impl/WebBrokenSiteFormFeature.kt | 33 ++++ .../impl/ui/PrivacyDashboardHybridActivity.kt | 44 +++-- .../ui/PrivacyDashboardHybridViewModel.kt | 105 ++++++++++- .../ui/PrivacyDashboardJavascriptInterface.kt | 6 + .../impl/ui/PrivacyDashboardPayloadAdapter.kt | 12 ++ .../impl/ui/PrivacyDashboardRenderer.kt | 11 +- .../ui/PrivacyDashboardRendererFactory.kt | 2 + .../impl/ui/WebBrokenSiteFormImpl.kt | 31 ++++ .../ui/PrivacyDashboardHybridViewModelTest.kt | 136 +++++++++++++- .../impl/ui/PrivacyDashboardRendererTest.kt | 27 ++- 27 files changed, 827 insertions(+), 320 deletions(-) create mode 100644 broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.kt rename privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/{PrivacyDashboardScrens.kt => PrivacyDashboardHybridScreenParams.kt} (65%) create mode 100644 privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/WebBrokenSiteForm.kt create mode 100644 privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/WebBrokenSiteFormFeature.kt create mode 100644 privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/WebBrokenSiteFormImpl.kt diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteActivity.kt b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteActivity.kt index cc810c8ccaf3..0316c651e330 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteActivity.kt @@ -33,7 +33,6 @@ import com.duckduckgo.app.brokensite.model.BrokenSiteCategory import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.ActivityBrokenSiteBinding import com.duckduckgo.appbuildconfig.api.AppBuildConfig -import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext @@ -55,8 +54,6 @@ class BrokenSiteActivity : DuckDuckGoActivity() { private val binding: ActivityBrokenSiteBinding by viewBinding() private val viewModel: BrokenSiteViewModel by bindViewModel() - @Inject lateinit var webViewVersionProvider: WebViewVersionProvider - @Inject lateinit var appBuildConfig: AppBuildConfig private val toolbar @@ -144,10 +141,9 @@ class BrokenSiteActivity : DuckDuckGoActivity() { } brokenSites.submitButton.setOnClickListener { if (!submitted) { - val webViewVersion = webViewVersionProvider.getFullVersion() val description = brokenSites.brokenSiteFormFeedbackInput.text val loginSite = brokenSites.brokenSiteFormLoginInput.text - viewModel.onSubmitPressed(webViewVersion, description, loginSite) + viewModel.onSubmitPressed(description, loginSite) submitted = true } } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt index acb14e4c5052..efb2e4af6f13 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt @@ -22,11 +22,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.brokensite.BrokenSiteViewModel.ViewState -import com.duckduckgo.app.brokensite.api.BrokenSiteSender -import com.duckduckgo.app.brokensite.model.BrokenSite import com.duckduckgo.app.brokensite.model.BrokenSiteCategory import com.duckduckgo.app.brokensite.model.BrokenSiteCategory.* -import com.duckduckgo.app.brokensite.model.ReportFlow as BrokenSiteModelReportFlow import com.duckduckgo.app.brokensite.model.SiteProtectionsState import com.duckduckgo.app.brokensite.model.SiteProtectionsState.DISABLED import com.duckduckgo.app.brokensite.model.SiteProtectionsState.DISABLED_BY_REMOTE_CONFIG @@ -35,6 +32,9 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.brokensite.api.BrokenSite +import com.duckduckgo.brokensite.api.BrokenSiteSender +import com.duckduckgo.brokensite.api.ReportFlow as BrokenSiteModelReportFlow import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.DASHBOARD import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU @@ -43,7 +43,6 @@ import com.duckduckgo.common.utils.SingleLiveEvent import com.duckduckgo.common.utils.extractDomain import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.feature.toggles.api.FeatureToggle -import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.UnprotectedTemporary @@ -62,7 +61,6 @@ import kotlinx.coroutines.launch class BrokenSiteViewModel @Inject constructor( private val pixel: Pixel, private val brokenSiteSender: BrokenSiteSender, - private val ampLinks: AmpLinks, private val featureToggle: FeatureToggle, private val contentBlocking: ContentBlocking, private val unprotectedTemporary: UnprotectedTemporary, @@ -199,29 +197,21 @@ class BrokenSiteViewModel @Inject constructor( } } - fun onSubmitPressed(webViewVersion: String, description: String?, loginSite: String?) { + fun onSubmitPressed( + description: String?, + loginSite: String?, + ) { viewState.value?.submitAllowed = false if (url.isNotEmpty()) { - val lastAmpLinkInfo = ampLinks.lastAmpLinkInfo - val loginSiteFinal = if (shuffledCategories.elementAtOrNull(viewValue.indexSelected)?.key == BrokenSiteCategory.LOGIN_CATEGORY_KEY) { loginSite } else { "" } - val brokenSite = if (lastAmpLinkInfo?.destinationUrl == url) { - getBrokenSite(lastAmpLinkInfo.ampLink, webViewVersion, description, loginSiteFinal) - } else { - getBrokenSite(url, webViewVersion, description, loginSiteFinal) - } + val brokenSite = getBrokenSite(url, description, loginSiteFinal) brokenSiteSender.submitBrokenSiteFeedback(brokenSite) - - pixel.fire( - AppPixelName.BROKEN_SITE_REPORTED, - mapOf(Pixel.PixelParameter.URL to brokenSite.siteUrl), - ) } command.value = Command.ConfirmAndFinish } @@ -256,7 +246,6 @@ class BrokenSiteViewModel @Inject constructor( @VisibleForTesting fun getBrokenSite( urlString: String, - webViewVersion: String, description: String?, loginSite: String?, ): BrokenSite { @@ -268,7 +257,6 @@ class BrokenSiteViewModel @Inject constructor( upgradeHttps = upgradedHttps, blockedTrackers = blockedTrackers, surrogates = surrogates, - webViewVersion = webViewVersion, siteType = if (isDesktopMode) DESKTOP_SITE else MOBILE_SITE, urlParametersRemoved = urlParametersRemoved, consentManaged = consentManaged, @@ -279,8 +267,8 @@ class BrokenSiteViewModel @Inject constructor( loginSite = loginSite, reportFlow = reportFlow?.mapToBrokenSiteModelReportFlow(), userRefreshCount = userRefreshCount, - openerContext = openerContext, - jsPerformance = jsPerformance, + openerContext = openerContext?.context, + jsPerformance = jsPerformance?.toList(), ) } 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 4837a449f259..2e0dbcb928a9 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 @@ -18,10 +18,6 @@ package com.duckduckgo.app.brokensite.api import android.net.Uri import androidx.core.net.toUri -import com.duckduckgo.app.brokensite.model.BrokenSite -import com.duckduckgo.app.brokensite.model.ReportFlow -import com.duckduckgo.app.brokensite.model.ReportFlow.DASHBOARD -import com.duckduckgo.app.brokensite.model.ReportFlow.MENU import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository @@ -29,7 +25,13 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.brokensite.api.BrokenSite import com.duckduckgo.brokensite.api.BrokenSiteLastSentReport +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.browser.api.WebViewVersionProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.absoluteString import com.duckduckgo.common.utils.domain @@ -38,6 +40,7 @@ import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.networkprotection.api.NetworkProtectionState +import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig @@ -51,10 +54,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber -interface BrokenSiteSender { - fun submitBrokenSiteFeedback(brokenSite: BrokenSite) -} - @ContributesBinding(AppScope::class) class BrokenSiteSubmitter @Inject constructor( private val statisticsStore: StatisticsDataStore, @@ -73,18 +72,26 @@ class BrokenSiteSubmitter @Inject constructor( private val brokenSiteLastSentReport: BrokenSiteLastSentReport, private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels, private val networkProtectionState: NetworkProtectionState, + private val webViewVersionProvider: WebViewVersionProvider, + private val ampLinks: AmpLinks, ) : BrokenSiteSender { override fun submitBrokenSiteFeedback(brokenSite: BrokenSite) { appCoroutineScope.launch(dispatcherProvider.io()) { val isGpcEnabled = (featureToggle.isFeatureEnabled(PrivacyFeatureName.GpcFeatureName.value) && gpc.isEnabled()).toString() - val absoluteUrl = Uri.parse(brokenSite.siteUrl).absoluteString - val domain = brokenSite.siteUrl.toUri().domain() + val ampLink = ampLinks.lastAmpLinkInfo + ?.takeIf { it.destinationUrl == brokenSite.siteUrl } + ?.ampLink + + val siteUrl = ampLink ?: brokenSite.siteUrl + val absoluteUrl = Uri.parse(siteUrl).absoluteString + val domain = siteUrl.toUri().domain() + val protectionsState = !userAllowListRepository.isDomainInUserAllowList(domain) && - !unprotectedTemporary.isAnException(brokenSite.siteUrl) && + !unprotectedTemporary.isAnException(siteUrl) && featureToggle.isFeatureEnabled(PrivacyFeatureName.ContentBlockingFeatureName.value) && - !contentBlocking.isAnException(brokenSite.siteUrl) + !contentBlocking.isAnException(siteUrl) val vpnOn = runCatching { networkProtectionState.isRunning() }.getOrNull() val locale = appBuildConfig.deviceLocale.toSanitizedLanguageTag() @@ -100,7 +107,7 @@ class BrokenSiteSubmitter @Inject constructor( OS_KEY to appBuildConfig.sdkInt.toString(), MANUFACTURER_KEY to appBuildConfig.manufacturer, MODEL_KEY to appBuildConfig.model, - WEBVIEW_VERSION_KEY to brokenSite.webViewVersion, + WEBVIEW_VERSION_KEY to webViewVersionProvider.getFullVersion(), SITE_TYPE_KEY to brokenSite.siteType, GPC to isGpcEnabled, URL_PARAMETERS_REMOVED to brokenSite.urlParametersRemoved.toBinaryString(), @@ -115,7 +122,7 @@ class BrokenSiteSubmitter @Inject constructor( VPN_ON to vpnOn.toString(), LOCALE to locale, USER_REFRESH_COUNT to brokenSite.userRefreshCount.toString(), - OPENER_CONTEXT to brokenSite.openerContext?.context.orEmpty(), + OPENER_CONTEXT to brokenSite.openerContext.orEmpty(), JS_PERFORMANCE to brokenSite.jsPerformance?.joinToString(",").orEmpty(), ) @@ -148,6 +155,11 @@ class BrokenSiteSubmitter @Inject constructor( } } .onFailure { Timber.w(it, "Feedback submission failed") } + + pixel.fire( + AppPixelName.BROKEN_SITE_REPORTED, + mapOf(Pixel.PixelParameter.URL to siteUrl), + ) } } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/model/BrokenSite.kt b/app/src/main/java/com/duckduckgo/app/brokensite/model/BrokenSite.kt index 7e39b052cac4..03759d2bf640 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/model/BrokenSite.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/model/BrokenSite.kt @@ -18,29 +18,6 @@ package com.duckduckgo.app.brokensite.model import androidx.annotation.StringRes import com.duckduckgo.app.browser.R -import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext - -data class BrokenSite( - val category: String?, - val description: String?, - val siteUrl: String, - val upgradeHttps: Boolean, - val blockedTrackers: String, - val surrogates: String, - val webViewVersion: String, - val siteType: String, - val urlParametersRemoved: Boolean, - val consentManaged: Boolean, - val consentOptOutFailed: Boolean, - val consentSelfTestFailed: Boolean, - val errorCodes: String, - val httpErrorCodes: String, - val loginSite: String?, - val reportFlow: ReportFlow?, - val userRefreshCount: Int, - val openerContext: BrokenSiteOpenerContext?, - val jsPerformance: DoubleArray?, -) sealed class BrokenSiteCategory( @StringRes val category: Int, @@ -68,5 +45,3 @@ sealed class BrokenSiteCategory( const val OTHER_CATEGORY_KEY = "other" } } - -enum class ReportFlow { DASHBOARD, MENU } 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 46af22a59f89..aa8fd23901fc 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -72,7 +72,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.playstore.PlayStoreUtils import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.navigation.api.GlobalActivityStarter -import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreen.PrivacyDashboardHybridWithTabIdParam +import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreenParams.PrivacyDashboardPrimaryScreen import com.duckduckgo.savedsites.impl.bookmarks.BookmarksActivity.Companion.SAVED_SITE_URL_EXTRA import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -434,7 +434,7 @@ open class BrowserActivity : DuckDuckGoActivity() { fun launchPrivacyDashboard() { currentTab?.tabId?.let { - val params = PrivacyDashboardHybridWithTabIdParam(it) + val params = PrivacyDashboardPrimaryScreen(it) val intent = globalActivityStarter.startIntent(this, params) intent?.let { startActivity(it) } } 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 b39143dcb75c..f6bcb0d75249 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -260,7 +260,9 @@ import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.navigation.api.GlobalActivityStarter.DeeplinkActivityParams -import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreen +import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreenParams +import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreenParams.BrokenSiteForm +import com.duckduckgo.privacy.dashboard.api.ui.WebBrokenSiteForm import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopup import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupFactory import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupViewState @@ -500,6 +502,9 @@ class BrowserTabFragment : @Inject lateinit var safeWebViewFeature: SafeWebViewFeature + @Inject + lateinit var webBrokenSiteForm: WebBrokenSiteForm + /** * We use this to monitor whether the user was seeing the in-context Email Protection signup prompt * This is needed because the activity stack will be cleared if an external link is opened in our browser @@ -895,7 +900,7 @@ class BrowserTabFragment : } omnibar.customTabToolbarContainer.customTabShieldIcon.setOnClickListener { _ -> - val params = PrivacyDashboardHybridScreen.PrivacyDashboardHybridWithTabIdParam(tabId) + val params = PrivacyDashboardHybridScreenParams.PrivacyDashboardPrimaryScreen(tabId) val intent = globalActivityStarter.startIntent(requireContext(), params) contentScopeScripts.sendSubscriptionEvent(createBreakageReportingEventData()) intent?.let { startActivity(it) } @@ -1819,9 +1824,14 @@ class BrowserTabFragment : } private fun launchBrokenSiteFeedback(data: BrokenSiteData) { - context?.let { + val context = context ?: return + + if (webBrokenSiteForm.shouldUseWebBrokenSiteForm()) { + globalActivityStarter.startIntent(context, BrokenSiteForm(tabId)) + ?.let { startActivity(it) } + } else { val options = ActivityOptions.makeSceneTransitionAnimation(browserActivity).toBundle() - startActivity(BrokenSiteActivity.intent(it, data), options) + startActivity(BrokenSiteActivity.intent(context, data), options) } } diff --git a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt index 01a2e842b6ad..072e559151b3 100644 --- a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt +++ b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt @@ -2,10 +2,8 @@ package com.duckduckgo.app.brokensite.api import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.brokensite.BrokenSiteViewModel -import com.duckduckgo.app.brokensite.model.BrokenSite -import com.duckduckgo.app.brokensite.model.ReportFlow.DASHBOARD -import com.duckduckgo.app.brokensite.model.ReportFlow.MENU import com.duckduckgo.app.pixels.AppPixelName.BROKEN_SITE_REPORT +import com.duckduckgo.app.pixels.AppPixelName.BROKEN_SITE_REPORTED import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel @@ -13,7 +11,11 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.brokensite.api.BrokenSite import com.duckduckgo.brokensite.api.BrokenSiteLastSentReport +import com.duckduckgo.brokensite.api.ReportFlow.DASHBOARD +import com.duckduckgo.brokensite.api.ReportFlow.MENU +import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext.EXTERNAL import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext.NAVIGATION import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext.SERP @@ -21,6 +23,8 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.networkprotection.api.NetworkProtectionState +import com.duckduckgo.privacy.config.api.AmpLinkInfo +import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig @@ -79,6 +83,10 @@ class BrokenSiteSubmitterTest { runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } } + private val webViewVersionProvider: WebViewVersionProvider = mock() + + private val ampLinks: AmpLinks = mock() + private lateinit var testee: BrokenSiteSubmitter @Before @@ -112,6 +120,8 @@ class BrokenSiteSubmitterTest { mockBrokenSiteLastSentReport, privacyProtectionsPopupExperimentExternalPixels, networkProtectionState, + webViewVersionProvider, + ampLinks, ) } @@ -390,7 +400,7 @@ class BrokenSiteSubmitterTest { @Test fun whenOpenerContextIsSerpThenIncludeParam() { val brokenSite = getBrokenSite() - .copy(openerContext = SERP) + .copy(openerContext = SERP.context) testee.submitBrokenSiteFeedback(brokenSite) @@ -404,7 +414,7 @@ class BrokenSiteSubmitterTest { @Test fun whenOpenerContextIsExternalThenIncludeParam() { val brokenSite = getBrokenSite() - .copy(openerContext = EXTERNAL) + .copy(openerContext = EXTERNAL.context) testee.submitBrokenSiteFeedback(brokenSite) @@ -418,7 +428,7 @@ class BrokenSiteSubmitterTest { @Test fun whenOpenerContextIsNavigationThenIncludeParam() { val brokenSite = getBrokenSite() - .copy(openerContext = NAVIGATION) + .copy(openerContext = NAVIGATION.context) testee.submitBrokenSiteFeedback(brokenSite) @@ -458,7 +468,7 @@ class BrokenSiteSubmitterTest { @Test fun whenJsPerformanceExistsThenIncludeParam() { val brokenSite = getBrokenSite() - .copy(jsPerformance = doubleArrayOf(123.45)) + .copy(jsPerformance = listOf(123.45)) testee.submitBrokenSiteFeedback(brokenSite) @@ -469,6 +479,78 @@ class BrokenSiteSubmitterTest { assertEquals("123.45", params["jsPerformance"]) } + @Test + fun whenSubmitReportThenIncludeWebViewVersion() { + val webViewVersion = "some WebView version" + whenever(webViewVersionProvider.getFullVersion()).thenReturn(webViewVersion) + + testee.submitBrokenSiteFeedback(getBrokenSite()) + + val paramsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + val params = paramsCaptor.firstValue + + assertEquals(webViewVersion, params["wvVersion"]) + } + + @Test + fun whenSubmitReportThenSendBothPixels() { + val brokenSite = getBrokenSite() + testee.submitBrokenSiteFeedback(brokenSite) + + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), any(), any(), eq(COUNT)) + + val paramsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORTED), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + val params = paramsCaptor.firstValue + assertEquals(brokenSite.siteUrl, params[Pixel.PixelParameter.URL]) + } + + @Test + fun whenSubmitReportAndAmpLinkIsNullThenUseSiteUrl() { + val brokenSite = getBrokenSite() + whenever(ampLinks.lastAmpLinkInfo).thenReturn(null) + + testee.submitBrokenSiteFeedback(brokenSite) + + val paramsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + assertEquals(brokenSite.siteUrl, paramsCaptor.lastValue["siteUrl"]) + + verify(mockPixel).fire(eq(BROKEN_SITE_REPORTED), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + assertEquals(brokenSite.siteUrl, paramsCaptor.lastValue[Pixel.PixelParameter.URL]) + } + + @Test + fun whenSubmitReportAndAmpLinkDoesNotMatchThenUseSiteUrl() { + val brokenSite = getBrokenSite() + whenever(ampLinks.lastAmpLinkInfo).thenReturn(AmpLinkInfo(ampLink = TRACKING_URL, destinationUrl = "https://someotherurl.com")) + + testee.submitBrokenSiteFeedback(brokenSite) + + val paramsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + assertEquals(brokenSite.siteUrl, paramsCaptor.lastValue["siteUrl"]) + + verify(mockPixel).fire(eq(BROKEN_SITE_REPORTED), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + assertEquals(brokenSite.siteUrl, paramsCaptor.lastValue[Pixel.PixelParameter.URL]) + } + + @Test + fun whenSubmitReportAndAmpLinkMatchesThenReplaceSiteUrlWithAmpLink() { + val brokenSite = getBrokenSite() + whenever(ampLinks.lastAmpLinkInfo).thenReturn(AmpLinkInfo(ampLink = TRACKING_URL, destinationUrl = brokenSite.siteUrl)) + + testee.submitBrokenSiteFeedback(brokenSite) + + val paramsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + assertEquals(TRACKING_URL, paramsCaptor.lastValue["siteUrl"]) + + verify(mockPixel).fire(eq(BROKEN_SITE_REPORTED), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + assertEquals(TRACKING_URL, paramsCaptor.lastValue[Pixel.PixelParameter.URL]) + } + private fun getBrokenSite(): BrokenSite { return BrokenSite( category = "category", @@ -477,7 +559,6 @@ class BrokenSiteSubmitterTest { upgradeHttps = true, blockedTrackers = "", surrogates = "", - webViewVersion = "webViewVersion", siteType = BrokenSiteViewModel.DESKTOP_SITE, urlParametersRemoved = false, consentManaged = false, @@ -492,4 +573,8 @@ class BrokenSiteSubmitterTest { jsPerformance = null, ) } + + private companion object { + const val TRACKING_URL = "https://foo.com" + } } diff --git a/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt index 70a91998b4dc..56c1964de8b9 100644 --- a/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt @@ -21,20 +21,18 @@ import androidx.lifecycle.Observer import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.brokensite.BrokenSiteViewModel import com.duckduckgo.app.brokensite.BrokenSiteViewModel.Command -import com.duckduckgo.app.brokensite.api.BrokenSiteSender -import com.duckduckgo.app.brokensite.model.BrokenSite import com.duckduckgo.app.brokensite.model.BrokenSiteCategory -import com.duckduckgo.app.brokensite.model.ReportFlow import com.duckduckgo.app.brokensite.model.SiteProtectionsState import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.brokensite.api.BrokenSite +import com.duckduckgo.brokensite.api.BrokenSiteSender +import com.duckduckgo.brokensite.api.ReportFlow import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.feature.toggles.api.FeatureToggle -import com.duckduckgo.privacy.config.api.AmpLinkInfo -import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.UnprotectedTemporary @@ -77,8 +75,6 @@ class BrokenSiteViewModelTest { private val mockCommandObserver: Observer = mock() - private val mockAmpLinks: AmpLinks = mock() - private val mockFeatureToggle: FeatureToggle = mock() private val mockContentBlocking: ContentBlocking = mock() @@ -104,7 +100,6 @@ class BrokenSiteViewModelTest { testee = BrokenSiteViewModel( mockPixel, mockBrokenSiteSender, - mockAmpLinks, mockFeatureToggle, mockContentBlocking, mockUnprotectedTemporary, @@ -175,7 +170,7 @@ class BrokenSiteViewModelTest { jsPerformance = null, ) selectAndAcceptCategory() - testee.onSubmitPressed("webViewVersion", "description", "") + testee.onSubmitPressed("description", "") val brokenSiteExpected = BrokenSite( category = testee.shuffledCategories[0].key, @@ -184,7 +179,6 @@ class BrokenSiteViewModelTest { upgradeHttps = false, blockedTrackers = "", surrogates = "", - webViewVersion = "webViewVersion", siteType = BrokenSiteViewModel.MOBILE_SITE, urlParametersRemoved = false, consentManaged = false, @@ -199,7 +193,6 @@ class BrokenSiteViewModelTest { jsPerformance = null, ) - verify(mockPixel).fire(AppPixelName.BROKEN_SITE_REPORTED, mapOf("url" to url)) verify(mockBrokenSiteSender).submitBrokenSiteFeedback(brokenSiteExpected) verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) } @@ -225,7 +218,7 @@ class BrokenSiteViewModelTest { jsPerformance = null, ) selectAndAcceptCategory() - testee.onSubmitPressed("webViewVersion", "description", "") + testee.onSubmitPressed("description", "") val brokenSiteExpected = BrokenSite( category = testee.shuffledCategories[0].key, @@ -234,7 +227,6 @@ class BrokenSiteViewModelTest { upgradeHttps = false, blockedTrackers = "", surrogates = "", - webViewVersion = "webViewVersion", siteType = BrokenSiteViewModel.DESKTOP_SITE, urlParametersRemoved = false, consentManaged = false, @@ -249,145 +241,10 @@ class BrokenSiteViewModelTest { jsPerformance = null, ) - verify(mockPixel, never()).fire(AppPixelName.BROKEN_SITE_REPORTED, mapOf("url" to nullUrl)) verify(mockBrokenSiteSender, never()).submitBrokenSiteFeedback(brokenSiteExpected) verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) } - @Test - fun whenCanSubmitBrokenSiteAndLastAmpLinkIsNullAndSubmitPressedThenReportUrlAndPixelSubmitted() { - whenever(mockAmpLinks.lastAmpLinkInfo).thenReturn(null) - - testee.setInitialBrokenSite( - url = url, - blockedTrackers = "", - surrogates = "", - upgradedHttps = false, - urlParametersRemoved = false, - consentManaged = false, - consentOptOutFailed = false, - consentSelfTestFailed = false, - errorCodes = emptyArray(), - httpErrorCodes = "", - isDesktopMode = false, - reportFlow = MENU, - userRefreshCount = 0, - openerContext = null, - jsPerformance = null, - ) - selectAndAcceptCategory() - testee.onSubmitPressed("webViewVersion", "description", "") - - val brokenSiteExpected = BrokenSite( - category = testee.shuffledCategories[0].key, - description = "description", - siteUrl = url, - upgradeHttps = false, - blockedTrackers = "", - surrogates = "", - webViewVersion = "webViewVersion", - siteType = BrokenSiteViewModel.MOBILE_SITE, - urlParametersRemoved = false, - consentManaged = false, - consentOptOutFailed = false, - consentSelfTestFailed = false, - errorCodes = "[]", - httpErrorCodes = "", - loginSite = "", - reportFlow = ReportFlow.MENU, - userRefreshCount = 0, - openerContext = null, - jsPerformance = null, - ) - - verify(mockPixel).fire(AppPixelName.BROKEN_SITE_REPORTED, mapOf("url" to url)) - verify(mockBrokenSiteSender).submitBrokenSiteFeedback(brokenSiteExpected) - verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) - } - - @Test - fun whenCanSubmitBrokenSiteAndUrlHasAssociatedAmpLinkAndSubmitPressedThenAmpLinkReportedAndPixelSubmitted() { - whenever(mockAmpLinks.lastAmpLinkInfo).thenReturn(AmpLinkInfo(trackingUrl, url)) - - testee.setInitialBrokenSite( - url = url, - blockedTrackers = "", - surrogates = "", - upgradedHttps = false, - urlParametersRemoved = false, - consentManaged = false, - consentOptOutFailed = false, - consentSelfTestFailed = false, - errorCodes = emptyArray(), - httpErrorCodes = "", - isDesktopMode = false, - reportFlow = MENU, - userRefreshCount = 0, - openerContext = null, - jsPerformance = null, - ) - selectAndAcceptCategory() - testee.onSubmitPressed("webViewVersion", "description", "") - - val brokenSiteExpected = BrokenSite( - category = testee.shuffledCategories[0].key, - description = "description", - siteUrl = trackingUrl, - upgradeHttps = false, - blockedTrackers = "", - surrogates = "", - webViewVersion = "webViewVersion", - siteType = BrokenSiteViewModel.MOBILE_SITE, - urlParametersRemoved = false, - consentManaged = false, - consentOptOutFailed = false, - consentSelfTestFailed = false, - errorCodes = "[]", - httpErrorCodes = "", - loginSite = "", - reportFlow = ReportFlow.MENU, - userRefreshCount = 0, - openerContext = null, - jsPerformance = null, - ) - - verify(mockPixel).fire(AppPixelName.BROKEN_SITE_REPORTED, mapOf("url" to trackingUrl)) - verify(mockBrokenSiteSender).submitBrokenSiteFeedback(brokenSiteExpected) - verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) - } - - @Test - fun whenCanSubmitBrokenSiteAndUrlNotNullAndSubmitPressedThenReportAndPixelSubmittedWithParams() { - whenever(mockAmpLinks.lastAmpLinkInfo).thenReturn(AmpLinkInfo(trackingUrl, url)) - - testee.setInitialBrokenSite( - url = url, - blockedTrackers = "", - surrogates = "", - upgradedHttps = false, - urlParametersRemoved = false, - consentManaged = false, - consentOptOutFailed = false, - consentSelfTestFailed = false, - errorCodes = emptyArray(), - httpErrorCodes = "", - isDesktopMode = false, - reportFlow = MENU, - userRefreshCount = 0, - openerContext = null, - jsPerformance = null, - ) - selectAndAcceptCategory() - testee.onSubmitPressed("webViewVersion", "description", "") - - verify(mockPixel).fire( - AppPixelName.BROKEN_SITE_REPORTED, - mapOf( - "url" to trackingUrl, - ), - ) - } - @Test fun whenIsDesktopModeTrueThenSendDesktopParameter() { testee.setInitialBrokenSite( @@ -409,7 +266,7 @@ class BrokenSiteViewModelTest { ) selectAndAcceptCategory() - val brokenSiteExpected = testee.getBrokenSite(url, "", "", "") + val brokenSiteExpected = testee.getBrokenSite(url, "", "") assertEquals(BrokenSiteViewModel.DESKTOP_SITE, brokenSiteExpected.siteType) } @@ -434,7 +291,7 @@ class BrokenSiteViewModelTest { ) selectAndAcceptCategory() - val brokenSiteExpected = testee.getBrokenSite(url, "", "", "") + val brokenSiteExpected = testee.getBrokenSite(url, "", "") assertEquals(BrokenSiteViewModel.MOBILE_SITE, brokenSiteExpected.siteType) } @@ -462,7 +319,7 @@ class BrokenSiteViewModelTest { selectAndAcceptCategory(categoryIndex) val categoryExpected = testee.shuffledCategories[categoryIndex].key - val brokenSiteExpected = testee.getBrokenSite(url, "", "", "") + val brokenSiteExpected = testee.getBrokenSite(url, "", "") assertEquals(categoryExpected, brokenSiteExpected.category) } @@ -539,7 +396,7 @@ class BrokenSiteViewModelTest { jsPerformance = null, ) selectAndAcceptCategory(categoryIndex) - testee.onSubmitPressed("webViewVersion", "description", "test") + testee.onSubmitPressed("description", "test") val brokenSiteExpected = BrokenSite( category = testee.shuffledCategories[categoryIndex].key, @@ -548,7 +405,6 @@ class BrokenSiteViewModelTest { upgradeHttps = false, blockedTrackers = "", surrogates = "", - webViewVersion = "webViewVersion", siteType = BrokenSiteViewModel.MOBILE_SITE, urlParametersRemoved = false, consentManaged = false, @@ -563,7 +419,6 @@ class BrokenSiteViewModelTest { jsPerformance = null, ) - verify(mockPixel).fire(AppPixelName.BROKEN_SITE_REPORTED, mapOf("url" to url)) verify(mockBrokenSiteSender).submitBrokenSiteFeedback(brokenSiteExpected) verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) } @@ -590,7 +445,7 @@ class BrokenSiteViewModelTest { jsPerformance = null, ) selectAndAcceptCategory(categoryIndex) - testee.onSubmitPressed("webViewVersion", "description", "test") + testee.onSubmitPressed("description", "test") val brokenSiteExpected = BrokenSite( category = testee.shuffledCategories[categoryIndex].key, @@ -599,7 +454,6 @@ class BrokenSiteViewModelTest { upgradeHttps = false, blockedTrackers = "", surrogates = "", - webViewVersion = "webViewVersion", siteType = BrokenSiteViewModel.MOBILE_SITE, urlParametersRemoved = false, consentManaged = false, @@ -614,7 +468,6 @@ class BrokenSiteViewModelTest { jsPerformance = null, ) - verify(mockPixel).fire(AppPixelName.BROKEN_SITE_REPORTED, mapOf("url" to url)) verify(mockBrokenSiteSender).submitBrokenSiteFeedback(brokenSiteExpected) verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) } @@ -906,6 +759,5 @@ class BrokenSiteViewModelTest { companion object Constants { private const val url = "http://example.com" - private const val trackingUrl = "https://foo.com" } } diff --git a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt index 426e9e3bca6a..4ef45111bea6 100644 --- a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt +++ b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt @@ -18,8 +18,6 @@ package com.duckduckgo.app.referencetests.brokensites import com.duckduckgo.app.brokensite.BrokenSiteViewModel import com.duckduckgo.app.brokensite.api.BrokenSiteSubmitter -import com.duckduckgo.app.brokensite.model.BrokenSite -import com.duckduckgo.app.brokensite.model.ReportFlow import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel @@ -27,7 +25,10 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.brokensite.api.BrokenSite import com.duckduckgo.brokensite.api.BrokenSiteLastSentReport +import com.duckduckgo.brokensite.api.ReportFlow.MENU +import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities import com.duckduckgo.experiments.api.VariantManager @@ -91,6 +92,8 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } } + private val webViewVersionProvider: WebViewVersionProvider = mock() + private lateinit var testee: BrokenSiteSubmitter companion object { @@ -135,6 +138,8 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor mockBrokenSiteLastSentReport, privacyProtectionsPopupExperimentExternalPixels, networkProtectionState, + webViewVersionProvider, + ampLinks = mock(), ) } @@ -172,7 +177,6 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor upgradeHttps = report.wasUpgraded, blockedTrackers = report.blockedTrackers.joinToString(","), surrogates = report.surrogates.joinToString(","), - webViewVersion = "webViewVersion", siteType = BrokenSiteViewModel.DESKTOP_SITE, urlParametersRemoved = report.urlParametersRemoved.toBoolean(), consentManaged = report.consentManaged.toBoolean(), @@ -181,7 +185,7 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor errorCodes = "", httpErrorCodes = "", loginSite = null, - reportFlow = ReportFlow.MENU, + reportFlow = MENU, userRefreshCount = 0, openerContext = null, jsPerformance = null, diff --git a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt index f3c261f8aa22..c3d10997fa34 100644 --- a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt +++ b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt @@ -19,8 +19,6 @@ package com.duckduckgo.app.referencetests.brokensites import android.net.Uri import com.duckduckgo.app.brokensite.BrokenSiteViewModel import com.duckduckgo.app.brokensite.api.BrokenSiteSubmitter -import com.duckduckgo.app.brokensite.model.BrokenSite -import com.duckduckgo.app.brokensite.model.ReportFlow import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.model.Atb @@ -29,7 +27,10 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig -import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext +import com.duckduckgo.brokensite.api.BrokenSite +import com.duckduckgo.brokensite.api.ReportFlow.MENU +import com.duckduckgo.browser.api.WebViewVersionProvider +import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext.SERP import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities import com.duckduckgo.experiments.api.VariantManager @@ -90,6 +91,8 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } } + private val webViewVersionProvider: WebViewVersionProvider = mock() + private lateinit var testee: BrokenSiteSubmitter companion object { @@ -133,6 +136,8 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { mock(), privacyProtectionsPopupExperimentExternalPixels, networkProtectionState, + webViewVersionProvider, + ampLinks = mock(), ) } @@ -160,7 +165,6 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { upgradeHttps = testCase.wasUpgraded, blockedTrackers = testCase.blockedTrackers.joinToString(","), surrogates = testCase.surrogates.joinToString(","), - webViewVersion = "webViewVersion", siteType = BrokenSiteViewModel.DESKTOP_SITE, urlParametersRemoved = testCase.urlParametersRemoved.toBoolean(), consentManaged = testCase.consentManaged.toBoolean(), @@ -169,10 +173,10 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { errorCodes = "", httpErrorCodes = "", loginSite = null, - reportFlow = ReportFlow.MENU, + reportFlow = MENU, userRefreshCount = 3, - openerContext = BrokenSiteOpenerContext.SERP, - jsPerformance = doubleArrayOf(123.45), + openerContext = SERP.context, + jsPerformance = listOf(123.45), ) testee.submitBrokenSiteFeedback(brokenSite) 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 new file mode 100644 index 000000000000..e56e47bfbfaa --- /dev/null +++ b/broken-site/broken-site-api/src/main/java/com/duckduckgo/brokensite/api/BrokenSiteSender.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.brokensite.api + +interface BrokenSiteSender { + fun submitBrokenSiteFeedback(brokenSite: BrokenSite) +} + +data class BrokenSite( + val category: String?, + val description: String?, + val siteUrl: String, + val upgradeHttps: Boolean, + val blockedTrackers: String, + val surrogates: String, + val siteType: String, + val urlParametersRemoved: Boolean, + val consentManaged: Boolean, + val consentOptOutFailed: Boolean, + val consentSelfTestFailed: Boolean, + val errorCodes: String, + val httpErrorCodes: String, + val loginSite: String?, + val reportFlow: ReportFlow?, + val userRefreshCount: Int, + val openerContext: String?, + val jsPerformance: List?, +) + +enum class ReportFlow { DASHBOARD, MENU } diff --git a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/js/base.js b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/js/base.js index 72bc8141ee5c..0ab91c3b9a8b 100644 --- a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/js/base.js +++ b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/js/base.js @@ -4577,7 +4577,7 @@ }); // schema/__generated__/schema.parsers.mjs - var protectionsDisabledReasonSchema, ownedByFirstPartyReasonSchema, ruleExceptionReasonSchema, adClickAttributionReasonSchema, otherThirdPartyRequestReasonSchema, screenKindSchema, wvVersionTitleSchema, requestsTitleSchema, featuresTitleSchema, appVersionTitleSchema, atbTitleSchema, errorDescriptionsTitleSchema, extensionVersionTitleSchema, httpErrorCodesTitleSchema, lastSentDayTitleSchema, deviceTitleSchema, osTitleSchema, listVersionsTitleSchema, reportFlowTitleSchema, siteUrlTitleSchema, didOpenReportInfoTitleSchema, toggleReportCounterTitleSchema, openerContextTitleSchema, userRefreshCountTitleSchema, jsPerformanceTitleSchema, stateBlockedSchema, stateAllowedSchema, extensionMessageGetPrivacyDashboardDataSchema, emailProtectionUserDataSchema, protectionsStatusSchema, localeSettingsSchema, parentEntitySchema, fireButtonSchema, searchSchema, breakageReportRequestSchema, setListOptionsSchema, windowsIncomingVisibilitySchema, cookiePromptManagementStatusSchema, refreshAliasResponseSchema, extensionMessageSetListOptionsSchema, fireOptionSchema, primaryScreenSchema, eventOriginSchema, siteUrlAdditionalDataSchema, closeMessageParamsSchema, categoryTypeSelectedSchema, categorySelectedSchema, toggleSkippedSchema, dataItemIdSchema, detectedRequestSchema, tabSchema, breakageReportSchema, fireButtonDataSchema, remoteFeatureSettingsSchema, setProtectionParamsSchema, toggleReportScreenDataItemSchema, telemetrySpanSchema, requestDataSchema, getPrivacyDashboardDataSchema, windowsViewModelSchema, toggleReportScreenSchema, windowsIncomingViewModelSchema, windowsIncomingMessageSchema, apiSchema; + var protectionsDisabledReasonSchema, ownedByFirstPartyReasonSchema, ruleExceptionReasonSchema, adClickAttributionReasonSchema, otherThirdPartyRequestReasonSchema, screenKindSchema, wvVersionTitleSchema, requestsTitleSchema, featuresTitleSchema, appVersionTitleSchema, atbTitleSchema, errorDescriptionsTitleSchema, extensionVersionTitleSchema, httpErrorCodesTitleSchema, lastSentDayTitleSchema, deviceTitleSchema, osTitleSchema, listVersionsTitleSchema, reportFlowTitleSchema, siteUrlTitleSchema, didOpenReportInfoTitleSchema, toggleReportCounterTitleSchema, openerContextTitleSchema, userRefreshCountTitleSchema, jsPerformanceTitleSchema, stateBlockedSchema, stateAllowedSchema, extensionMessageGetPrivacyDashboardDataSchema, emailProtectionUserDataSchema, protectionsStatusSchema, localeSettingsSchema, parentEntitySchema, fireButtonSchema, searchSchema, breakageReportRequestSchema, setListOptionsSchema, windowsIncomingVisibilitySchema, cookiePromptManagementStatusSchema, refreshAliasResponseSchema, extensionMessageSetListOptionsSchema, fireOptionSchema, primaryScreenSchema, webBreakageFormSchema, eventOriginSchema, siteUrlAdditionalDataSchema, closeMessageParamsSchema, categoryTypeSelectedSchema, categorySelectedSchema, toggleSkippedSchema, dataItemIdSchema, detectedRequestSchema, tabSchema, breakageReportSchema, fireButtonDataSchema, remoteFeatureSettingsSchema, setProtectionParamsSchema, toggleReportScreenDataItemSchema, telemetrySpanSchema, requestDataSchema, getPrivacyDashboardDataSchema, windowsViewModelSchema, toggleReportScreenSchema, windowsIncomingViewModelSchema, windowsIncomingMessageSchema, apiSchema; var init_schema_parsers = __esm({ "schema/__generated__/schema.parsers.mjs"() { "use strict"; @@ -4695,6 +4695,9 @@ primaryScreenSchema = z3.object({ layout: z3.union([z3.literal("default"), z3.literal("highlighted-protections-toggle")]) }); + webBreakageFormSchema = z3.object({ + state: z3.union([z3.literal("enabled"), z3.literal("disabled")]) + }); eventOriginSchema = z3.object({ screen: screenKindSchema }); @@ -4743,7 +4746,8 @@ options: z3.array(fireOptionSchema) }); remoteFeatureSettingsSchema = z3.object({ - primaryScreen: primaryScreenSchema.optional() + primaryScreen: primaryScreenSchema.optional(), + webBreakageForm: webBreakageFormSchema.optional() }); setProtectionParamsSchema = z3.object({ isProtected: z3.boolean(), @@ -14272,6 +14276,14 @@ openSettings(payload) { window.PrivacyDashboard.openSettings(JSON.stringify(payload)); } + /** + * {@inheritDoc common.submitBrokenSiteReport} + * @type {import("./common.js").submitBrokenSiteReport} + */ + submitBrokenSiteReport(payload) { + invariant(typeof window.PrivacyDashboard?.submitBrokenSiteReport, "window.PrivacyDashboard.submitBrokenSiteReport required"); + window.PrivacyDashboard.submitBrokenSiteReport(JSON.stringify(payload)); + } }; var privacyDashboardApi; async function fetchAndroid(message) { @@ -14286,19 +14298,29 @@ const isProtected = value === false; privacyDashboardApi.toggleAllowlist(isProtected); } + return; } if (message instanceof CloseMessage) { privacyDashboardApi.close(); + return; } if (message instanceof CheckBrokenSiteReportHandledMessage) { privacyDashboardApi.showBreakageForm(); return true; } + if (message instanceof SubmitBrokenSiteReportMessage) { + privacyDashboardApi.submitBrokenSiteReport({ + category: message.category, + description: message.description + }); + return true; + } if (message instanceof OpenSettingsMessages) { - privacyDashboardApi.openSettings({ + return privacyDashboardApi.openSettings({ target: message.target }); } + console.warn("unhandled message", message); } var getBackgroundTabDataAndroid = () => { return new Promise((resolve) => { @@ -14624,6 +14646,9 @@ if (url.searchParams.get("breakageScreen") === "categoryTypeSelection") { breakageScreen = "categoryTypeSelection"; } + let randomisedCategories = true; + if (url.searchParams.get("randomisedCategories") === "false") + randomisedCategories = false; return new PlatformFeatures({ spinnerFollowingProtectionsToggle: platform2.name !== "android" && platform2.name !== "windows", supportsHover: desktop.includes(platform2.name), @@ -14631,7 +14656,8 @@ opener, supportsInvalidCerts: platform2.name !== "browser" && platform2.name !== "windows", includeToggleOnBreakageForm, - breakageScreen + breakageScreen, + randomisedCategories }); } var PlatformFeatures = class { @@ -14644,6 +14670,7 @@ * @param {boolean} params.supportsInvalidCerts * @param {boolean} params.includeToggleOnBreakageForm * @param {InitialScreen} params.breakageScreen + * @param {boolean} params.randomisedCategories */ constructor(params) { this.spinnerFollowingProtectionsToggle = params.spinnerFollowingProtectionsToggle; @@ -14653,21 +14680,35 @@ this.opener = params.opener; this.includeToggleOnBreakageForm = params.includeToggleOnBreakageForm; this.breakageScreen = params.breakageScreen; + this.randomisedCategories = params.randomisedCategories; } }; var FeatureSettings = class _FeatureSettings { /** * @param {object} params * @param {import("../../../schema/__generated__/schema.types").PrimaryScreen} [params.primaryScreen] + * @param {import("../../../schema/__generated__/schema.types").WebBreakageForm} [params.webBreakageForm] */ constructor(params) { this.primaryScreen = params.primaryScreen || { layout: "default" }; + this.webBreakageForm = params.webBreakageForm || { state: "enabled" }; } /** - * + * @param {import("../../../schema/__generated__/schema.types").RemoteFeatureSettings|undefined} settings + * @param {Platform} platform */ - static default() { - return new _FeatureSettings({}); + static create(settings, platform2) { + switch (platform2.name) { + case "android": { + return new _FeatureSettings({ + webBreakageForm: { state: "disabled" }, + ...settings + }); + } + default: { + return new _FeatureSettings(settings || {}); + } + } } }; @@ -14718,7 +14759,7 @@ /** * @param {import('../shared/js/browser/common.js').BackgroundTabData} data */ - accept({ tab, emailProtectionUserData, fireButton }) { + accept({ tab, emailProtectionUserData, fireButton, featureSettings: featureSettings2 }) { if (tab) { if (tab.locale) { if (Object.keys(i18n.options.resources).includes(tab.locale)) { @@ -14739,7 +14780,7 @@ this.emailProtectionUserData = emailProtectionUserData; } this.fireButton = fireButton; - this.featureSettings = new FeatureSettings({}); + this.featureSettings = FeatureSettings.create(featureSettings2, platform); this.setSiteProperties(); this.setHttpsMessage(); if (this.tab) { @@ -14910,6 +14951,9 @@ function useFeatures() { return dc.lastValue().features; } + function useFeatureSettings() { + return dc.lastValue().featureSettings; + } function usePrimaryStatus() { const { disabled, tab } = dc.lastValue(); if (tab?.error) @@ -16083,12 +16127,13 @@ const onToggle = useToggle(); const fetcher = useFetcher(); const { breakageScreen } = useFeatures(); + const featureSettings2 = useFeatureSettings(); return /* @__PURE__ */ y("div", { "data-testid": "protectionHeader" }, /* @__PURE__ */ y(ProtectionHeader, { model: data, toggle: onToggle }, /* @__PURE__ */ y("div", { className: "text--center" }, /* @__PURE__ */ y( TextLink, { onClick: () => { fetcher(new CheckBrokenSiteReportHandledMessage()).then(() => { - if (!isAndroid()) { + if (featureSettings2.webBreakageForm.state === "enabled") { push(breakageScreen); } }).catch(console.error); @@ -16560,8 +16605,8 @@ return /* @__PURE__ */ y("div", { className: "padding-x" }, /* @__PURE__ */ y("div", { className: "cta-screen" }, /* @__PURE__ */ y("p", { className: "note token-title-3 text--center" }, errorText))); } - // v2/screens/breakage-form-screen.jsx - var categories = () => { + // v2/breakage-categories.js + var defaultCategories = () => { return { blocked: ns.report("blocked.title"), layout: ns.report("layout.title"), @@ -16574,6 +16619,40 @@ other: ns.report("other.title") }; }; + function createBreakageFeaturesFrom(platformFeatures) { + return { + /** + * @param {Record} [additional] + * @return {[key: string, description: string][]} + */ + categoryList(additional = {}) { + const items = { + ...defaultCategories(), + ...additional + }; + const list = Object.entries(items); + if (platformFeatures.randomisedCategories) { + return shuffle(list); + } + return list; + } + }; + } + function shuffle(arr2) { + let len = arr2.length; + let temp; + let index; + while (len > 0) { + index = Math.floor(Math.random() * len); + len--; + temp = arr2[len]; + arr2[len] = arr2[index]; + arr2[index] = temp; + } + return arr2; + } + + // v2/screens/breakage-form-screen.jsx function BreakageFormScreen({ includeToggle }) { const data = useData(); const onToggle = useToggle(); @@ -16581,6 +16660,7 @@ const nav = useNav(); const canPop = nav.canPop(); const sendReport = useSendReport(); + const platformFeatures = useFeatures(); const [state, setState] = h2( /** @type {"idle" | "sent"} */ "idle" @@ -16605,6 +16685,10 @@ default: () => /* @__PURE__ */ y(TopNav, { done: /* @__PURE__ */ y(Close, { onClick: onClose }) }) }); } + const randomised = F(() => { + const f3 = createBreakageFeaturesFrom(platformFeatures); + return f3.categoryList(); + }, [platformFeatures]); return /* @__PURE__ */ y("div", { className: "breakage-form page-inner" }, topNav2, /* @__PURE__ */ y("div", { className: "breakage-form__inner", "data-state": state }, includeToggle && /* @__PURE__ */ y("div", { class: "header header--breakage" }, /* @__PURE__ */ y( ProtectionHeader, { @@ -16617,7 +16701,7 @@ FormElement, { onSubmit: submit, - before: /* @__PURE__ */ y("div", { className: "form__select breakage-form__input--dropdown" }, /* @__PURE__ */ y("select", { name: "category" }, /* @__PURE__ */ y("option", { value: "" }, ns.report("pickYourIssueFromTheList.title")), "$", Object.entries(categories()).map(([key, value]) => { + before: /* @__PURE__ */ y("div", { className: "form__select breakage-form__input--dropdown" }, /* @__PURE__ */ y("select", { name: "category" }, /* @__PURE__ */ y("option", { value: "" }, ns.report("pickYourIssueFromTheList.title")), "$", randomised.map(([key, value]) => { return /* @__PURE__ */ y("option", { value: key }, value); }))) } @@ -17176,13 +17260,15 @@ const send = useTelemetry(); const { protectionsEnabled, tab } = useData(); const text = tab.domain; - const { breakageScreen, initialScreen } = useFeatures(); - const showToggle = protectionsEnabled && (breakageScreen === "categoryTypeSelection" || initialScreen === "categoryTypeSelection"); - const v2Categories = { - ...categories(), - login: ns.report("loginV2.title") - }; - return /* @__PURE__ */ y("div", { className: "site-info page-inner card", "data-page": "choice-category" }, /* @__PURE__ */ y(NavWrapper, null), /* @__PURE__ */ y("div", { className: "padding-x-double" }, /* @__PURE__ */ y(KeyInsightsMain, { title: text }, description)), /* @__PURE__ */ y("div", { className: "padding-x" }, /* @__PURE__ */ y(Nav, null, Object.entries(v2Categories).map(([value, title]) => { + const platformFeatures = useFeatures(); + const showToggle = protectionsEnabled && (platformFeatures.breakageScreen === "categoryTypeSelection" || platformFeatures.initialScreen === "categoryTypeSelection"); + const randomised = F(() => { + const f3 = createBreakageFeaturesFrom(platformFeatures); + return f3.categoryList({ + login: ns.report("loginV2.title") + }); + }, [platformFeatures]); + return /* @__PURE__ */ y("div", { className: "site-info page-inner card", "data-page": "choice-category" }, /* @__PURE__ */ y(NavWrapper, null), /* @__PURE__ */ y("div", { className: "padding-x-double" }, /* @__PURE__ */ y(KeyInsightsMain, { title: text }, description)), /* @__PURE__ */ y("div", { className: "padding-x" }, /* @__PURE__ */ y(Nav, null, randomised.map(([value, title]) => { return /* @__PURE__ */ y( NavItem, { @@ -17223,7 +17309,7 @@ } var validCategories = () => { return { - ...categories(), + ...defaultCategories(), dislike: ns.report("dislike.title") }; }; @@ -17232,12 +17318,12 @@ const sendReport = useSendReport(); const nav = useNav(); const showAlert = useShowAlert(); - const categories2 = validCategories(); + const categories = validCategories(); let category = nav.params.get("category"); - if (!category || !Object.hasOwnProperty.call(categories2, category)) { + if (!category || !Object.hasOwnProperty.call(categories, category)) { category = "other"; } - const description = categories2[category]; + const description = categories[category]; const placeholder = category === "other" ? ns.report("otherRequired.title") : ns.report("otherOptional.title"); function submit(e3) { e3.preventDefault(); diff --git a/package-lock.json b/package-lock.json index e61f8748e9eb..61c023ac1f5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@duckduckgo/autoconsent": "^10.15.0", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#13.0.0", "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.9.0", - "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#5.0.0", + "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#5.1.0", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1721137609" }, "devDependencies": { @@ -26,6 +26,7 @@ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/highlight": "^7.24.7", "picocolors": "^1.0.0" @@ -39,6 +40,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -48,6 +50,7 @@ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", @@ -73,6 +76,7 @@ "node_modules/@duckduckgo/content-scope-scripts": { "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#47cb47893bfe3c6956ca6788d630fc8f94718982", "hasInstallScript": true, + "license": "Apache-2.0", "workspaces": [ "packages/special-pages", "packages/messaging" @@ -85,20 +89,22 @@ } }, "node_modules/@duckduckgo/privacy-dashboard": { - "resolved": "git+ssh://git@github.com/duckduckgo/privacy-dashboard.git#36dc07cba4bc1e7e0c1d1fb679c3cd077694a072", + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-dashboard.git#549e393d54c8fc3df1292175135eb93988e5342f", "engines": { "node": ">=18.0.0", "npm": ">=9.0.0" } }, "node_modules/@duckduckgo/privacy-reference-tests": { - "resolved": "git+ssh://git@github.com/duckduckgo/privacy-reference-tests.git#afb4f6128a3b50d53ddcb1897ea1fb4df6858aa1" + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-reference-tests.git#afb4f6128a3b50d53ddcb1897ea1fb4df6858aa1", + "license": "Apache-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", @@ -113,6 +119,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" } @@ -122,6 +129,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" } @@ -131,6 +139,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" @@ -140,13 +149,15 @@ "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" @@ -157,6 +168,7 @@ "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" }, @@ -169,6 +181,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", @@ -189,6 +202,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", @@ -205,13 +219,15 @@ "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.4.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.4.1.tgz", "integrity": "sha512-1tbpb9325+gPnKK0dMm+/LMriX0vKxf6RnB0SZUqfyVkQ4fMgUSySqhxE/y8Jvs4NyF1yHzTfG9KlnkIODxPKg==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } @@ -221,6 +237,7 @@ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -230,6 +247,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -242,6 +260,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -253,13 +272,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" }, @@ -272,6 +293,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -286,6 +308,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "1.1.3" } @@ -294,19 +317,22 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/commander": { "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" } @@ -316,6 +342,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -324,7 +351,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", @@ -332,6 +360,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -345,6 +374,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" } @@ -354,6 +384,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -363,6 +394,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" }, @@ -373,13 +405,15 @@ "node_modules/immutable-json-patch": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/immutable-json-patch/-/immutable-json-patch-5.1.3.tgz", - "integrity": "sha512-95AsF9hJTPpwtBGAnHmw57PASL672tb+vGHR5xLhH2VPuHSsLho7grjlfgQ65DIhHP+UmLCjdmuuA6L1ndJbZg==" + "integrity": "sha512-95AsF9hJTPpwtBGAnHmw57PASL672tb+vGHR5xLhH2VPuHSsLho7grjlfgQ65DIhHP+UmLCjdmuuA6L1ndJbZg==", + "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" }, @@ -395,6 +429,7 @@ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", "integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==", "dev": true, + "license": "MIT", "dependencies": { "hasown": "^2.0.2" }, @@ -409,13 +444,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", @@ -430,6 +467,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" } @@ -439,6 +477,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" }, @@ -450,18 +489,21 @@ "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/parse-address": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/parse-address/-/parse-address-1.1.2.tgz", "integrity": "sha512-EnqetXieqyTlDzuuy+oT/pjjkWoI80MgFawDA/Z9LZBAMy+Iy6piURuX+Lr1iZNm7exD+V/B9IRjHaSj33adJw==", + "license": "ISC", "dependencies": { "xregexp": "^3.1.1" } @@ -470,19 +512,22 @@ "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.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "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" }, @@ -495,6 +540,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" } @@ -504,6 +550,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", @@ -521,6 +568,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -537,6 +585,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", @@ -565,18 +614,21 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/seedrandom": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "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" } @@ -585,6 +637,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/sjcl/-/sjcl-1.0.8.tgz", "integrity": "sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ==", + "license": "(BSD-2-Clause OR GPL-2.0-only)", "engines": { "node": "*" } @@ -594,6 +647,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" } @@ -603,6 +657,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" @@ -613,6 +668,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -625,6 +681,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" }, @@ -637,6 +694,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -667,12 +725,14 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/xregexp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-3.2.0.tgz", - "integrity": "sha512-tWodXkrdYZPGadukpkmhKAbyp37CV5ZiFHacIVPhRZ4/sSt7qtOYHLv2dAqcPN0mBsViY2Qai9JkO7v2TBP6hg==" + "integrity": "sha512-tWodXkrdYZPGadukpkmhKAbyp37CV5ZiFHacIVPhRZ4/sSt7qtOYHLv2dAqcPN0mBsViY2Qai9JkO7v2TBP6hg==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 0c183a790ca7..c86366d7bf0e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@duckduckgo/autoconsent": "^10.15.0", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#13.0.0", "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.9.0", - "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#5.0.0", + "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#5.1.0", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1721137609" } } diff --git a/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardScrens.kt b/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardHybridScreenParams.kt similarity index 65% rename from privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardScrens.kt rename to privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardHybridScreenParams.kt index 682ec6d5fae6..dbfb8b105167 100644 --- a/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardScrens.kt +++ b/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/PrivacyDashboardHybridScreenParams.kt @@ -18,10 +18,19 @@ package com.duckduckgo.privacy.dashboard.api.ui import com.duckduckgo.navigation.api.GlobalActivityStarter -interface PrivacyDashboardHybridScreen { +sealed class PrivacyDashboardHybridScreenParams : GlobalActivityStarter.ActivityParams { + + abstract val tabId: String + /** * Use this parameter to launch the privacy dashboard hybrid activity with the given tabId * @param tabId The tab ID */ - data class PrivacyDashboardHybridWithTabIdParam(val tabId: String) : GlobalActivityStarter.ActivityParams + data class PrivacyDashboardPrimaryScreen(override val tabId: String) : PrivacyDashboardHybridScreenParams() + + /** + * Use this parameter to launch the site breakage reporting form. + * @param tabId The tab ID + */ + data class BrokenSiteForm(override val tabId: String) : PrivacyDashboardHybridScreenParams() } diff --git a/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/WebBrokenSiteForm.kt b/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/WebBrokenSiteForm.kt new file mode 100644 index 000000000000..103259df0c9a --- /dev/null +++ b/privacy-dashboard/privacy-dashboard-api/src/main/java/com/duckduckgo/privacy/dashboard/api/ui/WebBrokenSiteForm.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.privacy.dashboard.api.ui + +interface WebBrokenSiteForm { + /** + * Returns true if web version of the broken site form should be used instead of the native implementation. + */ + fun shouldUseWebBrokenSiteForm(): Boolean +} diff --git a/privacy-dashboard/privacy-dashboard-impl/build.gradle b/privacy-dashboard/privacy-dashboard-impl/build.gradle index e0be2ca535df..bf725cbd2693 100644 --- a/privacy-dashboard/privacy-dashboard-impl/build.gradle +++ b/privacy-dashboard/privacy-dashboard-impl/build.gradle @@ -41,6 +41,7 @@ dependencies { implementation project(':navigation-api') implementation project(':feature-toggles-api') implementation project(':privacy-protections-popup-api') + implementation project(':broken-site-api') implementation Google.dagger implementation AndroidX.appCompat diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/WebBrokenSiteFormFeature.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/WebBrokenSiteFormFeature.kt new file mode 100644 index 000000000000..4235affd53cb --- /dev/null +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/WebBrokenSiteFormFeature.kt @@ -0,0 +1,33 @@ +/* + * 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.privacy.dashboard.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.DefaultValue + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "webBrokenSiteForm", +) +interface WebBrokenSiteFormFeature { + @DefaultValue(false) + fun self(): Toggle +} + +fun WebBrokenSiteFormFeature.isEnabled(): Boolean = self().isEnabled() 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 1c6524e4f24d..7d6457778a64 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 @@ -30,24 +30,28 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.autoconsent.api.AutoconsentNav +import com.duckduckgo.brokensite.api.ReportFlow import com.duckduckgo.browser.api.brokensite.BrokenSiteNav import com.duckduckgo.common.ui.DuckDuckGoActivity -import com.duckduckgo.common.ui.store.AppTheme import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.navigation.api.getActivityParams -import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreen.PrivacyDashboardHybridWithTabIdParam +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.PrivacyDashboardPrimaryScreen import com.duckduckgo.privacy.dashboard.impl.databinding.ActivityPrivacyHybridDashboardBinding import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.GoBack import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.LaunchReportBrokenSite import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.OpenSettings import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.OpenURL +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardRenderer.InitialScreen import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @InjectWith(ActivityScope::class) -@ContributeToActivityStarter(PrivacyDashboardHybridWithTabIdParam::class) +@ContributeToActivityStarter(PrivacyDashboardHybridScreenParams::class) class PrivacyDashboardHybridActivity : DuckDuckGoActivity() { @Inject @@ -68,9 +72,6 @@ class PrivacyDashboardHybridActivity : DuckDuckGoActivity() { @Inject lateinit var browserNav: BrowserNav - @Inject - lateinit var appTheme: AppTheme - private val binding: ActivityPrivacyHybridDashboardBinding by viewBinding() private val webView @@ -92,23 +93,38 @@ class PrivacyDashboardHybridActivity : DuckDuckGoActivity() { }, onBrokenSiteClicked = { viewModel.onReportBrokenSiteSelected() }, onClose = { this@PrivacyDashboardHybridActivity.finish() }, + onSubmitBrokenSiteReport = { payload -> + val reportFlow = when (params) { + is PrivacyDashboardPrimaryScreen, null -> ReportFlow.DASHBOARD + is BrokenSiteForm -> ReportFlow.MENU + } + viewModel.onSubmitBrokenSiteReport(payload, reportFlow) + }, ), ) } private val viewModel: PrivacyDashboardHybridViewModel by bindViewModel() + private val params: PrivacyDashboardHybridScreenParams? + get() = intent.getActivityParams(PrivacyDashboardHybridScreenParams::class.java) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) configureWebView() - dashboardRenderer.loadDashboard(webView) + + val initialScreen = when (params) { + is PrivacyDashboardPrimaryScreen, null -> InitialScreen.PRIMARY + is BrokenSiteForm -> InitialScreen.BREAKAGE_FORM + } + + dashboardRenderer.loadDashboard(webView, initialScreen) configureObservers() } private fun configureObservers() { - val tabIdParam = intent.getActivityParams(PrivacyDashboardHybridWithTabIdParam::class.java)!!.tabId - repository.retrieveSiteData(tabIdParam).observe( + repository.retrieveSiteData(params!!.tabId).observe( this, ) { viewModel.onSiteChanged(it) @@ -128,6 +144,13 @@ class PrivacyDashboardHybridActivity : DuckDuckGoActivity() { } is OpenURL -> openUrl(it.url) is OpenSettings -> openSettings(it.target) + GoBack -> { + if (webView.canGoBack()) { + webView.goBack() + } else { + finish() + } + } } } @@ -198,7 +221,6 @@ class PrivacyDashboardHybridActivity : DuckDuckGoActivity() { } private fun dashboardOpenedFromCustomTab(): Boolean { - val tabIdParam = intent.getActivityParams(PrivacyDashboardHybridWithTabIdParam::class.java)?.tabId - return tabIdParam?.startsWith("CustomTab-") ?: false + return params?.tabId?.startsWith("CustomTab-") ?: false } } diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt index a630cd31af9e..0e3605afd55b 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt @@ -16,6 +16,7 @@ package com.duckduckgo.privacy.dashboard.impl.ui +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -25,21 +26,32 @@ import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.trackerdetection.model.TrackerStatus.BLOCKED +import com.duckduckgo.brokensite.api.BrokenSite +import com.duckduckgo.brokensite.api.BrokenSiteSender +import com.duckduckgo.brokensite.api.ReportFlow import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.DASHBOARD import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.baseHost import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.privacy.dashboard.impl.WebBrokenSiteFormFeature +import com.duckduckgo.privacy.dashboard.impl.isEnabled import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardCustomTabPixelNames.CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_ADD import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardCustomTabPixelNames.CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_REMOVE import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels.* +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.GoBack import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.LaunchReportBrokenSite import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.OpenSettings import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.OpenURL import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types import java.util.Locale import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel @@ -75,6 +87,9 @@ class PrivacyDashboardHybridViewModel @Inject constructor( private val protectionsToggleUsageListener: PrivacyProtectionsToggleUsageListener, private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels, private val userBrowserProperties: UserBrowserProperties, + private val webBrokenSiteFormFeature: WebBrokenSiteFormFeature, + private val brokenSiteSender: BrokenSiteSender, + private val moshi: Moshi, ) : ViewModel() { private val command = Channel(1, DROP_OLDEST) @@ -83,6 +98,7 @@ class PrivacyDashboardHybridViewModel @Inject constructor( class LaunchReportBrokenSite(val data: BrokenSiteData) : Command() class OpenURL(val url: String) : Command() class OpenSettings(val target: String) : Command() + data object GoBack : Command() } data class ViewState( @@ -91,7 +107,7 @@ class PrivacyDashboardHybridViewModel @Inject constructor( val requestData: RequestDataViewState, val protectionStatus: ProtectionStatusViewState, val cookiePromptManagementStatus: CookiePromptManagementState, - val remoteFeatureSettings: RemoteFeatureSettingsViewState = RemoteFeatureSettingsViewState(), + val remoteFeatureSettings: RemoteFeatureSettingsViewState, ) data class ProtectionStatusViewState( @@ -180,7 +196,8 @@ class PrivacyDashboardHybridViewModel @Inject constructor( ) data class RemoteFeatureSettingsViewState( - val primaryScreen: PrimaryScreenSettings = PrimaryScreenSettings(), + val primaryScreen: PrimaryScreenSettings, + val webBreakageForm: WebBrokenSiteFormSettings, ) enum class LayoutType(val value: String) { @@ -188,9 +205,18 @@ class PrivacyDashboardHybridViewModel @Inject constructor( } data class PrimaryScreenSettings( - val layout: String = LayoutType.DEFAULT.value, + val layout: String, ) + data class WebBrokenSiteFormSettings( + val state: String, + ) + + enum class WebBrokenSiteFormState(val value: String) { + ENABLED("enabled"), + DISABLED("disabled"), + } + val viewState = MutableStateFlow(null) private val site = MutableStateFlow(null) @@ -237,8 +263,10 @@ class PrivacyDashboardHybridViewModel @Inject constructor( fun onReportBrokenSiteSelected() { viewModelScope.launch(dispatcher.io()) { - val siteData = BrokenSiteData.fromSite(site.value, reportFlow = DASHBOARD) - command.send(LaunchReportBrokenSite(siteData)) + if (!webBrokenSiteFormFeature.isEnabled()) { + val siteData = BrokenSiteData.fromSite(site.value, reportFlow = DASHBOARD) + command.send(LaunchReportBrokenSite(siteData)) + } } } @@ -254,11 +282,27 @@ class PrivacyDashboardHybridViewModel @Inject constructor( requestData = requestDataViewStateMapper.mapFromSite(site), protectionStatus = protectionStatusViewStateMapper.mapFromSite(site), cookiePromptManagementStatus = autoconsentStatusViewStateMapper.mapFromSite(site), + remoteFeatureSettings = createRemoteFeatureSettings(), ), ) } } + private suspend fun createRemoteFeatureSettings(): RemoteFeatureSettingsViewState { + val webBrokenSiteFormState = withContext(dispatcher.io()) { + if (webBrokenSiteFormFeature.isEnabled()) { + WebBrokenSiteFormState.ENABLED + } else { + WebBrokenSiteFormState.DISABLED + } + } + + return RemoteFeatureSettingsViewState( + primaryScreen = PrimaryScreenSettings(layout = LayoutType.DEFAULT.value), + webBreakageForm = WebBrokenSiteFormSettings(state = webBrokenSiteFormState.value), + ) + } + fun onPrivacyProtectionsClicked( enabled: Boolean, dashboardOpenedFromCustomTab: Boolean = false, @@ -268,7 +312,7 @@ class PrivacyDashboardHybridViewModel @Inject constructor( viewModelScope.launch(dispatcher.io()) { protectionsToggleUsageListener.onPrivacyProtectionsToggleUsed() - delay(CLOSE_DASHBOARD_ON_INTERACTION_DELAY) + delay(CLOSE_ON_PROTECTIONS_TOGGLE_DELAY) currentViewState().siteViewState.domain?.let { domain -> val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() if (enabled) { @@ -292,7 +336,10 @@ class PrivacyDashboardHybridViewModel @Inject constructor( } private companion object { - const val CLOSE_DASHBOARD_ON_INTERACTION_DELAY = 300L + val CLOSE_ON_PROTECTIONS_TOGGLE_DELAY = 300.milliseconds + val CLOSE_ON_SUBMIT_REPORT_DELAY = 1500.milliseconds + const val MOBILE_SITE = "mobile" + const val DESKTOP_SITE = "desktop" } private fun currentViewState(): ViewState { @@ -314,4 +361,48 @@ class PrivacyDashboardHybridViewModel @Inject constructor( } } } + + fun onSubmitBrokenSiteReport(payload: String, reportFlow: ReportFlow) { + viewModelScope.launch(dispatcher.io()) { + if (!webBrokenSiteFormFeature.isEnabled()) return@launch + val request = privacyDashboardPayloadAdapter.onSubmitBrokenSiteReport(payload) ?: return@launch + val site = site.value ?: return@launch + val siteUrl = site.url + if (siteUrl.isEmpty()) return@launch + + val brokenSite = BrokenSite( + category = request.category, + description = request.description, + siteUrl = siteUrl, + upgradeHttps = site.upgradedHttps, + blockedTrackers = site.trackingEvents + .filter { it.status == BLOCKED } + .map { Uri.parse(it.trackerUrl).baseHost.orEmpty() } + .distinct().joinToString(","), + surrogates = site.surrogates + .map { Uri.parse(it.name).baseHost } + .distinct() + .joinToString(","), + siteType = if (site.isDesktopMode) DESKTOP_SITE else MOBILE_SITE, + urlParametersRemoved = site.urlParametersRemoved, + consentManaged = site.consentManaged, + consentOptOutFailed = site.consentOptOutFailed, + consentSelfTestFailed = site.consentSelfTestFailed, + errorCodes = moshi.adapter>( + Types.newParameterizedType(List::class.java, String::class.java), + ).toJson(site.errorCodeEvents.toList()).toString(), + httpErrorCodes = site.httpErrorCodeEvents.distinct().joinToString(","), + loginSite = null, + reportFlow = reportFlow, + userRefreshCount = site.realBrokenSiteContext.userRefreshCount, + openerContext = site.realBrokenSiteContext.openerContext?.context, + jsPerformance = site.realBrokenSiteContext.jsPerformance?.toList(), + ) + + brokenSiteSender.submitBrokenSiteFeedback(brokenSite) + + delay(CLOSE_ON_SUBMIT_REPORT_DELAY) + command.send(GoBack) + } + } } diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardJavascriptInterface.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardJavascriptInterface.kt index ea2b6df0c7e6..d6aba58f9646 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardJavascriptInterface.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardJavascriptInterface.kt @@ -24,6 +24,7 @@ class PrivacyDashboardJavascriptInterface constructor( val onUrlClicked: (String) -> Unit, val onOpenSettings: (String) -> Unit, val onClose: () -> Unit, + val onSubmitBrokenSiteReport: (String) -> Unit, ) { @JavascriptInterface fun toggleAllowlist(newValue: String) { @@ -50,6 +51,11 @@ class PrivacyDashboardJavascriptInterface constructor( onOpenSettings(payload) } + @JavascriptInterface + fun submitBrokenSiteReport(payload: String) { + onSubmitBrokenSiteReport(payload) + } + companion object { // Interface name used inside js layer const val JAVASCRIPT_INTERFACE_NAME = "PrivacyDashboard" diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardPayloadAdapter.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardPayloadAdapter.kt index 6d64e18a7b5f..41cdf2a3597e 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardPayloadAdapter.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardPayloadAdapter.kt @@ -17,6 +17,7 @@ package com.duckduckgo.privacy.dashboard.impl.ui import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.dashboard.impl.ui.AppPrivacyDashboardPayloadAdapter.BreakageReportRequest import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi import javax.inject.Inject @@ -25,6 +26,7 @@ import javax.inject.Named interface PrivacyDashboardPayloadAdapter { fun onUrlClicked(payload: String): String fun onOpenSettings(payload: String): String + fun onSubmitBrokenSiteReport(payload: String): BreakageReportRequest? } @ContributesBinding(AppScope::class) @@ -40,6 +42,16 @@ class AppPrivacyDashboardPayloadAdapter @Inject constructor( return kotlin.runCatching { payloadAdapter.fromJson(payload)?.target ?: "" }.getOrDefault("") } + override fun onSubmitBrokenSiteReport(payload: String): BreakageReportRequest? { + val payloadAdapter = moshi.adapter(BreakageReportRequest::class.java) + return kotlin.runCatching { payloadAdapter.fromJson(payload) }.getOrNull() + } + data class Payload(val url: String) data class SettingsPayload(val target: String) + + data class BreakageReportRequest( + val category: String, + val description: String, + ) } diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRenderer.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRenderer.kt index 7ce1add8c36e..2f36edbd1233 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRenderer.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRenderer.kt @@ -36,11 +36,12 @@ class PrivacyDashboardRenderer( private val onUrlClicked: (String) -> Unit, private val onOpenSettings: (String) -> Unit, private val onClose: () -> Unit, + private val onSubmitBrokenSiteReport: (String) -> Unit, ) { private var lastSeenPrivacyDashboardViewState: ViewState? = null - fun loadDashboard(webView: WebView) { + fun loadDashboard(webView: WebView, initialScreen: InitialScreen) { webView.addJavascriptInterface( PrivacyDashboardJavascriptInterface( onBrokenSiteClicked = { onBrokenSiteClicked() }, @@ -54,10 +55,11 @@ class PrivacyDashboardRenderer( onOpenSettings(it) }, onClose = { onClose() }, + onSubmitBrokenSiteReport = onSubmitBrokenSiteReport, ), PrivacyDashboardJavascriptInterface.JAVASCRIPT_INTERFACE_NAME, ) - webView.loadUrl("file:///android_asset/html/android.html") + webView.loadUrl("file:///android_asset/html/android.html?screen=${initialScreen.value}") } fun render(viewState: ViewState) { @@ -102,4 +104,9 @@ class PrivacyDashboardRenderer( lastSeenPrivacyDashboardViewState = viewState } + + enum class InitialScreen(val value: String) { + PRIMARY("primaryScreen"), + BREAKAGE_FORM("breakageForm"), + } } diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRendererFactory.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRendererFactory.kt index 567d03d27f4a..b6fb594dd3eb 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRendererFactory.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRendererFactory.kt @@ -37,6 +37,7 @@ sealed class RendererViewHolder { val onUrlClicked: (String) -> Unit, val onOpenSettings: (String) -> Unit, val onClose: () -> Unit, + val onSubmitBrokenSiteReport: (String) -> Unit, ) : RendererViewHolder() } @@ -56,6 +57,7 @@ class BrowserPrivacyDashboardRendererFactory @Inject constructor( renderer.onUrlClicked, renderer.onOpenSettings, renderer.onClose, + renderer.onSubmitBrokenSiteReport, ) } } diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/WebBrokenSiteFormImpl.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/WebBrokenSiteFormImpl.kt new file mode 100644 index 000000000000..1a41a0c1c73f --- /dev/null +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/WebBrokenSiteFormImpl.kt @@ -0,0 +1,31 @@ +/* + * 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.privacy.dashboard.impl.ui + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.dashboard.api.ui.WebBrokenSiteForm +import com.duckduckgo.privacy.dashboard.impl.WebBrokenSiteFormFeature +import com.duckduckgo.privacy.dashboard.impl.isEnabled +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class WebBrokenSiteFormImpl @Inject constructor( + private val webBrokenSiteFormFeature: WebBrokenSiteFormFeature, +) : WebBrokenSiteForm { + override fun shouldUseWebBrokenSiteForm(): Boolean = webBrokenSiteFormFeature.isEnabled() +} diff --git a/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt b/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt index 7439f0304806..c3bdeec7cbad 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt @@ -27,16 +27,26 @@ import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.brokensite.api.BrokenSite +import com.duckduckgo.brokensite.api.BrokenSiteSender +import com.duckduckgo.brokensite.api.ReportFlow.DASHBOARD import com.duckduckgo.browser.api.UserBrowserProperties +import com.duckduckgo.browser.api.brokensite.BrokenSiteContext import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.privacy.dashboard.impl.WebBrokenSiteFormFeature +import com.duckduckgo.privacy.dashboard.impl.di.JsonModule import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardCustomTabPixelNames import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels.* +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.GoBack import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.LaunchReportBrokenSite import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener import com.nhaarman.mockitokotlin2.mock +import com.squareup.moshi.Moshi import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -53,7 +63,9 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @ExperimentalCoroutinesApi @@ -79,6 +91,20 @@ class PrivacyDashboardHybridViewModelTest { runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } } + private var webBrokenSiteFormFeatureEnabled = false + + private val webBrokenSiteFormFeature: WebBrokenSiteFormFeature = mock { + whenever(this.mock.self()).thenReturn( + object : Toggle { + override fun isEnabled(): Boolean = webBrokenSiteFormFeatureEnabled + override fun setEnabled(state: State) = throw UnsupportedOperationException() + override fun getRawStoredState(): State? = throw UnsupportedOperationException() + }, + ) + } + + private val brokenSiteSender: BrokenSiteSender = mock() + private val testee: PrivacyDashboardHybridViewModel by lazy { PrivacyDashboardHybridViewModel( userAllowListRepository = userAllowListRepository, @@ -87,16 +113,21 @@ class PrivacyDashboardHybridViewModelTest { siteViewStateMapper = AppSiteViewStateMapper(PublicKeyInfoMapper(androidQAppBuildConfig)), requestDataViewStateMapper = AppSiteRequestDataViewStateMapper(), protectionStatusViewStateMapper = AppProtectionStatusViewStateMapper(contentBlocking, unprotectedTemporary), - privacyDashboardPayloadAdapter = mock(), + privacyDashboardPayloadAdapter = AppPrivacyDashboardPayloadAdapter(moshi = JsonModule.moshi(Moshi.Builder().build())), autoconsentStatusViewStateMapper = CookiePromptManagementStatusViewStateMapper(), protectionsToggleUsageListener = privacyProtectionsToggleUsageListener, privacyProtectionsPopupExperimentExternalPixels = privacyProtectionsPopupExperimentExternalPixels, userBrowserProperties = mockUserBrowserProperties, + webBrokenSiteFormFeature = webBrokenSiteFormFeature, + brokenSiteSender = brokenSiteSender, + moshi = Moshi.Builder().build(), ) } @Test fun whenUserClicksOnReportBrokenSiteThenCommandEmitted() = runTest { + webBrokenSiteFormFeatureEnabled = false + testee.onReportBrokenSiteSelected() testee.commands().test { @@ -105,6 +136,17 @@ class PrivacyDashboardHybridViewModelTest { } } + @Test + fun whenUserClicksOnReportBrokenSiteAndWebFormEnabledThenCommandIsNotEmitted() = runTest { + webBrokenSiteFormFeatureEnabled = true + + testee.onReportBrokenSiteSelected() + + testee.commands().test { + expectNoEvents() + } + } + @Test fun whenSiteChangesThenViewStateUpdates() = runTest { testee.onSiteChanged(site()) @@ -201,6 +243,97 @@ class PrivacyDashboardHybridViewModelTest { verify(pixel).fire(PrivacyDashboardCustomTabPixelNames.CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_ADD) } + @Test + fun whenUserClicksOnSubmitReportThenSubmitsReport() = runTest { + webBrokenSiteFormFeatureEnabled = true + + val siteUrl = "https://example.com" + val userRefreshCount = 2 + val jsPerformance = doubleArrayOf(1.0, 2.0, 3.0) + + val site: Site = mock { site -> + whenever(site.uri).thenReturn(siteUrl.toUri()) + whenever(site.url).thenReturn(siteUrl) + whenever(site.userAllowList).thenReturn(true) + whenever(site.isDesktopMode).thenReturn(false) + whenever(site.upgradedHttps).thenReturn(true) + whenever(site.consentManaged).thenReturn(true) + whenever(site.errorCodeEvents).thenReturn(listOf("401", "401", "500")) + + val brokenSiteContext: BrokenSiteContext = mock { brokenSiteContext -> + whenever(brokenSiteContext.userRefreshCount).thenReturn(userRefreshCount) + whenever(brokenSiteContext.jsPerformance).thenReturn(jsPerformance) + } + whenever(site.realBrokenSiteContext).thenReturn(brokenSiteContext) + } + + testee.onSiteChanged(site) + + val category = "login" + val description = "I can't sign in!" + testee.onSubmitBrokenSiteReport( + payload = """{"category":"$category","description":"$description"}""", + reportFlow = DASHBOARD, + ) + + val expectedBrokenSite = BrokenSite( + category = category, + description = description, + siteUrl = siteUrl, + upgradeHttps = true, + blockedTrackers = "", + surrogates = "", + siteType = "mobile", + urlParametersRemoved = false, + consentManaged = true, + consentOptOutFailed = false, + consentSelfTestFailed = false, + errorCodes = """["401","401","500"]""", + httpErrorCodes = "", + loginSite = null, + reportFlow = DASHBOARD, + userRefreshCount = userRefreshCount, + openerContext = null, + jsPerformance = jsPerformance.toList(), + ) + + verify(brokenSiteSender).submitBrokenSiteFeedback(expectedBrokenSite) + } + + @Test + fun whenUserClicksOnSubmitReportAndSiteUrlIsEmptyThenDoesNotSubmitReport() = runTest { + webBrokenSiteFormFeatureEnabled = true + + testee.onSiteChanged(site(url = "")) + + val category = "login" + val description = "I can't sign in!" + testee.onSubmitBrokenSiteReport( + payload = """{"category":"$category","description":"$description"}""", + reportFlow = DASHBOARD, + ) + + verifyNoInteractions(brokenSiteSender) + } + + @Test + fun whenUserClicksOnSubmitReportThenCommandIsSent() = runTest { + webBrokenSiteFormFeatureEnabled = true + + testee.onSiteChanged(site()) + + testee.onSubmitBrokenSiteReport( + payload = """{"category":"login","description":"I can't sign in!"}""", + reportFlow = DASHBOARD, + ) + + verify(brokenSiteSender).submitBrokenSiteFeedback(any()) + + testee.commands().test { + assertEquals(GoBack, awaitItem()) + } + } + private fun site( url: String = "https://example.com", siteAllowed: Boolean = false, @@ -209,6 +342,7 @@ class PrivacyDashboardHybridViewModelTest { whenever(site.uri).thenReturn(url.toUri()) whenever(site.url).thenReturn(url) whenever(site.userAllowList).thenReturn(siteAllowed) + whenever(site.realBrokenSiteContext).thenReturn(mock()) return site } } diff --git a/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRendererTest.kt b/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRendererTest.kt index dcf2bf712930..a706cbda7919 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRendererTest.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardRendererTest.kt @@ -26,12 +26,19 @@ import com.duckduckgo.privacy.dashboard.impl.di.JsonModule import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.CookiePromptManagementState import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.DetectedRequest import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.EntityViewState +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.LayoutType +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.PrimaryScreenSettings import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.ProtectionStatusViewState +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.RemoteFeatureSettingsViewState import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.RequestDataViewState import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.RequestState.Blocked import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.SiteViewState import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.ViewState +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.WebBrokenSiteFormSettings +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.WebBrokenSiteFormState import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardJavascriptInterface.Companion.JAVASCRIPT_INTERFACE_NAME +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardRenderer.InitialScreen.BREAKAGE_FORM +import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardRenderer.InitialScreen.PRIMARY import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.spy @@ -59,11 +66,12 @@ class PrivacyDashboardRendererTest { {}, {}, {}, + {}, ) @Test fun whenLoadDashboardThenJSInterfaceInjected() { - testee.loadDashboard(spyWebView) + testee.loadDashboard(spyWebView, initialScreen = PRIMARY) verify(spyWebView).addJavascriptInterface( any(), @@ -72,10 +80,17 @@ class PrivacyDashboardRendererTest { } @Test - fun whenLoadDashboardThenLoadLocalHtml() { - testee.loadDashboard(spyWebView) + fun whenLoadDashboardWithInitialScreenPrimaryThenLoadLocalHtml() { + testee.loadDashboard(spyWebView, initialScreen = PRIMARY) + + verify(spyWebView).loadUrl("file:///android_asset/html/android.html?screen=primaryScreen") + } + + @Test + fun whenLoadDashboardWithInitialScreenBreakageFormThenLoadLocalHtml() { + testee.loadDashboard(spyWebView, initialScreen = BREAKAGE_FORM) - verify(spyWebView).loadUrl("file:///android_asset/html/android.html") + verify(spyWebView).loadUrl("file:///android_asset/html/android.html?screen=breakageForm") } @Test @@ -126,6 +141,10 @@ class PrivacyDashboardRendererTest { ), protectionStatus = ProtectionStatusViewState(true, true, emptyList(), true), cookiePromptManagementStatus = CookiePromptManagementState(), + remoteFeatureSettings = RemoteFeatureSettingsViewState( + primaryScreen = PrimaryScreenSettings(layout = LayoutType.DEFAULT.value), + webBreakageForm = WebBrokenSiteFormSettings(state = WebBrokenSiteFormState.DISABLED.value), + ), ) private fun getMoshiPD(): Moshi = JsonModule.moshi(Moshi.Builder().build())