diff --git a/.github/actions/assign-release-task/action.yml b/.github/actions/assign-release-task/action.yml new file mode 100644 index 000000000000..334921a02b94 --- /dev/null +++ b/.github/actions/assign-release-task/action.yml @@ -0,0 +1,67 @@ +name: 'Assign Android Release Task in Asana' +description: 'Assigns the latest Asana Release task to the user who runs the workflow' +inputs: + task_name: + description: 'The name of the task to search for' + required: true + asana_token: + description: 'Asana Personal Access Token' + required: true + project_gid: + description: 'Asana Project GID to search within' + required: true + username: + description: 'The Github username to search for' + required: true +runs: + using: 'composite' + steps: + - name: Find task in Asana and assign it to owner + shell: bash + run: | + task_name="${{ inputs.task_name }}" + asana_token="${{ inputs.asana_token }}" + project_gid="${{ inputs.project_gid }}" + username="${{ inputs.username }}" + + # Make the API request to get tasks from the specified project + response=$(curl -s -X GET "https://app.asana.com/api/1.0/projects/${project_gid}/tasks" \ + -H "Authorization: Bearer ${asana_token}") + + # Check if the response contains any tasks that match the specified task name exactly + task_id=$(echo "$response" | jq -r '.data[] | select(.name == "'"$task_name"'") | .gid') + + if [ -z "$task_id" ]; then + echo "No tasks with the exact name '$task_name' found in project GID '$project_gid'." + exit 1 + else + echo "Task ID for the task named '$task_name': $task_id" + fi + + asana_user_id=$(grep -E "^$username: " .github/actions/assign-release-task/github_asana_mapping.yml | awk -F': ' '{print $2}' | tr -d '"') + + if [ -z "asana_user_id" ]; then + echo "User $username not found." + exit 1 + else + echo "User ID for $username: $asana_user_id" + fi + + echo "Assigning task ID $task_id to user ID $asana_user_id" + + # Assign the task to the user + response=$(curl -s -X PUT "https://app.asana.com/api/1.0/tasks/${task_id}" \ + -H "Authorization: Bearer ${asana_token}" \ + -H "Content-Type: application/json" \ + -d "{\"data\": {\"assignee\": \"${asana_user_id}\"}}") + + # Check if the assignment was successful + status=$(echo $response | jq -r '.errors') + + if [ "$status" == "null" ]; then + echo "Task $task_id successfully assigned to user $asana_user_id." + else + echo "Failed to assign task: $status" + exit 1 + fi + diff --git a/.github/actions/assign-release-task/github_asana_mapping.yml b/.github/actions/assign-release-task/github_asana_mapping.yml new file mode 100644 index 000000000000..b4fb89396f86 --- /dev/null +++ b/.github/actions/assign-release-task/github_asana_mapping.yml @@ -0,0 +1,13 @@ +malmstein: "1157893581871899" +marcosholgado: "1125189844075764" +aitorvs: "1198194956790048" +CDRussell: "608920331025313" +anikiki: "1200581511061484" +joshliebe: "1200204095365673" +karlenDimla: "1201462763414791" +cmonfortep: "1149059203346875" +lmac012: "1205617573940213" +nalcalag: "1201807753392396" +CrisBarreiro: "1204920898013507" +0nko: "1207418217763343" +mikescamell: "1207908161520961" \ No newline at end of file diff --git a/.github/workflows/e2e-nightly-autofill.yml b/.github/workflows/e2e-nightly-autofill.yml index ba67fc39b604..63ac9b12d3d5 100644 --- a/.github/workflows/e2e-nightly-autofill.yml +++ b/.github/workflows/e2e-nightly-autofill.yml @@ -60,7 +60,7 @@ jobs: api-key: ${{ secrets.MOBILE_DEV_API_KEY }} name: ${{ github.sha }} app-file: apk/release.apk - android-api-level: 30 + android-api-level: 33 workspace: .maestro include-tags: autofillNoAuthTests diff --git a/.github/workflows/release_create_task.yml b/.github/workflows/release_create_task.yml new file mode 100644 index 000000000000..bd1ba8c4d1e6 --- /dev/null +++ b/.github/workflows/release_create_task.yml @@ -0,0 +1,63 @@ +name: Create Android App Release Task + +on: + workflow_dispatch: + inputs: + app-version: + description: 'App Version for Release' + required: true + default: 'PLACEHOLDER' + +env: + ASANA_PAT: ${{ secrets.GH_ASANA_SECRET }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + create_release_task: + name: Create Android App Release Task in Asana + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check app-version value + run: | + if [ "${{ github.event.inputs.app-version }}" == "PLACEHOLDER" ]; then + echo "Input value cannot be 'PLACEHOLDER'." + exit 1 + else + echo "Input value is valid: ${{ github.event.inputs.app-version }}" + fi + + - name: Install Release Bridge from Homebrew + run: | + brew tap cdrussell/aarb + brew install aarb + + - name: Create task in Asana + run: | + AndroidAsanaBridge version=${{ github.event.inputs.app-version }} action=createRelease,tagPendingTasks,addLinksToDescription,removePendingTasks + + - name: Assign task to Github Actor + id: assign-release-task + uses: ./.github/actions/assign-release-task + with: + task_name: 'Android Release ${{ github.event.inputs.app-version }}' + asana_token: ${{ secrets.GH_ASANA_SECRET }} + project_gid: ${{ vars.GH_ANDROID_RELEASE_BOARD_PROJECT_ID }} + username: ${{ github.actor }} + + - name: Create Asana task when workflow failed + if: ${{ failure() }} + uses: duckduckgo/native-github-asana-sync@v1.1 + with: + asana-pat: ${{ secrets.GH_ASANA_SECRET }} + asana-project: ${{ vars.GH_ANDROID_APP_PROJECT_ID }} + asana-section: ${{ vars.GH_ANDROID_APP_INCOMING_SECTION_ID }} + asana-task-name: GH Workflow Failure - Create Android App Release Task + asana-task-description: The Create Android App Release Task workflow has failed. See https://github.com/duckduckgo/Android/actions/runs/${{ github.run_id }} + action: 'create-asana-task' \ No newline at end of file diff --git a/.maestro/ads_preview_flows/1-_design-system-components.yaml b/.maestro/ads_preview_flows/1-_design-system-components.yaml index 03a23ddef402..33e51feda1df 100644 --- a/.maestro/ads_preview_flows/1-_design-system-components.yaml +++ b/.maestro/ads_preview_flows/1-_design-system-components.yaml @@ -18,7 +18,7 @@ tags: text: "Set of components designed following our Design System" direction: DOWN - assertVisible: "Set of components designed following our Design System" -- tapOn: "App Components Design Preview" +- tapOn: "Android Design System Preview" - assertVisible: "COLOR PALETTE" - tapOn: "TYPOGRAPHY" - scrollUntilVisible: diff --git a/.maestro/autofill/2_autofill_add_search_update_delete_creds.yaml b/.maestro/autofill/2_autofill_add_search_update_delete_creds.yaml index 3ef054cce02b..fb801b37349c 100644 --- a/.maestro/autofill/2_autofill_add_search_update_delete_creds.yaml +++ b/.maestro/autofill/2_autofill_add_search_update_delete_creds.yaml @@ -28,6 +28,9 @@ tags: - assertNotVisible: id: view_menu_save + - scrollUntilVisible: + element: + id: usernameEditText - tapOn: id: usernameEditText - inputText: "user" @@ -35,14 +38,23 @@ tags: - assertVisible: id: view_menu_save + - scrollUntilVisible: + element: + id: passwordEditText - tapOn: id: passwordEditText - inputText: "123" + - scrollUntilVisible: + element: + id: domainEditText - tapOn: id: domainEditText - inputText: "${output.addLogins.domains[output.addLogins.counter]}" + - scrollUntilVisible: + element: + id: notesEditText - tapOn: id: notesEditText - inputText: "a note" @@ -51,6 +63,9 @@ tags: id: view_menu_save retryTapIfNoChange: false + - scrollUntilVisible: + element: + text: "Last updated.*" - assertVisible: "Last updated.*" - tapOn: "Navigate up" @@ -59,15 +74,24 @@ tags: text: "Save and autofill passwords" - evalScript: ${output.addLogins.counter++} +- scrollUntilVisible: + element: + text: "#" - assertVisible: text: "#" +- scrollUntilVisible: + element: + text: "a.example.com" - assertVisible: text: "a.example.com" - assertNotVisible: text: "https://a.example.com" +- scrollUntilVisible: + element: + text: "fill.dev" - assertVisible: text: "fill.dev" diff --git a/.maestro/autofill/3_autofill_prompted_to_save_creds_on_form.yaml b/.maestro/autofill/3_autofill_prompted_to_save_creds_on_form.yaml index 224e1bde0db3..0c155b33ba97 100644 --- a/.maestro/autofill/3_autofill_prompted_to_save_creds_on_form.yaml +++ b/.maestro/autofill/3_autofill_prompted_to_save_creds_on_form.yaml @@ -1,9 +1,9 @@ appId: com.duckduckgo.mobile.android name: "Autofill: Prompted to save and update credentials on web form" tags: - - autofillNoAuthTests + - autofillNoAuthTestsModernWebView --- -# Pre-requisite: on an autofill-eligible device +# Pre-requisite: on an autofill-eligible device, including having a modern WebView - launchApp: clearState: true diff --git a/.maestro/autofill/steps/delete_logins.yaml b/.maestro/autofill/steps/delete_logins.yaml index 2c98a0251696..9efc3bc1462d 100644 --- a/.maestro/autofill/steps/delete_logins.yaml +++ b/.maestro/autofill/steps/delete_logins.yaml @@ -3,26 +3,35 @@ name: "Autofill: Delete credentials" --- # Pre-requisite: the user is viewing the password manager screen with some saved passwords added by a previous test step, on an autofill-eligible device +- scrollUntilVisible: + element: + text: "192.168.0.100" - tapOn: - id: "item_container" - index: 1 + text: "192.168.0.100" - tapOn: "More options" - tapOn: "Delete" - tapOn: "Delete" +- scrollUntilVisible: + element: + text: "a.example.com" - tapOn: - id: "item_container" - index: 1 + text: "a.example.com" - tapOn: "More options" - tapOn: "Delete" - tapOn: "Delete" +- scrollUntilVisible: + element: + text: "fill.dev" - tapOn: - id: "item_container" - index: 1 + text: "fill.dev" - tapOn: "More options" - tapOn: "Delete" - tapOn: "Delete" +- scrollUntilVisible: + element: + text: "No passwords saved yet" - assertVisible: text: "No passwords saved yet" \ No newline at end of file diff --git a/.maestro/autofill/steps/manual_update.yaml b/.maestro/autofill/steps/manual_update.yaml index 7c49d0ae484d..3de85bc0c0a6 100644 --- a/.maestro/autofill/steps/manual_update.yaml +++ b/.maestro/autofill/steps/manual_update.yaml @@ -3,13 +3,18 @@ name: "Autofill: Manually updating an existing credential" --- # Pre-requisite: the user is viewing the password manager screen with some saved passwords added by a previous test step, on an autofill-eligible device +- scrollUntilVisible: + element: + text: "a.example.com" - tapOn: - id: "item_container" - index: "1" + text: "a.example.com" - tapOn: "More options" - tapOn: "Edit" +- scrollUntilVisible: + element: + id: notesEditText - tapOn: id: notesEditText @@ -21,5 +26,8 @@ name: "Autofill: Manually updating an existing credential" id: view_menu_save retryTapIfNoChange: false +- scrollUntilVisible: + element: + text: "new note" - assertVisible: "new note" - tapOn: "Navigate up" \ No newline at end of file diff --git a/.maestro/autofill/steps/search_logins.yaml b/.maestro/autofill/steps/search_logins.yaml index 7a1ad578e11e..e36ce95379c3 100644 --- a/.maestro/autofill/steps/search_logins.yaml +++ b/.maestro/autofill/steps/search_logins.yaml @@ -3,6 +3,12 @@ name: "Autofill: Search credentials" --- # Pre-requisite: the user is viewing the password manager screen with some saved passwords added by a previous test step, on an autofill-eligible device +- runFlow: + when: + visible: "Sync & Back Up Your Passwords" + commands: + - tapOn: "No thanks" + - tapOn: id: searchLogins diff --git a/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/pixels/RealAdClickPixelsTest.kt b/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/pixels/RealAdClickPixelsTest.kt index 6b70570a0e50..d3ba9f7d8d8b 100644 --- a/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/pixels/RealAdClickPixelsTest.kt +++ b/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/pixels/RealAdClickPixelsTest.kt @@ -22,7 +22,7 @@ import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.adclick.impl.Exemption import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.common.test.api.InMemorySharedPreferences import java.time.Instant import java.util.concurrent.TimeUnit @@ -248,7 +248,7 @@ class RealAdClickPixelsTest { pixel = eq(AdClickPixelName.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION), parameters = any(), encodedParameters = any(), - type = eq(COUNT), + type = eq(Count), ) } @@ -264,7 +264,7 @@ class RealAdClickPixelsTest { pixel = eq(AdClickPixelName.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION), parameters = eq(mapOf(AdClickPixelParameters.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION_COUNT to "1")), encodedParameters = any(), - type = eq(COUNT), + type = eq(Count), ) } @@ -283,7 +283,7 @@ class RealAdClickPixelsTest { pixel = eq(AdClickPixelName.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION), parameters = any(), encodedParameters = any(), - type = eq(COUNT), + type = eq(Count), ) } @@ -302,7 +302,7 @@ class RealAdClickPixelsTest { pixel = eq(AdClickPixelName.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION), parameters = eq(mapOf(AdClickPixelParameters.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION_COUNT to "1")), encodedParameters = any(), - type = eq(COUNT), + type = eq(Count), ) } } diff --git a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt index b88dc1b89798..9968f4247187 100644 --- a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt +++ b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt @@ -21,7 +21,7 @@ import com.duckduckgo.anrs.api.AnrRepository import com.duckduckgo.app.statistics.api.OfflinePixel import com.duckduckgo.app.statistics.api.PixelSender import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import io.reactivex.Completable @@ -47,7 +47,7 @@ class AnrOfflinePixelSender @Inject constructor( ANR_CUSTOM_TAB to it.customTab.toString(), ), mapOf(), - COUNT, + Count, ).ignoreElement().doOnComplete { anrRepository.removeMostRecentAnr() } diff --git a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/CrashOfflinePixelSender.kt b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/CrashOfflinePixelSender.kt index 3c6d2fb4a8c6..fc5838549baa 100644 --- a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/CrashOfflinePixelSender.kt +++ b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/CrashOfflinePixelSender.kt @@ -22,7 +22,7 @@ import com.duckduckgo.app.anr.CrashPixel.APPLICATION_CRASH_GLOBAL_VERIFIED_INSTA import com.duckduckgo.app.anrs.store.UncaughtExceptionDao import com.duckduckgo.app.statistics.api.OfflinePixel import com.duckduckgo.app.statistics.api.PixelSender -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.verifiedinstallation.IsVerifiedPlayStoreInstall @@ -63,13 +63,13 @@ class CrashOfflinePixelSender @Inject constructor( pixelName = APPLICATION_CRASH_GLOBAL_VERIFIED_INSTALL.pixelName, parameters = params, encodedParameters = emptyMap(), - type = COUNT, + type = Count, ) pixels.add(verifiedPixel.ignoreElement()) } val pixel = - pixelSender.sendPixel(APPLICATION_CRASH_GLOBAL.pixelName, params, emptyMap(), COUNT).ignoreElement().doOnComplete { + pixelSender.sendPixel(APPLICATION_CRASH_GLOBAL.pixelName, params, emptyMap(), Count).ignoreElement().doOnComplete { logcat { "Sent pixel with params: $params containing exception; deleting exception with id=${exception.hash}" } uncaughtExceptionDao.delete(exception) } diff --git a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt index 8fe3d5a1d299..4d0d0aae5803 100644 --- a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt +++ b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt @@ -487,7 +487,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { exceptionStore.insertAll(exceptions) val isEnabled = (feature.state == "enabled") || (appBuildConfig.flavor == %T && feature.state == "internal") - this.feature.get().invokeMethod("self").setEnabled( + this.feature.get().invokeMethod("self").setRawStoredState( Toggle.State( remoteEnableState = isEnabled, enable = isEnabled, @@ -514,7 +514,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { variantKey = target.variantKey, ) } ?: emptyList() - this.feature.get().invokeMethod(subfeature.key).setEnabled( + this.feature.get().invokeMethod(subfeature.key).setRawStoredState( Toggle.State( remoteEnableState = newStateValue, enable = previousStateValue, diff --git a/app-tracking-protection/vpn-impl/lint-baseline.xml b/app-tracking-protection/vpn-impl/lint-baseline.xml index 1e47a80cba3d..f727d5c620d5 100644 --- a/app-tracking-protection/vpn-impl/lint-baseline.xml +++ b/app-tracking-protection/vpn-impl/lint-baseline.xml @@ -27,7 +27,7 @@ id="BadPeriodicWorkRequestEnqueue" message="Use `enqueueUniquePeriodicWork()` instead of `enqueue()`"> + file="$GRADLE_USER_HOME/caches/8.8/transforms/d14625ee55ebfba3972ab06f22b2b3b9/transformed/work-runtime-2.9.1/jars/classes.jar!/androidx/work/WorkManager.class"/> + errorLine1=" appTpRemoteFeatures.restartOnConnectivityLoss().setRawStoredState(Toggle.State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enabled))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" appTpLocalFeature.verboseLogging().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" appTpLocalFeature.verboseLogging().setRawStoredState(Toggle.State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> when { DebugLoggingReceiver.isLoggingOnIntent(intent) -> { - appTpLocalFeature.verboseLogging().setEnabled(Toggle.State(enable = true)) + appTpLocalFeature.verboseLogging().setRawStoredState(Toggle.State(enable = true)) TimberExtensions.enableLogging() // To propagate changes to NetGuard, reconfigure the VPN @@ -88,7 +88,7 @@ class DebugLoggingReceiverRegister @Inject constructor( } } DebugLoggingReceiver.isLoggingOffIntent(intent) -> { - appTpLocalFeature.verboseLogging().setEnabled(Toggle.State(enable = false)) + appTpLocalFeature.verboseLogging().setRawStoredState(Toggle.State(enable = false)) TimberExtensions.disableLogging() // To propagate changes to NetGuard, reconfigure the VPN diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 373045738c9e..734ba3cc6bc5 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,28 +1,6 @@ - - - - - - + + + + @@ -404,7 +393,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -415,7 +404,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -426,7 +415,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -437,7 +426,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -448,7 +437,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -459,7 +448,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -470,7 +459,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -481,7 +470,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -492,7 +481,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -503,7 +492,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -514,7 +503,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -613,7 +602,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -642,8 +631,8 @@ + errorLine1=" webContentDebuggingFeature.webContentDebugging().setRawStoredState(Toggle.State(enable = isChecked))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(Toggle.State(true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(Toggle.State(false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - + errorLine1=" setting.self().setRawStoredState(Toggle.State(true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(Toggle.State(false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1317,7 +1240,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1339,7 +1262,7 @@ errorLine2=" ^"> @@ -1350,7 +1273,7 @@ errorLine2=" ^"> @@ -1361,7 +1284,7 @@ errorLine2=" ^"> @@ -1372,7 +1295,7 @@ errorLine2=" ^"> @@ -1383,7 +1306,7 @@ errorLine2=" ^"> @@ -1394,7 +1317,7 @@ errorLine2=" ^"> @@ -1405,7 +1328,7 @@ errorLine2=" ^"> @@ -1416,7 +1339,7 @@ errorLine2=" ^"> @@ -1427,7 +1350,7 @@ errorLine2=" ^"> @@ -1438,7 +1361,7 @@ errorLine2=" ^"> @@ -1449,7 +1372,7 @@ errorLine2=" ^"> @@ -1460,7 +1383,7 @@ errorLine2=" ^"> @@ -1471,7 +1394,7 @@ errorLine2=" ^"> @@ -1482,7 +1405,7 @@ errorLine2=" ^"> @@ -1493,7 +1416,7 @@ errorLine2=" ^"> @@ -1504,7 +1427,7 @@ errorLine2=" ^"> @@ -1515,7 +1438,7 @@ errorLine2=" ^"> @@ -1526,7 +1449,7 @@ errorLine2=" ^"> @@ -2593,7 +2516,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2604,7 +2527,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2615,7 +2538,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2626,7 +2549,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2637,7 +2560,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2648,7 +2571,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2659,7 +2582,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2670,7 +2593,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2681,7 +2604,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2692,7 +2615,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2703,7 +2626,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2714,7 +2637,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2725,7 +2648,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2736,7 +2659,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2747,7 +2670,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2758,7 +2681,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2769,7 +2692,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2780,7 +2703,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2791,7 +2714,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2802,7 +2725,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2813,7 +2736,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2824,7 +2747,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2835,7 +2758,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2846,7 +2769,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2857,7 +2780,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2868,7 +2791,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2879,7 +2802,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2890,7 +2813,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2901,7 +2824,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2912,7 +2835,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2923,7 +2846,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2934,7 +2857,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2945,7 +2868,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2956,7 +2879,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2967,7 +2890,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2978,7 +2901,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2989,7 +2912,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3000,7 +2923,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3011,7 +2934,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3022,7 +2945,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3033,7 +2956,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3044,7 +2967,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3055,7 +2978,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3066,7 +2989,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3077,7 +3000,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3088,7 +3011,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3099,7 +3022,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3110,7 +3033,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3121,7 +3044,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3132,7 +3055,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3143,7 +3066,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3154,7 +3077,7 @@ errorLine2=" ^"> @@ -3165,7 +3088,7 @@ errorLine2=" ^"> @@ -3209,7 +3132,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3242,7 +3165,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -4060,6 +3983,17 @@ column="1"/> + + + + + + + + + + + + @@ -5503,7 +5459,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5514,7 +5470,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -5525,7 +5481,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5536,7 +5492,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5547,7 +5503,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5558,7 +5514,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5569,7 +5525,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5580,7 +5536,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5591,7 +5547,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5602,7 +5558,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5613,7 +5569,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5624,7 +5580,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5635,7 +5591,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5646,7 +5602,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5657,7 +5613,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5668,7 +5624,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5679,7 +5635,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5690,7 +5646,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -5701,7 +5657,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5712,7 +5668,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5723,7 +5679,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5734,7 +5690,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5745,7 +5701,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5756,7 +5712,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5767,7 +5723,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5778,7 +5734,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5789,7 +5745,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5800,7 +5756,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5811,7 +5767,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5822,7 +5778,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5833,7 +5789,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5844,7 +5800,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5855,7 +5811,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5866,7 +5822,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5877,7 +5833,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5888,7 +5844,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5899,7 +5855,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5910,7 +5866,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5921,7 +5877,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5932,7 +5888,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5943,7 +5899,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5954,7 +5910,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5965,7 +5921,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5976,7 +5932,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5987,7 +5943,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5998,7 +5954,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6009,7 +5965,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6020,7 +5976,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6031,7 +5987,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6042,7 +5998,227 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6097,7 +6273,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6108,7 +6284,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6119,7 +6295,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6130,7 +6306,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6141,7 +6317,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6152,7 +6328,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6163,7 +6339,7 @@ errorLine2=" ~~~~~~"> @@ -6174,7 +6350,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -6185,7 +6361,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -6196,7 +6372,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6207,7 +6383,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -6218,7 +6394,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -6229,7 +6405,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6240,7 +6416,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6251,7 +6427,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6262,7 +6438,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -6273,7 +6449,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6284,7 +6460,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6295,7 +6471,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6306,7 +6482,7 @@ errorLine2=" ~~~~~~~~~"> @@ -6317,7 +6493,7 @@ errorLine2=" ~~~~~~~~"> @@ -6328,7 +6504,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6339,7 +6515,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6350,7 +6526,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6361,7 +6537,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6372,7 +6548,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6383,7 +6559,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6394,7 +6570,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6405,7 +6581,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6416,7 +6592,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6427,7 +6603,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6438,7 +6614,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -6449,7 +6625,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6460,7 +6636,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6471,7 +6647,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -6482,7 +6658,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6493,7 +6669,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6504,7 +6680,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6515,7 +6691,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -6590,7 +6766,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6623,7 +6799,7 @@ errorLine2=" ^"> @@ -6634,7 +6810,7 @@ errorLine2=" ^"> @@ -6645,7 +6821,7 @@ errorLine2=" ^"> @@ -6656,7 +6832,7 @@ errorLine2=" ^"> @@ -7107,7 +7283,7 @@ errorLine2=" ~~"> @@ -7118,7 +7294,7 @@ errorLine2=" ~~"> @@ -7129,7 +7305,7 @@ errorLine2=" ~~"> @@ -7140,7 +7316,7 @@ errorLine2=" ~~"> @@ -7151,7 +7327,7 @@ errorLine2=" ~~"> @@ -7162,7 +7338,7 @@ errorLine2=" ~~"> @@ -7173,7 +7349,7 @@ errorLine2=" ~~"> @@ -7184,17 +7360,28 @@ errorLine2=" ~~"> + errorLine1=" android:paddingEnd="8dp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~"> + + + + @@ -7202,23 +7389,34 @@ + errorLine1=" android:paddingStart="5dp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/view_legacy_omnibar.xml" + line="179" + column="21"/> + errorLine1=" android:paddingStart="@dimen/keyline_4"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/view_legacy_omnibar_bottom.xml" + line="66" + column="21"/> + + + + @@ -7323,7 +7521,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserChromeClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserChromeClientTest.kt index af54161b10ec..378869a37aff 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserChromeClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserChromeClientTest.kt @@ -163,7 +163,7 @@ class BrowserChromeClientTest { fun whenOnReceivedTitleThenTitleReceived() { val title = "title" testee.onReceivedTitle(webView, title) - verify(mockWebViewClientListener).titleReceived(title, webView.url) + verify(mockWebViewClientListener).titleReceived(title) } @Test diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 59f0296e1d4d..a52af4ecfc46 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -102,6 +102,8 @@ import com.duckduckgo.app.browser.model.LongPressTarget import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter.QuickAccessFavorite import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.remotemessage.RemoteMessagingModel import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.viewstate.BrowserViewState @@ -158,9 +160,9 @@ import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository @@ -172,6 +174,7 @@ import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.widget.ui.WidgetCapabilities import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillCapabilityChecker +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor @@ -197,7 +200,13 @@ import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.history.api.HistoryEntry.VisitedPage import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels -import com.duckduckgo.privacy.config.api.* +import com.duckduckgo.privacy.config.api.AmpLinkInfo +import com.duckduckgo.privacy.config.api.AmpLinks +import com.duckduckgo.privacy.config.api.ContentBlocking +import com.duckduckgo.privacy.config.api.GpcException +import com.duckduckgo.privacy.config.api.PrivacyFeatureName +import com.duckduckgo.privacy.config.api.TrackingParameters +import com.duckduckgo.privacy.config.api.UnprotectedTemporary import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER_VALUE @@ -247,7 +256,14 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -258,11 +274,15 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations import org.mockito.internal.util.DefaultMockingDetails -import org.mockito.kotlin.* +import org.mockito.kotlin.KArgumentCaptor import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq +import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @FlowPreview @@ -493,9 +513,9 @@ class BrowserTabViewModelTest { whenever(mockSavedSitesRepository.getBookmarks()).thenReturn(bookmarksListFlow.consumeAsFlow()) whenever(mockRemoteMessagingRepository.messageFlow()).thenReturn(remoteMessageFlow.consumeAsFlow()) whenever(mockSettingsDataStore.automaticFireproofSetting).thenReturn(AutomaticFireproofSetting.ASK_EVERY_TIME) + whenever(mockSettingsDataStore.omnibarPosition).thenReturn(TOP) whenever(androidBrowserConfig.screenLock()).thenReturn(mockEnabledToggle) whenever(mockSSLCertificatesFeature.allowBypass()).thenReturn(mockEnabledToggle) - whenever(mockExtendedOnboardingFeatureToggles.aestheticUpdates()).thenReturn(mockEnabledToggle) whenever(subscriptions.shouldLaunchPrivacyProForUrl(any())).thenReturn(false) whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoUrl(any())).thenReturn(false) whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) @@ -530,6 +550,7 @@ class BrowserTabViewModelTest { coroutineRule.testScope, coroutineRule.testDispatcherProvider, DuckDuckGoUrlDetectorImpl(), + mockDuckPlayer, ) accessibilitySettingsDataStore = AccessibilitySettingsSharedPreferences( @@ -1891,13 +1912,42 @@ class BrowserTabViewModelTest { ) whenever(mockSavedSitesRepository.insertBookmark(title = anyString(), url = anyString())).thenReturn(bookmark) loadUrl(url = url) - testee.titleReceived(newTitle = title, url = url) + testee.titleReceived(newTitle = title) testee.onBookmarkMenuClicked() val command = captureCommands().lastValue as Command.ShowSavedSiteAddedConfirmation assertEquals(url, command.savedSiteChangedViewState.savedSite.url) assertEquals(title, command.savedSiteChangedViewState.savedSite.title) } + @Test + fun whenSiteLoadedWithSimulatedYouTubeNoCookieAndDuckPlayerEnabledThenShowWebPageTitleWithDuckPlayerIcon() = runTest { + val url = "http://youtube-nocookie.com/videoID=1234" + val title = "Duck Player" + whenever(mockDuckPlayer.isDuckPlayerUri(anyString())).thenReturn(true) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyUri())).thenReturn(true) + whenever(mockDuckPlayer.createDuckPlayerUriFromYoutubeNoCookie(any())).thenReturn("duck://player/1234") + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + + loadUrl(url = url) + testee.titleReceived(newTitle = title) + val command = captureCommands().lastValue as Command.ShowWebPageTitle + assertTrue(command.showDuckPlayerIcon) + assertEquals("duck://player/1234", command.url) + } + + @Test + fun whenSiteLoadedWithDuckPlayerDisabledThenShowWebPageTitleWithoutDuckPlayerIcon() = runTest { + val url = "http://youtube-nocookie.com/videoID=1234" + val title = "Duck Player" + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(DISABLED) + + loadUrl(url = url) + testee.titleReceived(newTitle = title) + val command = captureCommands().lastValue as Command.ShowWebPageTitle + assertFalse(command.showDuckPlayerIcon) + assertEquals("http://youtube-nocookie.com/videoID=1234", command.url) + } + @Test fun whenNoSiteAndUserSelectsToAddBookmarkThenBookmarkIsNotAdded() = runTest { val bookmark = Bookmark( @@ -3740,51 +3790,14 @@ class BrowserTabViewModelTest { assertTrue(browserViewState().isEmailSignedIn) } - @Test - fun whenEmailSignOutEventThenEmailSignEventCommandSent() = runTest { - emailStateFlow.emit(true) - emailStateFlow.emit(false) - - assertCommandIssuedTimes(2) - } - - @Test - fun whenEmailIsSignedInThenEmailSignEventCommandSent() = runTest { - emailStateFlow.emit(true) - - assertCommandIssued() - } - - @Test - fun whenConsumeAliasThenInjectAddressCommandSent() { - whenever(mockEmailManager.getAlias()).thenReturn("alias") - - testee.usePrivateDuckAddress("", "alias") - - assertCommandIssued { - assertEquals("alias", this.duckAddress) - } - } - - @Test - fun whenUseAddressThenInjectAddressCommandSent() { - whenever(mockEmailManager.getEmailAddress()).thenReturn("address") - - testee.usePersonalDuckAddress("", "address") - - assertCommandIssued { - assertEquals("address", this.duckAddress) - } - } - @Test fun whenShowEmailTooltipIfAddressExistsThenShowEmailTooltipCommandSent() { whenever(mockEmailManager.getEmailAddress()).thenReturn("address") - testee.showEmailProtectionChooseEmailPrompt() + testee.showEmailProtectionChooseEmailPrompt(urlRequest()) assertCommandIssued { - assertEquals("address", this.address) + assertEquals("address", this.duckAddress) } } @@ -3792,7 +3805,7 @@ class BrowserTabViewModelTest { fun whenShowEmailTooltipIfAddressDoesNotExistThenCommandNotSent() { whenever(mockEmailManager.getEmailAddress()).thenReturn(null) - testee.showEmailProtectionChooseEmailPrompt() + testee.showEmailProtectionChooseEmailPrompt(urlRequest()) assertCommandNotIssued() } @@ -4393,16 +4406,6 @@ class BrowserTabViewModelTest { assertShowHistoryCommandSent(expectedStackSize = 10) } - @Test - fun whenReturnNoCredentialsWithPageThenEmitCancelIncomingAutofillRequestCommand() = runTest { - val url = "originalurl.com" - testee.returnNoCredentialsWithPage(url) - - assertCommandIssued { - assertEquals(url, this.url) - } - } - @Test fun whenOnAutoconsentResultReceivedThenSiteUpdated() { updateUrl("http://www.example.com/", "http://twitter.com/explore", true) @@ -5167,11 +5170,20 @@ class BrowserTabViewModelTest { } @Test - fun whenRefreshIsTriggeredByUserThenPrivacyProtectionsPopupManagerIsNotified() = runTest { + fun whenRefreshIsTriggeredByUserThenPrivacyProtectionsPopupManagerIsNotifiedWithTopPosition() = runTest { testee.onRefreshRequested(triggeredByUser = false) - verify(mockPrivacyProtectionsPopupManager, never()).onPageRefreshTriggeredByUser() + verify(mockPrivacyProtectionsPopupManager, never()).onPageRefreshTriggeredByUser(isOmnibarAtTheTop = true) testee.onRefreshRequested(triggeredByUser = true) - verify(mockPrivacyProtectionsPopupManager).onPageRefreshTriggeredByUser() + verify(mockPrivacyProtectionsPopupManager).onPageRefreshTriggeredByUser(isOmnibarAtTheTop = true) + } + + @Test + fun whenRefreshIsTriggeredByUserThenPrivacyProtectionsPopupManagerIsNotifiedWithBottomPosition() = runTest { + whenever(mockSettingsDataStore.omnibarPosition).thenReturn(BOTTOM) + testee.onRefreshRequested(triggeredByUser = false) + verify(mockPrivacyProtectionsPopupManager, never()).onPageRefreshTriggeredByUser(isOmnibarAtTheTop = false) + testee.onRefreshRequested(triggeredByUser = true) + verify(mockPrivacyProtectionsPopupManager).onPageRefreshTriggeredByUser(isOmnibarAtTheTop = false) } @Test @@ -5197,8 +5209,8 @@ class BrowserTabViewModelTest { whenever(mockUserAllowListRepository.isDomainInUserAllowList("www.example.com")).thenReturn(true) testee.onPrivacyProtectionMenuClicked() - verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD, params, type = COUNT) - verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE, params, type = COUNT) + verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD, params, type = Count) + verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE, params, type = Count) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = false) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = true) } @@ -5459,7 +5471,7 @@ class BrowserTabViewModelTest { @Test fun whenTrackersBlockedCtaShownThenPrivacyShieldIsHighlighted() = runTest { - val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList()) + val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) testee.ctaViewState.value = ctaViewState().copy(cta = cta) testee.onOnboardingDaxTypingAnimationFinished() @@ -5469,7 +5481,7 @@ class BrowserTabViewModelTest { @Test fun givenPrivacyShieldHighlightedWhenShieldIconSelectedThenStopPulse() = runTest { - val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList()) + val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) testee.ctaViewState.value = ctaViewState().copy(cta = cta) testee.onPrivacyShieldSelected() @@ -5483,12 +5495,12 @@ class BrowserTabViewModelTest { val testParams = mapOf("daysSinceInstall" to "0", "from_onboarding" to "true") testee.onPrivacyShieldSelected() - verify(mockPixel).fire(pixel = PrivacyDashboardPixels.PRIVACY_DASHBOARD_FIRST_TIME_OPENED, parameters = testParams, type = UNIQUE) + verify(mockPixel).fire(pixel = PrivacyDashboardPixels.PRIVACY_DASHBOARD_FIRST_TIME_OPENED, parameters = testParams, type = Unique()) } @Test fun whenUserDismissDaxTrackersBlockedDialogThenFinishPrivacyShieldPulse() { - val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList()) + val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) setCta(cta) testee.onUserDismissedCta(cta) @@ -5516,7 +5528,7 @@ class BrowserTabViewModelTest { testee.onUserSubmittedQuery("foo") - verify(mockPixel).fire(ONBOARDING_SEARCH_CUSTOM, type = UNIQUE) + verify(mockPixel).fire(ONBOARDING_SEARCH_CUSTOM, type = Unique()) } @Test @@ -5527,7 +5539,7 @@ class BrowserTabViewModelTest { testee.onUserSubmittedQuery("foo") - verify(mockPixel).fire(ONBOARDING_VISIT_SITE_CUSTOM, type = UNIQUE) + verify(mockPixel).fire(ONBOARDING_VISIT_SITE_CUSTOM, type = Unique()) } @Test @@ -5578,7 +5590,7 @@ class BrowserTabViewModelTest { assertCommandIssued() verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED) - verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, emptyMap(), emptyMap(), DAILY) + verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, emptyMap(), emptyMap(), Daily()) } @Test @@ -5724,7 +5736,7 @@ class BrowserTabViewModelTest { } @Test - fun whenOnRemoveSearchSuggestionConfirmedForHistorySuggestionThenPixelFiredAndHistoryEntryRemoved() = runBlocking { + fun whenOnRemoveSearchSuggestionConfirmedForHistorySuggestionThenPixelsFiredAndHistoryEntryRemoved() = runBlocking { val suggestion = AutoCompleteHistorySuggestion(phrase = "phrase", title = "title", url = "url", isAllowedInTopHits = false) val omnibarText = "foo" @@ -5734,13 +5746,14 @@ class BrowserTabViewModelTest { testee.onRemoveSearchSuggestionConfirmed(suggestion, omnibarText) verify(mockPixel).fire(AppPixelName.AUTOCOMPLETE_RESULT_DELETED) + verify(mockPixel).fire(AppPixelName.AUTOCOMPLETE_RESULT_DELETED_DAILY, type = Daily()) verify(mockNavigationHistory).removeHistoryEntryByUrl(suggestion.url) testObserver.assertValue(omnibarText) assertCommandIssued() } @Test - fun whenOnRemoveSearchSuggestionConfirmedForHistorySearchSuggestionThenPixelFiredAndHistoryEntryRemoved() = runBlocking { + fun whenOnRemoveSearchSuggestionConfirmedForHistorySearchSuggestionThenPixelsFiredAndHistoryEntryRemoved() = runBlocking { val suggestion = AutoCompleteHistorySearchSuggestion(phrase = "phrase", isAllowedInTopHits = false) val omnibarText = "foo" @@ -5750,6 +5763,7 @@ class BrowserTabViewModelTest { testee.onRemoveSearchSuggestionConfirmed(suggestion, omnibarText) verify(mockPixel).fire(AppPixelName.AUTOCOMPLETE_RESULT_DELETED) + verify(mockPixel).fire(AppPixelName.AUTOCOMPLETE_RESULT_DELETED_DAILY, type = Daily()) verify(mockNavigationHistory).removeHistoryEntryByQuery(suggestion.phrase) testObserver.assertValue(omnibarText) assertCommandIssued() @@ -5978,6 +5992,8 @@ class BrowserTabViewModelTest { } } + private fun urlRequest() = AutofillWebMessageRequest("", "", "") + private fun givenLoginDetected(domain: String) = LoginDetected(authLoginDomain = "", forwardedToDomain = domain) private fun givenCurrentSite(domain: String): Site { @@ -6025,8 +6041,6 @@ class BrowserTabViewModelTest { title: String? = null, isBrowserShowing: Boolean = true, ) = runTest { - whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyUri())).thenReturn(false) - whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(DISABLED) whenever(mockDuckPlayer.observeUserPreferences()).thenReturn(flowOf(UserPreferences(false, Disabled))) setBrowserShowing(isBrowserShowing) @@ -6132,10 +6146,6 @@ class BrowserTabViewModelTest { fun anyUri(): Uri = any() class FakeCapabilityChecker(var enabled: Boolean) : AutofillCapabilityChecker { - override suspend fun isAutofillEnabledByConfiguration(url: String) = enabled - override suspend fun canInjectCredentialsToWebView(url: String) = enabled - override suspend fun canSaveCredentialsFromWebView(url: String) = enabled - override suspend fun canGeneratePasswordFromWebView(url: String) = enabled - override suspend fun canAccessCredentialManagementScreen() = enabled + override suspend fun canAccessCredentialManagementScreen(): Boolean = enabled } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 25fbe9a65401..d4747c0d2f06 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -61,7 +61,6 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.autoconsent.api.Autoconsent -import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.InternalTestUserChecker import com.duckduckgo.browser.api.JsInjectorPlugin import com.duckduckgo.browser.api.WebViewVersionProvider @@ -118,7 +117,6 @@ class BrowserWebViewClientTest { private val trustedCertificateStore: TrustedCertificateStore = mock() private val webViewHttpAuthStore: WebViewHttpAuthStore = mock() private val thirdPartyCookieManager: ThirdPartyCookieManager = mock() - private val browserAutofillConfigurator: BrowserAutofill.Configurator = mock() private val webResourceRequest: WebResourceRequest = mock() private val webResourceError: WebResourceError = mock() private val ampLinks: AmpLinks = mock() @@ -156,7 +154,6 @@ class BrowserWebViewClientTest { thirdPartyCookieManager, TestScope(), coroutinesTestRule.testDispatcherProvider, - browserAutofillConfigurator, ampLinks, printInjector, internalTestUserChecker, @@ -370,13 +367,6 @@ class BrowserWebViewClientTest { verify(cookieManager).flush() } - @UiThreadTest - @Test - fun whenOnPageStartedCalledThenInjectEmailAutofillJsCalled() { - testee.onPageStarted(webView, null, null) - verify(browserAutofillConfigurator).configureAutofillForCurrentPage(webView, null) - } - @UiThreadTest @Test fun whenShouldOverrideThrowsExceptionThenRecordException() { diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/TestBackForwardList.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/TestBackForwardList.kt new file mode 100644 index 000000000000..54352fcdc115 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/TestBackForwardList.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser + +import android.webkit.WebBackForwardList +import android.webkit.WebHistoryItem + +class TestBackForwardList : WebBackForwardList() { + private val fakeHistory: MutableList = mutableListOf() + private var fakeCurrentIndex = -1 + + fun addPageToHistory(webHistoryItem: WebHistoryItem) { + fakeHistory.add(webHistoryItem) + fakeCurrentIndex++ + } + + override fun getSize() = fakeHistory.size + + override fun getItemAtIndex(index: Int): WebHistoryItem = fakeHistory[index] + + override fun getCurrentItem(): WebHistoryItem? = null + + override fun getCurrentIndex(): Int = fakeCurrentIndex + + override fun clone(): WebBackForwardList = throw NotImplementedError() +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index 112d9b18d791..df545edea790 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -40,8 +40,8 @@ import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.TestEntity import com.duckduckgo.app.settings.db.SettingsDataStore 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.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.trackerdetection.model.Entity @@ -173,32 +173,32 @@ class CtaViewModelTest { @Test fun whenCtaShownAndCtaIsDaxAndCanNotSendPixelThenPixelIsNotFired() { testee.onCtaShown(DaxBubbleCta.DaxIntroSearchOptionsCta(mockOnboardingStore, mockAppInstallStore)) - verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(COUNT)) + verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(Count)) } @Test fun whenCtaShownAndCtaIsDaxAndCanSendPixelThenPixelIsFired() { whenever(mockOnboardingStore.onboardingDialogJourney).thenReturn("s:0") testee.onCtaShown(DaxBubbleCta.DaxEndCta(mockOnboardingStore, mockAppInstallStore)) - verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(COUNT)) + verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(Count)) } @Test fun whenCtaShownAndCtaIsNotDaxThenPixelIsFired() { testee.onCtaShown(HomePanelCta.AddWidgetAuto) - verify(mockPixel).fire(eq(WIDGET_CTA_SHOWN), any(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(WIDGET_CTA_SHOWN), any(), any(), eq(Count)) } @Test fun whenCtaLaunchedPixelIsFired() { testee.onUserClickCtaOkButton(HomePanelCta.AddWidgetAuto) - verify(mockPixel).fire(eq(WIDGET_CTA_LAUNCHED), any(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(WIDGET_CTA_LAUNCHED), any(), any(), eq(Count)) } @Test fun whenCtaDismissedPixelIsFired() = runTest { testee.onUserDismissedCta(HomePanelCta.AddWidgetAuto) - verify(mockPixel).fire(eq(WIDGET_CTA_DISMISSED), any(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(WIDGET_CTA_DISMISSED), any(), any(), eq(Count)) } @Test @@ -674,7 +674,7 @@ class CtaViewModelTest { @Test fun whenCtaShownIfCtaIsMarkedAsReadOnShowThenCtaInsertedInDatabase() { - testee.onCtaShown(OnboardingDaxDialogCta.DaxEndCta(mockOnboardingStore, mockAppInstallStore)) + testee.onCtaShown(OnboardingDaxDialogCta.DaxEndCta(mockOnboardingStore, mockAppInstallStore, mockSettingsDataStore)) verify(mockDismissedCtaDao).insert(DismissedCta(CtaId.DAX_END)) } @@ -745,7 +745,7 @@ class CtaViewModelTest { val site = site(url = privacyProUrl) val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) - verify(mockPixel, never()).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(UNIQUE)) + verify(mockPixel, never()).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(Unique())) assertNull(value) } @@ -761,7 +761,7 @@ class CtaViewModelTest { whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) - verify(mockPixel).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(UNIQUE)) + verify(mockPixel).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(Unique())) assertNull(value) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt deleted file mode 100644 index 2a2cc540789b..000000000000 --- a/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (c) 2022 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.app.email - -import android.webkit.WebView -import androidx.test.annotation.UiThreadTest -import androidx.test.filters.SdkSuppress -import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.autofill.DefaultEmailProtectionJavascriptInjector -import com.duckduckgo.app.autofill.EmailProtectionJavascriptInjector -import com.duckduckgo.app.browser.DuckDuckGoUrlDetectorImpl -import com.duckduckgo.app.browser.R -import com.duckduckgo.autofill.api.Autofill -import com.duckduckgo.autofill.api.AutofillFeature -import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory -import com.duckduckgo.feature.toggles.api.Toggle -import java.io.BufferedReader -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.* - -class EmailInjectorJsTest { - - private val mockEmailManager: EmailManager = mock() - private val mockDispatcherProvider: DispatcherProvider = mock() - private val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) - private val mockAutofill: Autofill = mock() - private val javascriptInjector: EmailProtectionJavascriptInjector = DefaultEmailProtectionJavascriptInjector() - - lateinit var testee: EmailInjectorJs - - @Before - fun setup() { - testee = - EmailInjectorJs( - mockEmailManager, - DuckDuckGoUrlDetectorImpl(), - mockDispatcherProvider, - autofillFeature, - javascriptInjector, - mockAutofill, - ) - whenever(mockAutofill.isAnException(any())).thenReturn(false) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectAddressThenInjectJsCodeReplacingTheAlias() { - val address = "address" - val jsToEvaluate = getAliasJsToEvaluate().replace("%s", address) - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - autofillFeature.self().setEnabled(Toggle.State(enable = true)) - - testee.injectAddressInEmailField(webView, address, "https://example.com") - - verify(webView).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectAddressAndFeatureIsDisabledThenJsCodeNotInjected() { - autofillFeature.self().setEnabled(Toggle.State(enable = true)) - - val address = "address" - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.injectAddressInEmailField(webView, address, "https://example.com") - - verify(webView, never()).evaluateJavascript(any(), any()) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectAddressAndUrlIsAnExceptionThenJsCodeNotInjected() { - whenever(mockAutofill.isAnException(any())).thenReturn(true) - - val address = "address" - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.injectAddressInEmailField(webView, address, "https://example.com") - - verify(webView, never()).evaluateJavascript(any(), any()) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenNotifyWebAppSignEventAndUrlIsNotFromDuckDuckGoAndEmailIsSignedInThenDoNotEvaluateJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(true) - val jsToEvaluate = getNotifySignOutJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.notifyWebAppSignEvent(webView, "https://example.com") - - verify(webView, never()).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenNotifyWebAppSignEventAndUrlIsNotFromDuckDuckGoAndEmailIsNotSignedInThenDoNotEvaluateJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(false) - val jsToEvaluate = getNotifySignOutJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.notifyWebAppSignEvent(webView, "https://example.com") - - verify(webView, never()).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenNotifyWebAppSignEventAndUrlIsFromDuckDuckGoAndFeatureIsDisabledAndEmailIsNotSignedInThenDoNotEvaluateJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(false) - autofillFeature.self().setEnabled(Toggle.State(enable = false)) - - val jsToEvaluate = getNotifySignOutJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.notifyWebAppSignEvent(webView, "https://duckduckgo.com/email") - - verify(webView, never()).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenNotifyWebAppSignEventAndUrlIsFromDuckDuckGoAndFeatureIsEnabledAndEmailIsNotSignedInThenEvaluateJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(false) - autofillFeature.self().setEnabled(Toggle.State(enable = true)) - - val jsToEvaluate = getNotifySignOutJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.notifyWebAppSignEvent(webView, "https://duckduckgo.com/email") - - verify(webView).evaluateJavascript(jsToEvaluate, null) - } - - private fun getAliasJsToEvaluate(): String { - val js = InstrumentationRegistry.getInstrumentation().targetContext.resources.openRawResource(R.raw.inject_alias) - .bufferedReader() - .use { it.readText() } - return "javascript:$js" - } - - private fun getNotifySignOutJsToEvaluate(): String { - val js = - InstrumentationRegistry.getInstrumentation().targetContext.resources.openRawResource(R.raw.signout_autofill) - .bufferedReader() - .use { it.readText() } - return "javascript:$js" - } - - private fun readResource(resourceName: String): BufferedReader? { - return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader() - } -} diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt index decd1f0b78e0..d00aaf97114a 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt @@ -23,7 +23,7 @@ import com.duckduckgo.app.notification.model.SchedulableNotificationPlugin import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.PluginPoint @@ -76,7 +76,7 @@ class NotificationRegistrarTest { fun whenNotificationsPreviouslyOffAndNowOnThenPixelIsFiredAndSettingsUpdated() { whenever(mockSettingsDataStore.appNotificationsEnabled).thenReturn(false) testee.updateStatus(true) - verify(mockPixel).fire(eq(AppPixelName.NOTIFICATIONS_ENABLED), any(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(AppPixelName.NOTIFICATIONS_ENABLED), any(), any(), eq(Count)) verify(mockSettingsDataStore).appNotificationsEnabled = true } @@ -84,7 +84,7 @@ class NotificationRegistrarTest { fun whenNotificationsPreviouslyOffAndStillOffThenNoPixelIsFiredAndSettingsUnchanged() { whenever(mockSettingsDataStore.appNotificationsEnabled).thenReturn(false) testee.updateStatus(false) - verify(mockPixel, never()).fire(any(), any(), any(), eq(COUNT)) + verify(mockPixel, never()).fire(any(), any(), any(), eq(Count)) verify(mockSettingsDataStore, never()).appNotificationsEnabled = true } @@ -92,7 +92,7 @@ class NotificationRegistrarTest { fun whenNotificationsPreviouslyOnAndStillOnThenNoPixelIsFiredAndSettingsUnchanged() { whenever(mockSettingsDataStore.appNotificationsEnabled).thenReturn(true) testee.updateStatus(true) - verify(mockPixel, never()).fire(any(), any(), any(), eq(COUNT)) + verify(mockPixel, never()).fire(any(), any(), any(), eq(Count)) verify(mockSettingsDataStore, never()).appNotificationsEnabled = false } @@ -100,7 +100,7 @@ class NotificationRegistrarTest { fun whenNotificationsPreviouslyOnAndNowOffPixelIsFiredAndSettingsUpdated() { whenever(mockSettingsDataStore.appNotificationsEnabled).thenReturn(true) testee.updateStatus(false) - verify(mockPixel).fire(eq(AppPixelName.NOTIFICATIONS_DISABLED), any(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(AppPixelName.NOTIFICATIONS_DISABLED), any(), any(), eq(Count)) verify(mockSettingsDataStore).appNotificationsEnabled = false } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt b/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt index c768275bad84..12f0256e3f3d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt @@ -35,6 +35,7 @@ import com.duckduckgo.app.tabs.store.TabSwitcherDataStore import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.privacy.config.api.ContentBlocking import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.consumeAsFlow @@ -61,6 +62,8 @@ class TabDataRepositoryTest { private val mockDao: TabsDao = mock() + private val mockDuckPlayer: DuckPlayer = mock() + private val daoDeletableTabs = Channel>() @After @@ -438,6 +441,7 @@ class TabDataRepositoryTest { coroutinesTestRule.testScope, coroutinesTestRule.testDispatcherProvider, duckDuckGoUrlDetector, + mockDuckPlayer, ), webViewPreviewPersister, faviconManager, diff --git a/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsActivity.kt b/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsActivity.kt index 039fafd15426..dd8d7cec1643 100644 --- a/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsActivity.kt +++ b/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsActivity.kt @@ -89,7 +89,7 @@ class DevSettingsActivity : DuckDuckGoActivity() { private fun configureUiEventHandlers() { binding.enableWebContentDebugging.quietlySetIsChecked(webContentDebuggingFeature.webContentDebugging().isEnabled()) { _, isChecked -> - webContentDebuggingFeature.webContentDebugging().setEnabled(Toggle.State(enable = isChecked)) + webContentDebuggingFeature.webContentDebugging().setRawStoredState(Toggle.State(enable = isChecked)) } binding.triggerAnr.setOnClickListener { Handler(Looper.getMainLooper()).post { diff --git a/app/src/main/java/com/duckduckgo/app/appearance/AppearanceActivity.kt b/app/src/main/java/com/duckduckgo/app/appearance/AppearanceActivity.kt index f9bf820f4781..06855af2ca2a 100644 --- a/app/src/main/java/com/duckduckgo/app/appearance/AppearanceActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/appearance/AppearanceActivity.kt @@ -25,14 +25,21 @@ import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.appearance.AppearanceViewModel.Command +import com.duckduckgo.app.appearance.AppearanceViewModel.Command.LaunchAppIcon +import com.duckduckgo.app.appearance.AppearanceViewModel.Command.LaunchOmnibarPositionSettings +import com.duckduckgo.app.appearance.AppearanceViewModel.Command.LaunchThemeSettings +import com.duckduckgo.app.appearance.AppearanceViewModel.Command.UpdateTheme import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.ActivityAppearanceBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.fire.FireActivity import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.DuckDuckGoTheme import com.duckduckgo.common.ui.sendThemeChangedBroadcast import com.duckduckgo.common.ui.view.dialog.RadioListAlertDialogBuilder import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder +import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope import kotlinx.coroutines.flow.launchIn @@ -46,7 +53,7 @@ class AppearanceActivity : DuckDuckGoActivity() { private val viewModel: AppearanceViewModel by bindViewModel() private val binding: ActivityAppearanceBinding by viewBinding() - private val forceDarkModeToggleListener = CompoundButton.OnCheckedChangeListener { view, isChecked -> + private val forceDarkModeToggleListener = CompoundButton.OnCheckedChangeListener { _, isChecked -> viewModel.onForceDarkModeSettingChanged(isChecked) TextAlertDialogBuilder(this) @@ -87,6 +94,7 @@ class AppearanceActivity : DuckDuckGoActivity() { private fun configureUiEventHandlers() { binding.selectedThemeSetting.setClickListener { viewModel.userRequestedToChangeTheme() } binding.changeAppIconSetting.setOnClickListener { viewModel.userRequestedToChangeIcon() } + binding.addressBarPositionSetting.setOnClickListener { viewModel.userRequestedToChangeAddressBarPosition() } } private fun observeViewModel() { @@ -99,6 +107,7 @@ class AppearanceActivity : DuckDuckGoActivity() { binding.experimentalNightMode.quietlySetIsChecked(viewState.forceDarkModeEnabled, forceDarkModeToggleListener) binding.experimentalNightMode.isEnabled = viewState.canForceDarkMode binding.experimentalNightMode.isVisible = viewState.supportsForceDarkMode + updateSelectedOmnibarPosition(it.isOmnibarPositionFeatureEnabled, it.omnibarPosition) } }.launchIn(lifecycleScope) @@ -119,11 +128,29 @@ class AppearanceActivity : DuckDuckGoActivity() { binding.selectedThemeSetting.setSecondaryText(subtitle) } + private fun updateSelectedOmnibarPosition(isFeatureEnabled: Boolean, position: OmnibarPosition) { + if (isFeatureEnabled) { + val subtitle = getString( + when (position) { + OmnibarPosition.TOP -> R.string.settingsAddressBarPositionTop + OmnibarPosition.BOTTOM -> R.string.settingsAddressBarPositionBottom + }, + ) + binding.addressBarPositionSetting.setSecondaryText(subtitle) + binding.addressBarPositionSettingDivider.show() + binding.addressBarPositionSetting.show() + } else { + binding.addressBarPositionSettingDivider.gone() + binding.addressBarPositionSetting.gone() + } + } + private fun processCommand(it: Command) { when (it) { - is Command.LaunchAppIcon -> launchAppIconChange() - is Command.UpdateTheme -> sendThemeChangedBroadcast() - is Command.LaunchThemeSettings -> launchThemeSelector(it.theme) + is LaunchAppIcon -> launchAppIconChange() + is UpdateTheme -> sendThemeChangedBroadcast() + is LaunchThemeSettings -> launchThemeSelector(it.theme) + is LaunchOmnibarPositionSettings -> launchOmnibarPositionSelector(it.position) } } @@ -159,4 +186,27 @@ class AppearanceActivity : DuckDuckGoActivity() { ) .show() } + + private fun launchOmnibarPositionSelector(position: OmnibarPosition) { + RadioListAlertDialogBuilder(this) + .setTitle(R.string.settingsAddressBarPositionTitle) + .setOptions( + listOf( + R.string.settingsAddressBarPositionTop, + R.string.settingsAddressBarPositionBottom, + ), + OmnibarPosition.entries.indexOf(position) + 1, + ) + .setPositiveButton(R.string.dialogSave) + .setNegativeButton(R.string.cancel) + .addEventListener( + object : RadioListAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked(selectedItem: Int) { + val newPosition = OmnibarPosition.entries[selectedItem - 1] + viewModel.onOmnibarPositionUpdated(newPosition) + } + }, + ) + .show() + } } diff --git a/app/src/main/java/com/duckduckgo/app/appearance/AppearanceViewModel.kt b/app/src/main/java/com/duckduckgo/app/appearance/AppearanceViewModel.kt index e6fccd6f8a4b..d10b3157b0a5 100644 --- a/app/src/main/java/com/duckduckgo/app/appearance/AppearanceViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/appearance/AppearanceViewModel.kt @@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.webkit.WebViewFeature import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.omnibar.ChangeOmnibarPositionFeature +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.icon.api.AppIcon import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.settings.db.SettingsDataStore @@ -28,6 +30,7 @@ import com.duckduckgo.common.ui.DuckDuckGoTheme import com.duckduckgo.common.ui.store.ThemingDataStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -35,6 +38,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -45,6 +49,8 @@ class AppearanceViewModel @Inject constructor( private val settingsDataStore: SettingsDataStore, private val pixel: Pixel, private val dispatcherProvider: DispatcherProvider, + private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature, + private val loadingBarExperimentManager: LoadingBarExperimentManager, ) : ViewModel() { data class ViewState( @@ -53,12 +59,15 @@ class AppearanceViewModel @Inject constructor( val forceDarkModeEnabled: Boolean = false, val canForceDarkMode: Boolean = false, val supportsForceDarkMode: Boolean = true, + val omnibarPosition: OmnibarPosition = OmnibarPosition.TOP, + val isOmnibarPositionFeatureEnabled: Boolean = true, ) sealed class Command { data class LaunchThemeSettings(val theme: DuckDuckGoTheme) : Command() - object LaunchAppIcon : Command() - object UpdateTheme : Command() + data object LaunchAppIcon : Command() + data object UpdateTheme : Command() + data class LaunchOmnibarPositionSettings(val position: OmnibarPosition) : Command() } private val viewState = MutableStateFlow(ViewState()) @@ -66,15 +75,18 @@ class AppearanceViewModel @Inject constructor( fun viewState(): Flow = viewState.onStart { viewModelScope.launch { - viewState.emit( + viewState.update { currentViewState().copy( theme = themingDataStore.theme, appIcon = settingsDataStore.appIcon, forceDarkModeEnabled = settingsDataStore.experimentalWebsiteDarkMode, canForceDarkMode = canForceDarkMode(), supportsForceDarkMode = WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING), - ), - ) + omnibarPosition = settingsDataStore.omnibarPosition, + isOmnibarPositionFeatureEnabled = changeOmnibarPositionFeature.self().isEnabled() && + !loadingBarExperimentManager.isExperimentEnabled(), // feature disabled during loading experiment to avoid conflicts + ) + } } } @@ -96,6 +108,11 @@ class AppearanceViewModel @Inject constructor( pixel.fire(AppPixelName.SETTINGS_APP_ICON_PRESSED) } + fun userRequestedToChangeAddressBarPosition() { + viewModelScope.launch { command.send(Command.LaunchOmnibarPositionSettings(viewState.value.omnibarPosition)) } + pixel.fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_PRESSED) + } + fun onThemeSelected(selectedTheme: DuckDuckGoTheme) { Timber.d("User toggled theme, theme to set: $selectedTheme") if (themingDataStore.isCurrentlySelected(selectedTheme)) { @@ -105,7 +122,7 @@ class AppearanceViewModel @Inject constructor( viewModelScope.launch(dispatcherProvider.io()) { themingDataStore.theme = selectedTheme withContext(dispatcherProvider.main()) { - viewState.emit(currentViewState().copy(theme = selectedTheme, forceDarkModeEnabled = canForceDarkMode())) + viewState.update { currentViewState().copy(theme = selectedTheme, forceDarkModeEnabled = canForceDarkMode()) } command.send(Command.UpdateTheme) } } @@ -119,6 +136,18 @@ class AppearanceViewModel @Inject constructor( pixel.fire(pixelName) } + fun onOmnibarPositionUpdated(position: OmnibarPosition) { + viewModelScope.launch(dispatcherProvider.io()) { + settingsDataStore.omnibarPosition = position + viewState.update { currentViewState().copy(omnibarPosition = position) } + + when (position) { + OmnibarPosition.TOP -> pixel.fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_TOP) + OmnibarPosition.BOTTOM -> pixel.fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_BOTTOM) + } + } + } + private fun currentViewState(): ViewState { return viewState.value } diff --git a/app/src/main/java/com/duckduckgo/app/autofill/DefaultEmailProtectionJavascriptInjector.kt b/app/src/main/java/com/duckduckgo/app/autofill/DefaultEmailProtectionJavascriptInjector.kt deleted file mode 100644 index d402b019a509..000000000000 --- a/app/src/main/java/com/duckduckgo/app/autofill/DefaultEmailProtectionJavascriptInjector.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2022 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.app.autofill - -import android.content.Context -import com.duckduckgo.app.browser.R -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import dagger.SingleInstanceIn -import javax.inject.Inject - -@SingleInstanceIn(AppScope::class) -@ContributesBinding(AppScope::class) -class DefaultEmailProtectionJavascriptInjector @Inject constructor() : EmailProtectionJavascriptInjector { - private lateinit var aliasFunctions: String - private lateinit var signOutFunctions: String - - override fun getAliasFunctions( - context: Context, - alias: String?, - ): String { - if (!this::aliasFunctions.isInitialized) { - aliasFunctions = context.resources.openRawResource(R.raw.inject_alias).bufferedReader().use { it.readText() } - } - return aliasFunctions.replace("%s", alias.orEmpty()) - } - - override fun getSignOutFunctions( - context: Context, - ): String { - if (!this::signOutFunctions.isInitialized) { - signOutFunctions = context.resources.openRawResource(R.raw.signout_autofill).bufferedReader().use { it.readText() } - } - return signOutFunctions - } -} 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 efb2e4af6f13..3cccb8fad1f0 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt @@ -31,7 +31,7 @@ import com.duckduckgo.app.brokensite.model.SiteProtectionsState.ENABLED 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.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 @@ -187,10 +187,10 @@ class BrokenSiteViewModel @Inject constructor( val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() if (protectionsEnabled) { userAllowListRepository.removeDomainFromUserAllowList(domain) - pixel.fire(AppPixelName.BROKEN_SITE_ALLOWLIST_REMOVE, pixelParams, type = COUNT) + pixel.fire(AppPixelName.BROKEN_SITE_ALLOWLIST_REMOVE, pixelParams, type = Count) } else { userAllowListRepository.addDomainToUserAllowList(domain) - pixel.fire(AppPixelName.BROKEN_SITE_ALLOWLIST_ADD, pixelParams, type = COUNT) + pixel.fire(AppPixelName.BROKEN_SITE_ALLOWLIST_ADD, pixelParams, type = Count) } privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled) protectionsToggleUsageListener.onPrivacyProtectionsToggleUsed() 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 aa8fd23901fc..3981de8bba3f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.content.Intent.EXTRA_TEXT import android.os.Bundle import android.os.Handler +import android.os.Looper import android.os.Message import android.view.KeyEvent import android.view.View @@ -39,6 +40,8 @@ import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.BrowserViewModel.Command.Query import com.duckduckgo.app.browser.databinding.ActivityBrowserBinding import com.duckduckgo.app.browser.databinding.IncludeOmnibarToolbarMockupBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.shortcut.ShortcutBuilder import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams @@ -167,7 +170,18 @@ open class BrowserActivity : DuckDuckGoActivity() { instanceStateBundles = CombinedInstanceState(originalInstanceState = savedInstanceState, newInstanceState = newInstanceState) super.onCreate(savedInstanceState = newInstanceState, daggerInject = false) - toolbarMockupBinding = IncludeOmnibarToolbarMockupBinding.bind(binding.root) + + toolbarMockupBinding = when (settingsDataStore.omnibarPosition) { + TOP -> { + binding.bottomMockupToolbar.appBarLayoutMockup.gone() + binding.topMockupToolbar + } + BOTTOM -> { + binding.topMockupToolbar.appBarLayoutMockup.gone() + binding.bottomMockupToolbar + } + } + setContentView(binding.root) viewModel.viewState.observe(this) { renderer.renderBrowserViewState(it) @@ -319,14 +333,17 @@ open class BrowserActivity : DuckDuckGoActivity() { Toast.makeText(applicationContext, R.string.fireDataCleared, Toast.LENGTH_LONG).show() } - if (emailProtectionLinkVerifier.shouldDelegateToInContextView(intent.intentText, currentTab?.inContextEmailProtectionShowing)) { - currentTab?.showEmailProtectionInContextWebFlow(intent.intentText) + val inContextSignupState = currentTab?.inContextEmailProtectionSignupState + if (emailProtectionLinkVerifier.shouldDelegateToInContextView(intent.intentText, inContextSignupState?.showing)) { + currentTab?.resumeEmailProtectionInContextWebFlow( + verificationUrl = intent.intentText, + messageRequestId = inContextSignupState?.requestId!!, + ) Timber.v("Verification link was consumed, so don't allow it to open in a new tab") return } - // the BrowserActivity will automatically clear its stack of activities when being brought to the foreground, so this can no longer be true - currentTab?.inContextEmailProtectionShowing = false + currentTab?.inContextEmailProtectionSignupState = InProgressEmailProtectionSignupState(showing = false) if (launchNewSearch(intent)) { Timber.w("new tab requested") @@ -528,7 +545,7 @@ open class BrowserActivity : DuckDuckGoActivity() { private fun hideMockupOmnibar() { // Delaying this code to avoid race condition when fragment and activity recreated - Handler().postDelayed( + Handler(Looper.getMainLooper()).postDelayed( { if (this::toolbarMockupBinding.isInitialized) { toolbarMockupBinding.appBarLayoutMockup.visibility = View.GONE @@ -726,3 +743,9 @@ private class TabList() : ArrayList() { return super.add(element) } } + +// Needed to keep track of in-context email protection signup state +data class InProgressEmailProtectionSignupState( + val showing: Boolean = false, + val requestId: String? = null, +) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt index adbc6a0fbd74..366645e15323 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt @@ -111,7 +111,7 @@ class BrowserChromeClient @Inject constructor( view: WebView, title: String, ) { - webViewClientListener?.titleReceived(title, view.url) + webViewClientListener?.titleReceived(title) } override fun onShowFileChooser( 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 46b797ba38b8..6c80e0fd6232 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -116,6 +116,8 @@ import com.duckduckgo.app.browser.R.string import com.duckduckgo.app.browser.SSLErrorType.NONE import com.duckduckgo.app.browser.WebViewErrorResponse.LOADING import com.duckduckgo.app.browser.WebViewErrorResponse.OMITTED +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability import com.duckduckgo.app.browser.applinks.AppLinksLauncher import com.duckduckgo.app.browser.applinks.AppLinksSnackBarConfigurator import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter @@ -130,7 +132,6 @@ import com.duckduckgo.app.browser.databinding.ContentSiteLocationPermissionDialo import com.duckduckgo.app.browser.databinding.ContentSystemLocationPermissionDialogBinding import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding import com.duckduckgo.app.browser.databinding.HttpAuthenticationBinding -import com.duckduckgo.app.browser.databinding.PopupWindowBrowserMenuBinding import com.duckduckgo.app.browser.downloader.BlobConverterInjector import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.browser.filechooser.FileChooserIntentBuilder @@ -148,6 +149,7 @@ import com.duckduckgo.app.browser.model.BasicAuthenticationCredentials import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.model.LongPressTarget import com.duckduckgo.app.browser.newtab.NewTabPageProvider +import com.duckduckgo.app.browser.omnibar.Omnibar import com.duckduckgo.app.browser.omnibar.OmnibarScrolling import com.duckduckgo.app.browser.omnibar.animations.BrowserTrackersAnimatorHelper import com.duckduckgo.app.browser.omnibar.animations.PrivacyShieldAnimationHelper @@ -205,7 +207,7 @@ import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_BUTTON_STATE import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator import com.duckduckgo.app.tabs.ui.TabSwitcherActivity @@ -213,12 +215,12 @@ import com.duckduckgo.app.widget.AddWidgetLauncher import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autoconsent.api.Autoconsent import com.duckduckgo.autoconsent.api.AutoconsentCallback -import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreenDirectlyViewCredentialsParams import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreenShowSuggestionsForSiteParams import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.Callback import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory @@ -228,9 +230,8 @@ import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpHandleVerificationLink -import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpScreenNoParams import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpScreenResult -import com.duckduckgo.autofill.api.EmailProtectionUserPromptListener +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpStartScreen import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.ExactMatch import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.NoMatch @@ -238,10 +239,9 @@ import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCrede import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.UsernameMatch import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.UsernameMissing import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog -import com.duckduckgo.autofill.api.credential.saving.DuckAddressLoginCreator import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType -import com.duckduckgo.autofill.api.emailprotection.EmailInjector +import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.common.ui.DuckDuckGoActivity @@ -348,8 +348,7 @@ class BrowserTabFragment : TrackersAnimatorListener, DownloadConfirmationDialogListener, SitePermissionsGrantedListener, - AutofillEventListener, - EmailProtectionUserPromptListener { + AutofillEventListener { private val supervisorJob = SupervisorJob() @@ -419,9 +418,6 @@ class BrowserTabFragment : @Inject lateinit var thirdPartyCookieManager: ThirdPartyCookieManager - @Inject - lateinit var emailInjector: EmailInjector - @Inject lateinit var browserAutofill: BrowserAutofill @@ -471,9 +467,6 @@ class BrowserTabFragment : @Inject lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory - @Inject - lateinit var duckAddressInjectedResultHandler: DuckAddressLoginCreator - @Inject lateinit var existingCredentialMatchDetector: ExistingCredentialMatchDetector @@ -486,9 +479,6 @@ class BrowserTabFragment : @Inject lateinit var autoconsent: Autoconsent - @Inject - lateinit var autofillCapabilityChecker: AutofillCapabilityChecker - @Inject lateinit var sitePermissionsDialogLauncher: SitePermissionsDialogLauncher @@ -524,6 +514,9 @@ class BrowserTabFragment : @Inject lateinit var clientBrandHintProvider: ClientBrandHintProvider + @Inject + lateinit var autofillMessagePoster: AutofillMessagePoster + @Inject lateinit var subscriptions: Subscriptions @@ -554,12 +547,15 @@ class BrowserTabFragment : @Inject lateinit var loadingBarExperimentManager: LoadingBarExperimentManager + @Inject + lateinit var webViewCapabilityChecker: WebViewCapabilityChecker + /** * 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 * We need to be able to determine if inContextEmailProtection view was showing. If it was, it will consume email verification links. */ - var inContextEmailProtectionShowing: Boolean = false + var inContextEmailProtectionSignupState = InProgressEmailProtectionSignupState() private var urlExtractingWebView: UrlExtractingWebView? = null @@ -591,7 +587,7 @@ class BrowserTabFragment : private val downloadMessagesJob = ConflatedJob() private val viewModel: BrowserTabViewModel by lazy { - val viewModel = ViewModelProvider(this, viewModelFactory).get(BrowserTabViewModel::class.java) + val viewModel = ViewModelProvider(this, viewModelFactory)[BrowserTabViewModel::class.java] viewModel.loadData(tabId, initialUrl, skipHome, isLaunchedFromExternalApp) launchDownloadMessagesJob() viewModel @@ -599,6 +595,8 @@ class BrowserTabFragment : private val binding: FragmentBrowserTabBinding by viewBinding() + private lateinit var omnibar: Omnibar + private lateinit var webViewContainer: FrameLayout private var bookmarksBottomSheetDialog: BookmarksBottomSheetDialog.Builder? = null @@ -607,7 +605,7 @@ class BrowserTabFragment : private var autocompleteFirstVisibleItemPosition: Int = 0 private val findInPage - get() = binding.legacyOmnibar.findInPage + get() = omnibar.findInPage private val newBrowserTab get() = binding.includeNewBrowserTab @@ -624,7 +622,7 @@ class BrowserTabFragment : private val daxDialogOnboardingCta get() = binding.includeOnboardingDaxDialog - private val smoothProgressAnimator by lazy { SmoothProgressAnimator(binding.legacyOmnibar.pageLoadingIndicator) } + private val smoothProgressAnimator by lazy { SmoothProgressAnimator(omnibar.pageLoadingIndicator) } // Optimization to prevent against excessive work generating WebView previews; an existing job will be cancelled if a new one is launched private var bitmapGeneratorJob: Job? = null @@ -633,26 +631,26 @@ class BrowserTabFragment : get() = activity as? BrowserActivity private val tabsButton: TabSwitcherButton? - get() = binding.legacyOmnibar.tabsMenu + get() = omnibar.tabsMenu private val fireMenuButton: ViewGroup? - get() = binding.legacyOmnibar.fireIconMenu + get() = omnibar.fireIconMenu private val menuButton: ViewGroup? - get() = binding.legacyOmnibar.browserMenu + get() = omnibar.browserMenu private var webView: DuckDuckGoWebView? = null private val activityResultHandlerEmailProtectionInContextSignup = registerForActivityResult(StartActivityForResult()) { result: ActivityResult -> when (result.resultCode) { EmailProtectionInContextSignUpScreenResult.SUCCESS -> { - browserAutofill.inContextEmailProtectionFlowFinished() - inContextEmailProtectionShowing = false + postEmailProtectionFlowFinishedResult(result.data) + inContextEmailProtectionSignupState = InProgressEmailProtectionSignupState(showing = false) } EmailProtectionInContextSignUpScreenResult.CANCELLED -> { - browserAutofill.inContextEmailProtectionFlowFinished() - inContextEmailProtectionShowing = false + postEmailProtectionFlowFinishedResult(result.data) + inContextEmailProtectionSignupState = InProgressEmailProtectionSignupState(showing = false) } else -> { @@ -662,6 +660,12 @@ class BrowserTabFragment : } } + private fun postEmailProtectionFlowFinishedResult(result: Intent?) { + val requestId = result?.getStringExtra(EmailProtectionInContextSignUpScreenResult.RESULT_KEY_REQUEST_ID) ?: return + val message = result.getStringExtra(EmailProtectionInContextSignUpScreenResult.RESULT_KEY_MESSAGE) ?: return + autofillMessagePoster.postMessage(message, requestId) + } + private val errorSnackbar: Snackbar by lazy { binding.browserLayout.makeSnackbarWithNoBottomInset(R.string.crashedWebViewErrorMessage, Snackbar.LENGTH_INDEFINITE) .setBehavior(NonDismissibleBehavior()) @@ -676,13 +680,13 @@ class BrowserTabFragment : private val omnibarInputTextWatcher = object : TextChangedWatcher() { override fun afterTextChanged(editable: Editable) { viewModel.onOmnibarInputStateChanged( - binding.legacyOmnibar.omnibarTextInput.text.toString(), - binding.legacyOmnibar.omnibarTextInput.hasFocus(), + omnibar.omnibarTextInput.text.toString(), + omnibar.omnibarTextInput.hasFocus(), true, ) viewModel.triggerAutocomplete( - binding.legacyOmnibar.omnibarTextInput.text.toString(), - binding.legacyOmnibar.omnibarTextInput.hasFocus(), + omnibar.omnibarTextInput.text.toString(), + omnibar.omnibarTextInput.hasFocus(), true, ) } @@ -691,8 +695,8 @@ class BrowserTabFragment : private val showSuggestionsListener = object : ShowSuggestionsListener { override fun showSuggestions() { viewModel.triggerAutocomplete( - binding.legacyOmnibar.omnibarTextInput.text.toString(), - binding.legacyOmnibar.omnibarTextInput.hasFocus(), + omnibar.omnibarTextInput.text.toString(), + omnibar.omnibarTextInput.hasFocus(), true, ) } @@ -710,9 +714,9 @@ class BrowserTabFragment : animatorHelper.createCookiesAnimation( it, omnibarViews(), - binding.legacyOmnibar.cookieDummyView, - binding.legacyOmnibar.cookieAnimation, - binding.legacyOmnibar.sceneRoot, + omnibar.cookieDummyView, + omnibar.cookieAnimation, + omnibar.sceneRoot, isCosmetic, ) } @@ -731,38 +735,50 @@ class BrowserTabFragment : private val autofillCallback = object : Callback { override suspend fun onCredentialsAvailableToInject( - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: List, triggerType: LoginTriggerType, ) { withContext(dispatchers.main()) { - showAutofillDialogChooseCredentials(originalUrl, credentials, triggerType) + showAutofillDialogChooseCredentials(autofillWebMessageRequest, credentials, triggerType) } } override suspend fun onGeneratedPasswordAvailableToUse( - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, username: String?, generatedPassword: String, ) { // small delay added to let keyboard disappear if it was present; helps avoid jarring transition - delay(100) + delay(KEYBOARD_DELAY) withContext(dispatchers.main()) { - showUserAutoGeneratedPasswordDialog(originalUrl, username, generatedPassword) + showUserAutoGeneratedPasswordDialog(autofillWebMessageRequest, username, generatedPassword) } } - override fun noCredentialsAvailable(originalUrl: String) { - viewModel.returnNoCredentialsWithPage(originalUrl) - } - override fun onCredentialsSaved(savedCredentials: LoginCredentials) { viewModel.onShowUserCredentialsSaved(savedCredentials) } + override fun showNativeChooseEmailAddressPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { + viewModel.showEmailProtectionChooseEmailPrompt(autofillWebMessageRequest) + } + + override fun showNativeInContextEmailProtectionSignupPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { + context?.let { + val url = webView?.url ?: return + + val dialog = credentialAutofillDialogFactory.emailProtectionInContextSignUpDialog( + tabId = tabId, + autofillWebMessageRequest = autofillWebMessageRequest, + ) + showDialogHidingPrevious(dialog, EmailProtectionInContextSignUpDialog.TAG, url) + } + } + override suspend fun onCredentialsAvailableToSave( - currentUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, ) { val username = credentials.username @@ -773,39 +789,41 @@ class BrowserTabFragment : return } + val currentUrl = autofillWebMessageRequest.requestOrigin + val matchType = existingCredentialMatchDetector.determine(currentUrl, username, password) Timber.v("MatchType is %s", matchType.javaClass.simpleName) // we need this delay to ensure web navigation / form submission events aren't blocked - delay(100) + delay(NAVIGATION_DELAY) withContext(dispatchers.main()) { when (matchType) { ExactMatch -> Timber.w("Credentials already exist for %s", currentUrl) - UsernameMatch -> showAutofillDialogUpdatePassword(currentUrl, credentials) - UsernameMissing -> showAutofillDialogUpdateUsername(currentUrl, credentials) - NoMatch -> showAutofillDialogSaveCredentials(currentUrl, credentials) - UrlOnlyMatch -> showAutofillDialogSaveCredentials(currentUrl, credentials) + UsernameMatch -> showAutofillDialogUpdatePassword(autofillWebMessageRequest, credentials) + UsernameMissing -> showAutofillDialogUpdateUsername(autofillWebMessageRequest, credentials) + NoMatch -> showAutofillDialogSaveCredentials(autofillWebMessageRequest, credentials) + UrlOnlyMatch -> showAutofillDialogSaveCredentials(autofillWebMessageRequest, credentials) } } } private fun showUserAutoGeneratedPasswordDialog( - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, username: String?, generatedPassword: String, ) { val url = webView?.url ?: return - if (url != originalUrl) { + if (url != autofillWebMessageRequest.originalPageUrl) { Timber.w("WebView url has changed since autofill request; bailing") return } - val dialog = credentialAutofillDialogFactory.autofillGeneratePasswordDialog(url, username, generatedPassword, tabId) - showDialogHidingPrevious(dialog, UseGeneratedPasswordDialog.TAG, originalUrl) + val dialog = credentialAutofillDialogFactory.autofillGeneratePasswordDialog(autofillWebMessageRequest, username, generatedPassword, tabId) + showDialogHidingPrevious(dialog, UseGeneratedPasswordDialog.TAG, autofillWebMessageRequest.originalPageUrl) } private fun showAutofillDialogChooseCredentials( - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: List, triggerType: LoginTriggerType, ) { @@ -814,12 +832,12 @@ class BrowserTabFragment : return } val url = webView?.url ?: return - if (url != originalUrl) { + if (url != autofillWebMessageRequest.originalPageUrl) { Timber.w("WebView url has changed since autofill request; bailing") return } - val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(url, credentials, triggerType, tabId) - showDialogHidingPrevious(dialog, CredentialAutofillPickerDialog.TAG, originalUrl) + val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(autofillWebMessageRequest, credentials, triggerType, tabId) + showDialogHidingPrevious(dialog, CredentialAutofillPickerDialog.TAG, autofillWebMessageRequest.originalPageUrl) } } @@ -858,7 +876,7 @@ class BrowserTabFragment : voiceSearchLauncher.registerResultsCallback(this, requireActivity(), BROWSER) { when (it) { is VoiceSearchLauncher.Event.VoiceRecognitionSuccess -> { - binding.legacyOmnibar.omnibarTextInput.setText(it.result) + omnibar.omnibarTextInput.setText(it.result) userEnteredQuery(it.result) resumeWebView() } @@ -897,6 +915,7 @@ class BrowserTabFragment : override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) + omnibar = Omnibar(settingsDataStore.omnibarPosition, binding) webViewContainer = binding.webViewContainer configureObservers() configurePrivacyShield() @@ -944,20 +963,20 @@ class BrowserTabFragment : } private fun configureCustomTab() { - binding.legacyOmnibar.omniBarContainer.hide() - binding.legacyOmnibar.fireIconMenu.hide() - binding.legacyOmnibar.tabsMenu.hide() + omnibar.omniBarContainer.hide() + omnibar.fireIconMenu.hide() + omnibar.tabsMenu.hide() - binding.legacyOmnibar.toolbar.background = ColorDrawable(customTabToolbarColor) - binding.legacyOmnibar.toolbarContainer.background = ColorDrawable(customTabToolbarColor) + omnibar.toolbar.background = ColorDrawable(customTabToolbarColor) + omnibar.toolbarContainer.background = ColorDrawable(customTabToolbarColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabToolbar.show() + omnibar.customTabToolbarContainer.customTabToolbar.show() - binding.legacyOmnibar.customTabToolbarContainer.customTabCloseIcon.setOnClickListener { + omnibar.customTabToolbarContainer.customTabCloseIcon.setOnClickListener { requireActivity().finish() } - binding.legacyOmnibar.customTabToolbarContainer.customTabShieldIcon.setOnClickListener { _ -> + omnibar.customTabToolbarContainer.customTabShieldIcon.setOnClickListener { _ -> val params = PrivacyDashboardHybridScreenParams.PrivacyDashboardPrimaryScreen(tabId) val intent = globalActivityStarter.startIntent(requireContext(), params) contentScopeScripts.sendSubscriptionEvent(createBreakageReportingEventData()) @@ -965,16 +984,16 @@ class BrowserTabFragment : pixel.fire(CustomTabPixelNames.CUSTOM_TABS_PRIVACY_DASHBOARD_OPENED) } - binding.legacyOmnibar.customTabToolbarContainer.customTabDomain.text = viewModel.url?.extractDomain() - binding.legacyOmnibar.customTabToolbarContainer.customTabDomainOnly.text = viewModel.url?.extractDomain() - binding.legacyOmnibar.customTabToolbarContainer.customTabDomainOnly.show() + omnibar.customTabToolbarContainer.customTabDomain.text = viewModel.url?.extractDomain() + omnibar.customTabToolbarContainer.customTabDomainOnly.text = viewModel.url?.extractDomain() + omnibar.customTabToolbarContainer.customTabDomainOnly.show() val foregroundColor = calculateBlackOrWhite(customTabToolbarColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabCloseIcon.setColorFilter(foregroundColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabDomain.setTextColor(foregroundColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabDomainOnly.setTextColor(foregroundColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabTitle.setTextColor(foregroundColor) - binding.legacyOmnibar.browserMenuImageView.setColorFilter(foregroundColor) + omnibar.customTabToolbarContainer.customTabCloseIcon.setColorFilter(foregroundColor) + omnibar.customTabToolbarContainer.customTabDomain.setTextColor(foregroundColor) + omnibar.customTabToolbarContainer.customTabDomainOnly.setTextColor(foregroundColor) + omnibar.customTabToolbarContainer.customTabTitle.setTextColor(foregroundColor) + omnibar.browserMenuImageView.setColorFilter(foregroundColor) requireActivity().window.navigationBarColor = customTabToolbarColor requireActivity().window.statusBarColor = customTabToolbarColor @@ -999,7 +1018,7 @@ class BrowserTabFragment : private fun initPrivacyProtectionsPopup() { privacyProtectionsPopup = privacyProtectionsPopupFactory.createPopup( - anchor = binding.legacyOmnibar.shieldIcon, + anchor = omnibar.shieldIcon, ) privacyProtectionsPopup.events .onEach(viewModel::onPrivacyProtectionsPopupUiEvent) @@ -1039,12 +1058,17 @@ class BrowserTabFragment : private fun launchTabSwitcher() { val activity = activity ?: return startActivity(TabSwitcherActivity.intent(activity, tabId)) - activity.overridePendingTransition(R.anim.tab_anim_fade_in, R.anim.slide_to_bottom) } override fun onResume() { super.onResume() - binding.legacyOmnibar.setExpanded(true) + + if (viewModel.hasOmnibarPositionChanged(omnibar.omnibarPosition)) { + requireActivity().recreate() + return + } + omnibar.appBarLayout.setExpanded(true) + viewModel.onViewResumed() // onResume can be called for a hidden/backgrounded fragment, ensure this tab is visible. @@ -1231,8 +1255,8 @@ class BrowserTabFragment : newBrowserTab.newTabContainerLayout.show() binding.browserLayout.gone() webViewContainer.gone() - omnibarScrolling.disableOmnibarScrolling(binding.legacyOmnibar.toolbarContainer) - binding.legacyOmnibar.setExpanded(true) + omnibarScrolling.disableOmnibarScrolling(omnibar.toolbarContainer) + omnibar.appBarLayout.setExpanded(true) webView?.onPause() webView?.hide() errorView.errorLayout.gone() @@ -1258,8 +1282,8 @@ class BrowserTabFragment : newBrowserTab.newTabLayout.gone() newBrowserTab.newTabContainerLayout.gone() sslErrorView.gone() - binding.legacyOmnibar.setExpanded(true) - binding.legacyOmnibar.shieldIcon.isInvisible = true + omnibar.appBarLayout.setExpanded(true) + omnibar.shieldIcon.isInvisible = true webView?.onPause() webView?.hide() errorView.errorMessage.text = getString(errorType.errorId, url).html(requireContext()) @@ -1280,10 +1304,10 @@ class BrowserTabFragment : newBrowserTab.newTabContainerLayout.gone() webView?.onPause() webView?.hide() - binding.legacyOmnibar.setExpanded(true) - binding.legacyOmnibar.shieldIcon.isInvisible = true - binding.legacyOmnibar.searchIcon.isInvisible = true - binding.legacyOmnibar.daxIcon.isInvisible = true + omnibar.appBarLayout.setExpanded(true) + omnibar.shieldIcon.isInvisible = true + omnibar.searchIcon.isInvisible = true + omnibar.daxIcon.isInvisible = true errorView.errorLayout.gone() binding.browserLayout.gone() sslErrorView.bind(handler, errorResponse) { action -> @@ -1329,36 +1353,8 @@ class BrowserTabFragment : viewModel.onRefreshRequested(triggeredByUser = false) } - override fun onRejectGeneratedPassword(originalUrl: String) { - rejectGeneratedPassword(originalUrl) - } - - override fun onAcceptGeneratedPassword(originalUrl: String) { - acceptGeneratedPassword(originalUrl) - } - - override fun onUseEmailProtectionPrivateAlias( - originalUrl: String, - duckAddress: String, - ) { - viewModel.usePrivateDuckAddress(originalUrl, duckAddress) - } - - override fun onUseEmailProtectionPersonalAddress( - originalUrl: String, - duckAddress: String, - ) { - viewModel.usePersonalDuckAddress(originalUrl, duckAddress) - } - - override fun onSelectedToSignUpForInContextEmailProtection() { - showEmailProtectionInContextWebFlow() - } - - override fun onEndOfEmailProtectionInContextSignupFlow() { - webView?.let { - browserAutofill.inContextEmailProtectionFlowFinished() - } + override fun onSelectedToSignUpForInContextEmailProtection(autofillWebMessageRequest: AutofillWebMessageRequest) { + showEmailProtectionInContextWebFlow(autofillWebMessageRequest = autofillWebMessageRequest) } override fun onSavedCredentials(credentials: LoginCredentials) { @@ -1369,17 +1365,6 @@ class BrowserTabFragment : viewModel.onShowUserCredentialsUpdated(credentials) } - override fun onNoCredentialsChosenForAutofill(originalUrl: String) { - viewModel.returnNoCredentialsWithPage(originalUrl) - } - - override fun onShareCredentialsForAutofill( - originalUrl: String, - selectedCredentials: LoginCredentials, - ) { - injectAutofillCredentials(originalUrl, selectedCredentials) - } - fun refresh() { webView?.reload() viewModel.onWebViewRefreshed() @@ -1576,27 +1561,16 @@ class BrowserTabFragment : is Command.RequestFileDownload -> requestFileDownload(it.url, it.contentDisposition, it.mimeType, it.requestUserConfirmation) is Command.ChildTabClosed -> processUriForThirdPartyCookies() is Command.CopyAliasToClipboard -> copyAliasToClipboard(it.alias) - is Command.InjectEmailAddress -> injectEmailAddress( - alias = it.duckAddress, - originalUrl = it.originalUrl, - autoSaveLogin = it.autoSaveLogin, - ) - - is Command.ShowEmailProtectionChooseEmailPrompt -> showEmailProtectionChooseEmailDialog(it.address) - is Command.ShowEmailProtectionInContextSignUpPrompt -> showNativeInContextEmailProtectionSignupPrompt() - - is Command.CancelIncomingAutofillRequest -> injectAutofillCredentials(it.url, null) + is Command.ShowEmailProtectionChooseEmailPrompt -> showEmailProtectionChooseEmailDialog(it.duckAddress, it.autofillWebMessageRequest) + is Command.PageChanged -> onPageChanged() is Command.LaunchAutofillSettings -> launchAutofillManagementScreen(it.privacyProtectionEnabled) is Command.EditWithSelectedQuery -> { - binding.legacyOmnibar.omnibarTextInput.setText(it.query) - binding.legacyOmnibar.omnibarTextInput.setSelection(it.query.length) + omnibar.omnibarTextInput.setText(it.query) + omnibar.omnibarTextInput.setSelection(it.query.length) } is ShowBackNavigationHistory -> showBackNavigationHistory(it) is NavigationCommand.NavigateToHistory -> navigateBackHistoryStack(it.historyStackIndex) - is Command.EmailSignEvent -> { - notifyEmailSignEvent() - } is Command.PrintLink -> launchPrint(it.url, it.mediaSize) is Command.ShowSitePermissionsDialog -> showSitePermissionsDialog(it.permissionsToRequest, it.request) @@ -1613,7 +1587,7 @@ class BrowserTabFragment : is Command.ScreenLock -> screenLock(it.data) is Command.ScreenUnlock -> screenUnlock() is Command.ShowFaviconsPrompt -> showFaviconsPrompt() - is Command.ShowWebPageTitle -> showWebPageTitleInCustomTab(it.title, it.url) + is Command.ShowWebPageTitle -> showWebPageTitleInCustomTab(it.title, it.url, it.showDuckPlayerIcon) is Command.ShowSSLError -> showSSLWarning(it.handler, it.error) is Command.HideSSLError -> hideSSLWarning() is Command.LaunchScreen -> launchScreen(it.screen, it.payload) @@ -1637,7 +1611,6 @@ class BrowserTabFragment : contentScopeScripts.sendSubscriptionEvent(it.cssData) duckPlayerScripts.sendSubscriptionEvent(it.duckPlayerData) } - else -> { // NO OP } @@ -1656,7 +1629,7 @@ class BrowserTabFragment : .addEventListener( object : TextAlertDialogBuilder.EventListener() { override fun onPositiveButtonClicked() { - viewModel.onRemoveSearchSuggestionConfirmed(suggestion, binding.legacyOmnibar.omnibarTextInput.text.toString()) + viewModel.onRemoveSearchSuggestionConfirmed(suggestion, omnibar.omnibarTextInput.text.toString()) } override fun onNegativeButtonClicked() { @@ -1685,7 +1658,7 @@ class BrowserTabFragment : position: Int, offset: Int, ) { - val rootView = binding.legacyOmnibar.omnibarTextInput.rootView + val rootView = omnibar.omnibarTextInput.rootView val keyboardVisibilityUtil = KeyboardVisibilityUtil(rootView) keyboardVisibilityUtil.addKeyboardVisibilityListener { scrollToPositionWithOffset(position, offset) @@ -1713,21 +1686,29 @@ class BrowserTabFragment : private fun showWebPageTitleInCustomTab( title: String, url: String?, + showDuckPlayerIcon: Boolean, ) { if (isActiveCustomTab()) { - binding.legacyOmnibar.customTabToolbarContainer.customTabTitle.text = title + omnibar.customTabToolbarContainer.customTabTitle.text = title val redirectedDomain = url?.extractDomain() redirectedDomain?.let { - binding.legacyOmnibar.customTabToolbarContainer.customTabDomain.text = redirectedDomain + omnibar.customTabToolbarContainer.customTabDomain.text = redirectedDomain } - binding.legacyOmnibar.customTabToolbarContainer.customTabTitle.show() - binding.legacyOmnibar.customTabToolbarContainer.customTabDomainOnly.hide() - binding.legacyOmnibar.customTabToolbarContainer.customTabDomain.show() + omnibar.customTabToolbarContainer.customTabTitle.show() + omnibar.customTabToolbarContainer.customTabDomainOnly.hide() + omnibar.customTabToolbarContainer.customTabDomain.show() + omnibar.customTabToolbarContainer.customTabShieldIcon.isInvisible = showDuckPlayerIcon + omnibar.customTabToolbarContainer.customTabDuckPlayerIcon.isVisible = showDuckPlayerIcon } } + private fun onPageChanged() { + browserAutofill.notifyPageChanged() + hideDialogWithTag(CredentialAutofillPickerDialog.TAG) + } + private fun extractUrlFromAmpLink(initialUrl: String) { context?.let { val client = urlExtractingWebViewClient.get() @@ -1748,35 +1729,6 @@ class BrowserTabFragment : urlExtractingWebView = null } - private fun injectEmailAddress( - alias: String, - originalUrl: String, - autoSaveLogin: Boolean, - ) { - webView?.let { - if (it.url != originalUrl) { - Timber.w("WebView url has changed since autofill request; bailing") - return - } - - emailInjector.injectAddressInEmailField(it, alias, it.url) - - if (autoSaveLogin) { - duckAddressInjectedResultHandler.createLoginForPrivateDuckAddress( - duckAddress = alias, - tabId = tabId, - originalUrl = originalUrl, - ) - } - } - } - - private fun notifyEmailSignEvent() { - webView?.let { - emailInjector.notifyWebAppSignEvent(it, it.url) - } - } - private fun copyAliasToClipboard(alias: String) { context?.let { val clipboard: ClipboardManager? = ContextCompat.getSystemService(it, ClipboardManager::class.java) @@ -1961,7 +1913,7 @@ class BrowserTabFragment : } private fun openInNewBackgroundTab() { - binding.legacyOmnibar.setExpanded(true, true) + omnibar.appBarLayout.setExpanded(true, true) viewModel.tabs.removeObservers(this) decorator.incrementTabs() } @@ -2289,22 +2241,23 @@ class BrowserTabFragment : autoCompleteLongPressClickListener = { viewModel.userLongPressedAutocomplete(it) }, + omnibarPosition = settingsDataStore.omnibarPosition, ) binding.autoCompleteSuggestionsList.adapter = autoCompleteSuggestionsAdapter } private fun configureNewTab() { - newBrowserTab.newTabLayout.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY -> - if (binding.legacyOmnibar.omniBarContainer.isPressed) { - binding.legacyOmnibar.omnibarTextInput.hideKeyboard() + newBrowserTab.newTabLayout.setOnScrollChangeListener { _, _, _, _, _ -> + if (omnibar.omniBarContainer.isPressed) { + omnibar.omnibarTextInput.hideKeyboard() binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false } } } private fun configurePrivacyShield() { - binding.legacyOmnibar.shieldIcon.setOnClickListener { + omnibar.shieldIcon.setOnClickListener { contentScopeScripts.sendSubscriptionEvent(createBreakageReportingEventData()) browserActivity?.launchPrivacyDashboard() viewModel.onPrivacyShieldSelected() @@ -2326,50 +2279,50 @@ class BrowserTabFragment : } private fun configureOmnibarTextInput() { - binding.legacyOmnibar.omnibarTextInput.onFocusChangeListener = + omnibar.omnibarTextInput.onFocusChangeListener = OnFocusChangeListener { _, hasFocus: Boolean -> - viewModel.onOmnibarInputStateChanged(binding.legacyOmnibar.omnibarTextInput.text.toString(), hasFocus, false) - viewModel.triggerAutocomplete(binding.legacyOmnibar.omnibarTextInput.text.toString(), hasFocus, false) + viewModel.onOmnibarInputStateChanged(omnibar.omnibarTextInput.text.toString(), hasFocus, false) + viewModel.triggerAutocomplete(omnibar.omnibarTextInput.text.toString(), hasFocus, false) if (hasFocus) { cancelPendingAutofillRequestsToChooseCredentials() - binding.legacyOmnibar.omniBarContainer.isPressed = true + omnibar.omniBarContainer.isPressed = true } else { - binding.legacyOmnibar.omnibarTextInput.hideKeyboard() + omnibar.omnibarTextInput.hideKeyboard() binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false } } - binding.legacyOmnibar.omnibarTextInput.onBackKeyListener = object : KeyboardAwareEditText.OnBackKeyListener { + omnibar.omnibarTextInput.onBackKeyListener = object : KeyboardAwareEditText.OnBackKeyListener { override fun onBackKey(): Boolean { viewModel.sendPixelsOnBackKeyPressed() - binding.legacyOmnibar.omnibarTextInput.hideKeyboard() + omnibar.omnibarTextInput.hideKeyboard() binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false // Allow the event to be handled by the next receiver. return false } } - binding.legacyOmnibar.omnibarTextInput.setOnEditorActionListener( + omnibar.omnibarTextInput.setOnEditorActionListener( TextView.OnEditorActionListener { _, actionId, keyEvent -> if (actionId == EditorInfo.IME_ACTION_GO || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER) { viewModel.sendPixelsOnEnterKeyPressed() - userEnteredQuery(binding.legacyOmnibar.omnibarTextInput.text.toString()) + userEnteredQuery(omnibar.omnibarTextInput.text.toString()) return@OnEditorActionListener true } false }, ) - binding.legacyOmnibar.omnibarTextInput.setOnTouchListener { _, event -> + omnibar.omnibarTextInput.setOnTouchListener { _, event -> viewModel.onUserTouchedOmnibarTextInput(event.action) false } - binding.legacyOmnibar.clearTextButton.setOnClickListener { + omnibar.clearTextButton.setOnClickListener { viewModel.onClearOmnibarTextInput() - binding.legacyOmnibar.omnibarTextInput.setText("") + omnibar.omnibarTextInput.setText("") } } @@ -2421,7 +2374,7 @@ class BrowserTabFragment : } it.setOnTouchListener { _, _ -> - if (binding.legacyOmnibar.omnibarTextInput.isFocused) { + if (omnibar.omnibarTextInput.isFocused) { binding.focusDummy.requestFocus() } dismissAppLinkSnackBar() @@ -2436,11 +2389,6 @@ class BrowserTabFragment : it.setFindListener(this) loginDetector.addLoginDetection(it) { viewModel.loginDetected() } - emailInjector.addJsInterface( - it, - onSignedInEmailProtectionPromptShown = { viewModel.showEmailProtectionChooseEmailPrompt() }, - onInContextEmailProtectionSignupPromptShown = { showNativeInContextEmailProtectionSignupPrompt() }, - ) configureWebViewForBlobDownload(it) configureWebViewForAutofill(it) printInjector.addJsInterface(it) { viewModel.printFromWebView() } @@ -2517,8 +2465,7 @@ class BrowserTabFragment : WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) webView.safeAddWebMessageListener( - dispatchers, - webViewVersionProvider, + webViewCapabilityChecker, "ddgBlobDownloadObj", setOf("*"), object : WebViewCompat.WebMessageListener { @@ -2600,96 +2547,55 @@ class BrowserTabFragment : private suspend fun isBlobDownloadWebViewFeatureEnabled(webView: DuckDuckGoWebView): Boolean { return withContext(dispatchers.io()) { webViewBlobDownloadFeature.self().isEnabled() } && - webView.isWebMessageListenerSupported(dispatchers, webViewVersionProvider) && - WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) + webViewCapabilityChecker.isSupported(WebViewCapability.WebMessageListener) && + webViewCapabilityChecker.isSupported(WebViewCapability.DocumentStartJavaScript) } private fun configureWebViewForAutofill(it: DuckDuckGoWebView) { - browserAutofill.addJsInterface(it, autofillCallback, this, null, tabId) + launch(dispatchers.main()) { + browserAutofill.addJsInterface(it, autofillCallback, tabId) - autofillFragmentResultListeners.getPlugins().forEach { plugin -> - setFragmentResultListener(plugin.resultKey(tabId)) { _, result -> - context?.let { - plugin.processResult( - result = result, - context = it, - tabId = tabId, - fragment = this@BrowserTabFragment, - autofillCallback = this@BrowserTabFragment, - ) + autofillFragmentResultListeners.getPlugins().forEach { plugin -> + setFragmentResultListener(plugin.resultKey(tabId)) { _, result -> + context?.let { + plugin.processResult( + result = result, + context = it, + tabId = tabId, + fragment = this@BrowserTabFragment, + autofillCallback = this@BrowserTabFragment, + ) + } } } } } - private fun injectAutofillCredentials( - url: String, - credentials: LoginCredentials?, - ) { - webView?.let { - if (it.url != url) { - Timber.w("WebView url has changed since autofill request; bailing") - return - } - browserAutofill.injectCredentials(credentials) - } - } - - private fun acceptGeneratedPassword(url: String) { - webView?.let { - if (it.url != url) { - Timber.w("WebView url has changed since autofill request; bailing") - return - } - browserAutofill.acceptGeneratedPassword() - } - } - - private fun rejectGeneratedPassword(url: String) { - webView?.let { - if (it.url != url) { - Timber.w("WebView url has changed since autofill request; bailing") - return - } - browserAutofill.rejectGeneratedPassword() - } - } - private fun cancelPendingAutofillRequestsToChooseCredentials() { - browserAutofill.cancelPendingAutofillRequestToChooseCredentials() viewModel.cancelPendingAutofillRequestToChooseCredentials() } private fun showAutofillDialogSaveCredentials( - currentUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, ) { - val url = webView?.url ?: return - if (url != currentUrl) return - - val dialog = credentialAutofillDialogFactory.autofillSavingCredentialsDialog(url, credentials, tabId) + val dialog = credentialAutofillDialogFactory.autofillSavingCredentialsDialog(autofillWebMessageRequest, credentials, tabId) showDialogHidingPrevious(dialog, CredentialSavePickerDialog.TAG) } private fun showAutofillDialogUpdatePassword( - currentUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, ) { - val url = webView?.url ?: return - if (url != currentUrl) return - - val dialog = credentialAutofillDialogFactory.autofillSavingUpdatePasswordDialog(url, credentials, tabId) + val dialog = credentialAutofillDialogFactory.autofillSavingUpdatePasswordDialog(autofillWebMessageRequest, credentials, tabId) showDialogHidingPrevious(dialog, CredentialUpdateExistingCredentialsDialog.TAG) } private fun showAutofillDialogUpdateUsername( - currentUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, ) { - val url = webView?.url ?: return - if (url != currentUrl) return - - val dialog = credentialAutofillDialogFactory.autofillSavingUpdateUsernameDialog(url, credentials, tabId) + val dialog = credentialAutofillDialogFactory.autofillSavingUpdateUsernameDialog(autofillWebMessageRequest, credentials, tabId) showDialogHidingPrevious(dialog, CredentialUpdateExistingCredentialsDialog.TAG) } @@ -2786,11 +2692,11 @@ class BrowserTabFragment : pixel.fire( AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - type = DAILY, + type = Daily(), ) } else { pixel.fire(AppPixelName.BROWSER_PULL_TO_REFRESH.pixelName) - pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, type = DAILY) + pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, type = Daily()) } } @@ -2799,7 +2705,7 @@ class BrowserTabFragment : } // avoids progressView from showing under toolbar - binding.swipeRefreshContainer.progressViewStartOffset = binding.swipeRefreshContainer.progressViewStartOffset - 15 + binding.swipeRefreshContainer.progressViewStartOffset -= 15 } /** @@ -2819,8 +2725,8 @@ class BrowserTabFragment : private fun addTextChangedListeners() { findInPage.findInPageInput.replaceTextChangedListener(findInPageTextWatcher) - binding.legacyOmnibar.omnibarTextInput.replaceTextChangedListener(omnibarInputTextWatcher) - binding.legacyOmnibar.omnibarTextInput.showSuggestionsListener = showSuggestionsListener + omnibar.omnibarTextInput.replaceTextChangedListener(omnibarInputTextWatcher) + omnibar.omnibarTextInput.showSuggestionsListener = showSuggestionsListener } override fun onCreateContextMenu( @@ -3076,33 +2982,33 @@ class BrowserTabFragment : private fun hideKeyboardImmediately() { if (!isHidden) { Timber.v("Keyboard now hiding") - binding.legacyOmnibar.omnibarTextInput.hideKeyboard() + omnibar.omnibarTextInput.hideKeyboard() binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false } } private fun hideKeyboard() { if (!isHidden) { Timber.v("Keyboard now hiding") - hideKeyboard(binding.legacyOmnibar.omnibarTextInput) + hideKeyboard(omnibar.omnibarTextInput) binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false } } private fun hideKeyboardRetainFocus() { if (!isHidden) { Timber.v("Keyboard now hiding") - binding.legacyOmnibar.omnibarTextInput.postDelayed(KEYBOARD_DELAY) { binding.legacyOmnibar.omnibarTextInput.hideKeyboard() } + omnibar.omnibarTextInput.postDelayed(KEYBOARD_DELAY) { omnibar.omnibarTextInput.hideKeyboard() } } } private fun showKeyboard() { if (!isHidden) { Timber.v("Keyboard now showing") - showKeyboard(binding.legacyOmnibar.omnibarTextInput) - binding.legacyOmnibar.omniBarContainer.isPressed = true + showKeyboard(omnibar.omnibarTextInput) + omnibar.omniBarContainer.isPressed = true } } @@ -3129,7 +3035,7 @@ class BrowserTabFragment : } override fun onViewStateRestored(bundle: Bundle?) { - viewModel.restoreWebViewState(webView, binding.legacyOmnibar.omnibarTextInput.text.toString()) + viewModel.restoreWebViewState(webView, omnibar.omnibarTextInput.text.toString()) viewModel.determineShowBrowser() super.onViewStateRestored(bundle) } @@ -3194,7 +3100,6 @@ class BrowserTabFragment : if (::popupMenu.isInitialized) popupMenu.dismiss() loginDetectionDialog?.dismiss() automaticFireproofDialog?.dismiss() - browserAutofill.removeJsInterface() destroyWebView() super.onDestroy() } @@ -3353,7 +3258,7 @@ class BrowserTabFragment : downloadFile(requestUserConfirmation = true) } else { Timber.i("Write external storage permission refused") - binding.legacyOmnibar.toolbar.makeSnackbarWithNoBottomInset(R.string.permissionRequiredToDownload, Snackbar.LENGTH_LONG).show() + omnibar.toolbar.makeSnackbarWithNoBottomInset(R.string.permissionRequiredToDownload, Snackbar.LENGTH_LONG).show() } } @@ -3420,21 +3325,24 @@ class BrowserTabFragment : } fun omnibarViews(): List = listOf( - binding.legacyOmnibar.clearTextButton, - binding.legacyOmnibar.omnibarTextInput, - binding.legacyOmnibar.searchIcon, + omnibar.clearTextButton, + omnibar.omnibarTextInput, + omnibar.searchIcon, ) override fun onAnimationFinished() { // NO OP } - private fun showEmailProtectionChooseEmailDialog(address: String) { + private fun showEmailProtectionChooseEmailDialog( + address: String, + autofillWebMessageRequest: AutofillWebMessageRequest, + ) { context?.let { val url = webView?.url ?: return val dialog = credentialAutofillDialogFactory.autofillEmailProtectionEmailChooserDialog( - url = url, + autofillWebMessageRequest = autofillWebMessageRequest, personalDuckAddress = address, tabId = tabId, ) @@ -3442,34 +3350,28 @@ class BrowserTabFragment : } } - override fun showNativeInContextEmailProtectionSignupPrompt() { + private fun showEmailProtectionInContextWebFlow(autofillWebMessageRequest: AutofillWebMessageRequest) { context?.let { - val url = webView?.url ?: return - - val dialog = credentialAutofillDialogFactory.emailProtectionInContextSignUpDialog( - tabId = tabId, + val params = EmailProtectionInContextSignUpStartScreen(messageRequestId = autofillWebMessageRequest.requestId) + val intent = globalActivityStarter.startIntent(it, params) + activityResultHandlerEmailProtectionInContextSignup.launch(intent) + inContextEmailProtectionSignupState = InProgressEmailProtectionSignupState( + showing = true, + requestId = autofillWebMessageRequest.requestId, ) - showDialogHidingPrevious(dialog, EmailProtectionInContextSignUpDialog.TAG, url) } } - fun showEmailProtectionInContextWebFlow(verificationUrl: String? = null) { + fun resumeEmailProtectionInContextWebFlow(verificationUrl: String?, messageRequestId: String) { + if (verificationUrl == null) return context?.let { - val params = if (verificationUrl == null) { - EmailProtectionInContextSignUpScreenNoParams - } else { - EmailProtectionInContextSignUpHandleVerificationLink(verificationUrl) - } + val params = EmailProtectionInContextSignUpHandleVerificationLink(url = verificationUrl, messageRequestId = messageRequestId) val intent = globalActivityStarter.startIntent(it, params) activityResultHandlerEmailProtectionInContextSignup.launch(intent) - inContextEmailProtectionShowing = true + inContextEmailProtectionSignupState = InProgressEmailProtectionSignupState(showing = true, requestId = messageRequestId) } } - override fun showNativeChooseEmailAddressPrompt() { - viewModel.showEmailProtectionChooseEmailPrompt() - } - companion object { private const val CUSTOM_TAB_TOOLBAR_COLOR_ARG = "CUSTOM_TAB_TOOLBAR_COLOR_ARG" private const val TAB_DISPLAYED_IN_CUSTOM_TAB_SCREEN_ARG = "TAB_DISPLAYED_IN_CUSTOM_TAB_SCREEN_ARG" @@ -3481,6 +3383,8 @@ class BrowserTabFragment : const val ADD_SAVED_SITE_FRAGMENT_TAG = "ADD_SAVED_SITE" private const val KEYBOARD_DELAY = 200L + private const val NAVIGATION_DELAY = 100L + private const val POPUP_MENU_DELAY = 200L private const val REQUEST_CODE_CHOOSE_FILE = 100 private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200 @@ -3499,6 +3403,8 @@ class BrowserTabFragment : private const val COOKIES_ANIMATION_DELAY = 400L + private const val SCROLLABILITY_CHECK_DELAY = 2000L + private const val BOOKMARKS_BOTTOM_SHEET_DURATION = 3500L private const val AUTOCOMPLETE_PADDING_DP = 6 @@ -3563,23 +3469,23 @@ class BrowserTabFragment : menuButton?.isVisible = viewState.showMenuButton is HighlightableButton.Visible val targetView = if (viewState.showMenuButton.isHighlighted()) { - binding.legacyOmnibar.browserMenuImageView + omnibar.browserMenuImageView } else if (viewState.fireButton.isHighlighted()) { - binding.legacyOmnibar.fireIconImageView + omnibar.fireIconImageView } else if (viewState.showPrivacyShield.isHighlighted()) { - binding.legacyOmnibar.placeholder + omnibar.placeholder } else { null } // omnibar only scrollable when browser showing and the fire button is not promoted if (targetView != null) { - omnibarScrolling.disableOmnibarScrolling(binding.legacyOmnibar.toolbarContainer) + omnibarScrolling.disableOmnibarScrolling(omnibar.toolbarContainer) playPulseAnimation(targetView) webView?.setBottomMatchingBehaviourEnabled(false) } else { if (viewState.browserShowing) { - omnibarScrolling.enableOmnibarScrolling(binding.legacyOmnibar.toolbarContainer) + omnibarScrolling.enableOmnibarScrolling(omnibar.toolbarContainer) } if (pulseAnimation.isActive) { webView?.setBottomMatchingBehaviourEnabled(true) // only execute if animation is playing @@ -3589,7 +3495,7 @@ class BrowserTabFragment : } private fun playPulseAnimation(targetView: View) { - binding.legacyOmnibar.toolbarContainer.doOnLayout { + omnibar.toolbarContainer.doOnLayout { pulseAnimation.playOn(targetView) } } @@ -3612,22 +3518,21 @@ class BrowserTabFragment : popupMenu = BrowserPopupMenu( context = requireContext(), layoutInflater = layoutInflater, - displayedInCustomTabScreen = tabDisplayedInCustomTabScreen, + settingsDataStore.omnibarPosition, ) - val menuBinding = PopupWindowBrowserMenuBinding.bind(popupMenu.contentView) popupMenu.apply { - onMenuItemClicked(menuBinding.forwardMenuItem) { + onMenuItemClicked(forwardMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_NAVIGATE_FORWARD_PRESSED) viewModel.onUserPressedForward() } - onMenuItemClicked(menuBinding.backMenuItem) { + onMenuItemClicked(backMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_NAVIGATE_BACK_PRESSED) activity?.onBackPressed() } - onMenuItemLongClicked(menuBinding.backMenuItem) { + onMenuItemLongClicked(backMenuItem) { viewModel.onUserLongPressedBack() } - onMenuItemClicked(menuBinding.refreshMenuItem) { + onMenuItemClicked(refreshMenuItem) { viewModel.onRefreshRequested(triggeredByUser = true) if (isActiveCustomTab()) { pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_REFRESH) @@ -3641,81 +3546,81 @@ class BrowserTabFragment : pixel.fire( AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - type = DAILY, + type = Daily(), ) } else { pixel.fire(AppPixelName.MENU_ACTION_REFRESH_PRESSED.pixelName) - pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, type = DAILY) + pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, type = Daily()) } } } - onMenuItemClicked(menuBinding.newTabMenuItem) { + onMenuItemClicked(newTabMenuItem) { viewModel.userRequestedOpeningNewTab() pixel.fire(AppPixelName.MENU_ACTION_NEW_TAB_PRESSED.pixelName) } - onMenuItemClicked(menuBinding.bookmarksMenuItem) { + onMenuItemClicked(bookmarksMenuItem) { browserActivity?.launchBookmarks() pixel.fire(AppPixelName.MENU_ACTION_BOOKMARKS_PRESSED.pixelName) } - onMenuItemClicked(menuBinding.fireproofWebsiteMenuItem) { + onMenuItemClicked(fireproofWebsiteMenuItem) { viewModel.onFireproofWebsiteMenuClicked() } - onMenuItemClicked(menuBinding.addBookmarksMenuItem) { + onMenuItemClicked(addBookmarksMenuItem) { viewModel.onBookmarkMenuClicked() } - onMenuItemClicked(menuBinding.findInPageMenuItem) { + onMenuItemClicked(findInPageMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_FIND_IN_PAGE_PRESSED) viewModel.onFindInPageSelected() } - onMenuItemClicked(menuBinding.privacyProtectionMenuItem) { viewModel.onPrivacyProtectionMenuClicked(isActiveCustomTab()) } - onMenuItemClicked(menuBinding.brokenSiteMenuItem) { + onMenuItemClicked(privacyProtectionMenuItem) { viewModel.onPrivacyProtectionMenuClicked(isActiveCustomTab()) } + onMenuItemClicked(brokenSiteMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_REPORT_BROKEN_SITE_PRESSED) viewModel.onBrokenSiteSelected() } - onMenuItemClicked(menuBinding.downloadsMenuItem) { + onMenuItemClicked(downloadsMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_DOWNLOADS_PRESSED) browserActivity?.launchDownloads() } - onMenuItemClicked(menuBinding.settingsMenuItem) { + onMenuItemClicked(settingsMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_SETTINGS_PRESSED) browserActivity?.launchSettings() } - onMenuItemClicked(menuBinding.changeBrowserModeMenuItem) { + onMenuItemClicked(changeBrowserModeMenuItem) { viewModel.onChangeBrowserModeClicked() } - onMenuItemClicked(menuBinding.sharePageMenuItem) { + onMenuItemClicked(sharePageMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_SHARE_PRESSED) viewModel.onShareSelected() } - onMenuItemClicked(menuBinding.addToHomeMenuItem) { + onMenuItemClicked(addToHomeMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_ADD_TO_HOME_PRESSED) viewModel.onPinPageToHomeSelected() } - onMenuItemClicked(menuBinding.createAliasMenuItem) { viewModel.consumeAliasAndCopyToClipboard() } - onMenuItemClicked(menuBinding.openInAppMenuItem) { + onMenuItemClicked(createAliasMenuItem) { viewModel.consumeAliasAndCopyToClipboard() } + onMenuItemClicked(openInAppMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_APP_LINKS_OPEN_PRESSED) viewModel.openAppLink() } - onMenuItemClicked(menuBinding.printPageMenuItem) { + onMenuItemClicked(printPageMenuItem) { viewModel.onPrintSelected() } - onMenuItemClicked(menuBinding.autofillMenuItem) { + onMenuItemClicked(autofillMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_AUTOFILL_PRESSED) viewModel.onAutofillMenuSelected() } - onMenuItemClicked(menuBinding.openInDdgBrowserMenuItem) { + onMenuItemClicked(openInDdgBrowserMenuItem) { viewModel.url?.let { launchCustomTabUrlInDdg(it) pixel.fire(CustomTabPixelNames.CUSTOM_TABS_OPEN_IN_DDG) } } } - binding.legacyOmnibar.browserMenu.setOnClickListener { + omnibar.browserMenu.setOnClickListener { contentScopeScripts.sendSubscriptionEvent(createBreakageReportingEventData()) viewModel.onBrowserMenuClicked() hideKeyboardImmediately() - launchTopAnchoredPopupMenu() + launchPopupMenu() } } @@ -3726,12 +3631,15 @@ class BrowserTabFragment : startActivity(intent) } - private fun launchTopAnchoredPopupMenu() { - popupMenu.show(binding.rootView, binding.legacyOmnibar.toolbar) - if (isActiveCustomTab()) { - pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_OPENED) - } else { - pixel.fire(AppPixelName.MENU_ACTION_POPUP_OPENED.pixelName) + private fun launchPopupMenu() { + // small delay added to let keyboard disappear and avoid jarring transition + binding.rootView.postDelayed(POPUP_MENU_DELAY) { + popupMenu.show(binding.rootView, omnibar.toolbar) + if (isActiveCustomTab()) { + pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_OPENED) + } else { + pixel.fire(AppPixelName.MENU_ACTION_POPUP_OPENED.pixelName) + } } } @@ -3782,9 +3690,9 @@ class BrowserTabFragment : if (viewState.privacyShield != UNKNOWN) { lastSeenPrivacyShieldViewState = viewState val animationViewHolder = if (isActiveCustomTab()) { - binding.legacyOmnibar.customTabToolbarContainer.customTabShieldIcon + omnibar.customTabToolbarContainer.customTabShieldIcon } else { - binding.legacyOmnibar.shieldIcon + omnibar.shieldIcon } privacyShieldView.setAnimationView(animationViewHolder, viewState.privacyShield) cancelTrackersAnimation() @@ -3832,14 +3740,14 @@ class BrowserTabFragment : } if (viewState.navigationChange) { - binding.legacyOmnibar.setExpanded(true, true) + omnibar.appBarLayout.setExpanded(true, true) } else if (shouldUpdateOmnibarTextInput(viewState, viewState.omnibarText)) { - binding.legacyOmnibar.omnibarTextInput.setText(viewState.omnibarText) + omnibar.omnibarTextInput.setText(viewState.omnibarText) if (viewState.forceExpand) { - binding.legacyOmnibar.setExpanded(true, true) + omnibar.appBarLayout.setExpanded(true, true) } if (viewState.shouldMoveCaretToEnd) { - binding.legacyOmnibar.omnibarTextInput.setSelection(viewState.omnibarText.length) + omnibar.omnibarTextInput.setSelection(viewState.omnibarText.length) } } @@ -3851,14 +3759,14 @@ class BrowserTabFragment : private fun renderVoiceSearch(viewState: BrowserViewState) { if (viewState.showVoiceSearch) { - binding.legacyOmnibar.voiceSearchButton.visibility = VISIBLE - binding.legacyOmnibar.voiceSearchButton.setOnClickListener { + omnibar.voiceSearchButton.visibility = VISIBLE + omnibar.voiceSearchButton.setOnClickListener { webView?.onPause() hideKeyboardImmediately() voiceSearchLauncher.launch(requireActivity()) } } else { - binding.legacyOmnibar.voiceSearchButton.visibility = GONE + omnibar.voiceSearchButton.visibility = GONE } } @@ -3875,7 +3783,7 @@ class BrowserTabFragment : webView?.setBottomMatchingBehaviourEnabled(true) } - binding.legacyOmnibar.pageLoadingIndicator.apply { + omnibar.pageLoadingIndicator.apply { if (viewState.isLoading) show() smoothProgressAnimator.onNewProgress(viewState.progress) { if (!viewState.isLoading) hide() } } @@ -3912,8 +3820,8 @@ class BrowserTabFragment : activity?.let { activity -> animatorHelper.startTrackersAnimation( context = activity, - shieldAnimationView = binding.legacyOmnibar.shieldIcon, - trackersAnimationView = binding.legacyOmnibar.trackersAnimation, + shieldAnimationView = omnibar.shieldIcon, + trackersAnimationView = omnibar.trackersAnimation, omnibarViews = omnibarViews(), entities = events, ) @@ -3985,10 +3893,12 @@ class BrowserTabFragment : } renderToolbarMenus(viewState) + popupMenu.renderState(browserShowing, viewState, tabDisplayedInCustomTabScreen) + renderFullscreenMode(viewState) renderVoiceSearch(viewState) - binding.legacyOmnibar.spacer.isVisible = viewState.showVoiceSearch && lastSeenBrowserViewState?.showClearButton ?: false + omnibar.spacer.isVisible = viewState.showVoiceSearch && lastSeenBrowserViewState?.showClearButton ?: false privacyProtectionsPopup.setViewState(viewState.privacyProtectionsPopupViewState) bookmarksBottomSheetDialog?.dialog?.toggleSwitch(viewState.favorite != null) @@ -4031,21 +3941,20 @@ class BrowserTabFragment : private fun renderToolbarMenus(viewState: BrowserViewState) { if (viewState.browserShowing) { - binding.legacyOmnibar.daxIcon?.isVisible = viewState.showDaxIcon - binding.legacyOmnibar.duckPlayerIcon.isVisible = viewState.showDuckPlayerIcon - binding.legacyOmnibar.shieldIcon?.isInvisible = - !viewState.showPrivacyShield.isEnabled() || viewState.showDaxIcon || viewState.showDuckPlayerIcon - binding.legacyOmnibar.clearTextButton?.isVisible = viewState.showClearButton - binding.legacyOmnibar.searchIcon?.isVisible = viewState.showSearchIcon + omnibar.daxIcon.isVisible = viewState.showDaxIcon + omnibar.duckPlayerIcon.isVisible = viewState.showDuckPlayerIcon + omnibar.shieldIcon.isInvisible = !viewState.showPrivacyShield.isEnabled() || viewState.showDaxIcon || viewState.showDuckPlayerIcon + omnibar.clearTextButton.isVisible = viewState.showClearButton + omnibar.searchIcon.isVisible = viewState.showSearchIcon } else { - binding.legacyOmnibar.daxIcon.isVisible = false - binding.legacyOmnibar.duckPlayerIcon.isVisible = false - binding.legacyOmnibar.shieldIcon?.isVisible = false - binding.legacyOmnibar.clearTextButton?.isVisible = viewState.showClearButton - binding.legacyOmnibar.searchIcon?.isVisible = true + omnibar.daxIcon.isVisible = false + omnibar.duckPlayerIcon.isVisible = false + omnibar.shieldIcon.isVisible = false + omnibar.clearTextButton.isVisible = viewState.showClearButton + omnibar.searchIcon.isVisible = true } - binding.legacyOmnibar.spacer.isVisible = viewState.showClearButton && lastSeenBrowserViewState?.showVoiceSearch ?: false + omnibar.spacer.isVisible = viewState.showClearButton && lastSeenBrowserViewState?.showVoiceSearch ?: false decorator.updateToolbarActionsVisibility(viewState) } @@ -4208,7 +4117,7 @@ class BrowserTabFragment : .launchIn(lifecycleScope) newBrowserTab.newTabContainerLayout.show() newBrowserTab.newTabLayout.show() - omnibarScrolling.disableOmnibarScrolling(binding.legacyOmnibar.toolbarContainer) + omnibarScrolling.disableOmnibarScrolling(omnibar.toolbarContainer) viewModel.onNewTabShown() } @@ -4275,7 +4184,7 @@ class BrowserTabFragment : viewState: OmnibarViewState, omnibarInput: String?, ) = - (!viewState.isEditing || omnibarInput.isNullOrEmpty()) && binding.legacyOmnibar.omnibarTextInput.isDifferent(omnibarInput) + (!viewState.isEditing || omnibarInput.isNullOrEmpty()) && omnibar.omnibarTextInput.isDifferent(omnibarInput) } private fun launchPrint( diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index b58df1e2ea29..6b4267d10cc0 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -39,8 +39,12 @@ import android.webkit.WebView import androidx.annotation.AnyThread import androidx.annotation.VisibleForTesting import androidx.core.net.toUri -import androidx.lifecycle.* +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import androidx.webkit.JavaScriptReplyProxy import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -71,7 +75,83 @@ import com.duckduckgo.app.browser.camera.CameraHardwareChecker import com.duckduckgo.app.browser.certificates.BypassedSSLCertificatesRepository import com.duckduckgo.app.browser.certificates.remoteconfig.SSLCertificatesFeature import com.duckduckgo.app.browser.commands.Command -import com.duckduckgo.app.browser.commands.Command.* +import com.duckduckgo.app.browser.commands.Command.AddHomeShortcut +import com.duckduckgo.app.browser.commands.Command.AskDomainPermission +import com.duckduckgo.app.browser.commands.Command.AskToAutomateFireproofWebsite +import com.duckduckgo.app.browser.commands.Command.AskToDisableLoginDetection +import com.duckduckgo.app.browser.commands.Command.AskToFireproofWebsite +import com.duckduckgo.app.browser.commands.Command.AutocompleteItemRemoved +import com.duckduckgo.app.browser.commands.Command.BrokenSiteFeedback +import com.duckduckgo.app.browser.commands.Command.CheckSystemLocationPermission +import com.duckduckgo.app.browser.commands.Command.ChildTabClosed +import com.duckduckgo.app.browser.commands.Command.ConvertBlobToDataUri +import com.duckduckgo.app.browser.commands.Command.CopyAliasToClipboard +import com.duckduckgo.app.browser.commands.Command.CopyLink +import com.duckduckgo.app.browser.commands.Command.DeleteFavoriteConfirmation +import com.duckduckgo.app.browser.commands.Command.DeleteFireproofConfirmation +import com.duckduckgo.app.browser.commands.Command.DeleteSavedSiteConfirmation +import com.duckduckgo.app.browser.commands.Command.DialNumber +import com.duckduckgo.app.browser.commands.Command.DismissFindInPage +import com.duckduckgo.app.browser.commands.Command.DownloadImage +import com.duckduckgo.app.browser.commands.Command.EditWithSelectedQuery +import com.duckduckgo.app.browser.commands.Command.ExtractUrlFromCloakedAmpLink +import com.duckduckgo.app.browser.commands.Command.FindInPageCommand +import com.duckduckgo.app.browser.commands.Command.GenerateWebViewPreviewImage +import com.duckduckgo.app.browser.commands.Command.HandleNonHttpAppLink +import com.duckduckgo.app.browser.commands.Command.HideKeyboard +import com.duckduckgo.app.browser.commands.Command.HideOnboardingDaxDialog +import com.duckduckgo.app.browser.commands.Command.HideSSLError +import com.duckduckgo.app.browser.commands.Command.HideWebContent +import com.duckduckgo.app.browser.commands.Command.LaunchAddWidget +import com.duckduckgo.app.browser.commands.Command.LaunchAutofillSettings +import com.duckduckgo.app.browser.commands.Command.LaunchNewTab +import com.duckduckgo.app.browser.commands.Command.LaunchPrivacyPro +import com.duckduckgo.app.browser.commands.Command.LaunchTabSwitcher +import com.duckduckgo.app.browser.commands.Command.LoadExtractedUrl +import com.duckduckgo.app.browser.commands.Command.OpenAppLink +import com.duckduckgo.app.browser.commands.Command.OpenInNewBackgroundTab +import com.duckduckgo.app.browser.commands.Command.OpenInNewTab +import com.duckduckgo.app.browser.commands.Command.OpenMessageInNewTab +import com.duckduckgo.app.browser.commands.Command.PageChanged +import com.duckduckgo.app.browser.commands.Command.PrintLink +import com.duckduckgo.app.browser.commands.Command.RefreshUserAgent +import com.duckduckgo.app.browser.commands.Command.RequestFileDownload +import com.duckduckgo.app.browser.commands.Command.RequestSystemLocationPermission +import com.duckduckgo.app.browser.commands.Command.RequiresAuthentication +import com.duckduckgo.app.browser.commands.Command.ResetHistory +import com.duckduckgo.app.browser.commands.Command.SaveCredentials +import com.duckduckgo.app.browser.commands.Command.ScreenLock +import com.duckduckgo.app.browser.commands.Command.ScreenUnlock +import com.duckduckgo.app.browser.commands.Command.SendEmail +import com.duckduckgo.app.browser.commands.Command.SendResponseToJs +import com.duckduckgo.app.browser.commands.Command.SendSms +import com.duckduckgo.app.browser.commands.Command.ShareLink +import com.duckduckgo.app.browser.commands.Command.ShowAppLinkPrompt +import com.duckduckgo.app.browser.commands.Command.ShowBackNavigationHistory +import com.duckduckgo.app.browser.commands.Command.ShowDomainHasPermissionMessage +import com.duckduckgo.app.browser.commands.Command.ShowEditSavedSiteDialog +import com.duckduckgo.app.browser.commands.Command.ShowEmailProtectionChooseEmailPrompt +import com.duckduckgo.app.browser.commands.Command.ShowErrorWithAction +import com.duckduckgo.app.browser.commands.Command.ShowExistingImageOrCameraChooser +import com.duckduckgo.app.browser.commands.Command.ShowFaviconsPrompt +import com.duckduckgo.app.browser.commands.Command.ShowFileChooser +import com.duckduckgo.app.browser.commands.Command.ShowFireproofWebSiteConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowFullScreen +import com.duckduckgo.app.browser.commands.Command.ShowImageCamera +import com.duckduckgo.app.browser.commands.Command.ShowKeyboard +import com.duckduckgo.app.browser.commands.Command.ShowPrivacyProtectionDisabledConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowPrivacyProtectionEnabledConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowRemoveSearchSuggestionDialog +import com.duckduckgo.app.browser.commands.Command.ShowSSLError +import com.duckduckgo.app.browser.commands.Command.ShowSavedSiteAddedConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowSitePermissionsDialog +import com.duckduckgo.app.browser.commands.Command.ShowSoundRecorder +import com.duckduckgo.app.browser.commands.Command.ShowUserCredentialSavedOrUpdatedConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowVideoCamera +import com.duckduckgo.app.browser.commands.Command.ShowWebContent +import com.duckduckgo.app.browser.commands.Command.ShowWebPageTitle +import com.duckduckgo.app.browser.commands.Command.WebShareRequest +import com.duckduckgo.app.browser.commands.Command.WebViewError import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME @@ -95,6 +175,9 @@ import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.omnibar.QueryOrigin import com.duckduckgo.app.browser.omnibar.QueryOrigin.FromAutocomplete +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.urlextraction.UrlExtractionListener import com.duckduckgo.app.browser.viewstate.AccessibilityViewState @@ -111,7 +194,10 @@ import com.duckduckgo.app.browser.viewstate.OmnibarViewState import com.duckduckgo.app.browser.viewstate.PrivacyShieldViewState import com.duckduckgo.app.browser.viewstate.SavedSiteChangedViewState import com.duckduckgo.app.browser.webview.SslWarningLayout.Action -import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.cta.ui.Cta +import com.duckduckgo.app.cta.ui.CtaViewModel +import com.duckduckgo.app.cta.ui.DaxBubbleCta +import com.duckduckgo.app.cta.ui.HomePanelCta import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity @@ -134,6 +220,7 @@ import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_BANNER_SHOWN import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_HISTORY_SEARCH_SELECTION import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_HISTORY_SITE_SELECTION import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_RESULT_DELETED +import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_RESULT_DELETED_DAILY import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_SEARCH_PHRASE_SELECTION import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_SEARCH_WEBSITE_SELECTION import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_SEARCH_CUSTOM @@ -145,15 +232,16 @@ import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.autofill.api.AutofillCapabilityChecker +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor @@ -180,7 +268,11 @@ import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentM import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels -import com.duckduckgo.privacy.config.api.* +import com.duckduckgo.privacy.config.api.AmpLinkInfo +import com.duckduckgo.privacy.config.api.AmpLinks +import com.duckduckgo.privacy.config.api.ContentBlocking +import com.duckduckgo.privacy.config.api.Gpc +import com.duckduckgo.privacy.config.api.TrackingParameters import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupManager @@ -208,12 +300,54 @@ import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import java.net.URI import java.net.URISyntaxException -import java.util.* +import java.util.Locale import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.collections.MutableMap +import kotlin.collections.any +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.contains +import kotlin.collections.drop +import kotlin.collections.emptyList +import kotlin.collections.emptyMap +import kotlin.collections.filter +import kotlin.collections.filterNot +import kotlin.collections.firstOrNull +import kotlin.collections.forEach +import kotlin.collections.isNotEmpty +import kotlin.collections.iterator +import kotlin.collections.map +import kotlin.collections.mapOf +import kotlin.collections.minus +import kotlin.collections.mutableMapOf +import kotlin.collections.mutableSetOf +import kotlin.collections.plus +import kotlin.collections.set +import kotlin.collections.setOf +import kotlin.collections.take +import kotlin.collections.toList +import kotlin.collections.toMutableMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONArray import org.json.JSONObject @@ -458,7 +592,6 @@ class BrowserTabViewModel @Inject constructor( emailManager.signedInFlow().onEach { isSignedIn -> browserViewState.value = currentBrowserViewState().copy(isEmailSignedIn = isSignedIn) - command.value = EmailSignEvent }.launchIn(viewModelScope) observeAccessibilitySettings() @@ -765,6 +898,8 @@ class BrowserTabViewModel @Inject constructor( fun onRemoveSearchSuggestionConfirmed(suggestion: AutoCompleteSuggestion, omnibarText: String) { appCoroutineScope.launch(dispatchers.io()) { pixel.fire(AUTOCOMPLETE_RESULT_DELETED) + pixel.fire(AUTOCOMPLETE_RESULT_DELETED_DAILY, type = Daily()) + when (suggestion) { is AutoCompleteHistorySuggestion -> { history.removeHistoryEntryByUrl(suggestion.url) @@ -816,7 +951,7 @@ class BrowserTabViewModel @Inject constructor( when (currentCtaViewState().cta) { is DaxBubbleCta.DaxIntroSearchOptionsCta -> { if (!ctaViewModel.isSuggestedSearchOption(query)) { - pixel.fire(ONBOARDING_SEARCH_CUSTOM, type = UNIQUE) + pixel.fire(ONBOARDING_SEARCH_CUSTOM, type = Unique()) } } @@ -824,7 +959,7 @@ class BrowserTabViewModel @Inject constructor( is OnboardingDaxDialogCta.DaxSiteSuggestionsCta, -> { if (!ctaViewModel.isSuggestedSiteOption(query)) { - pixel.fire(ONBOARDING_VISIT_SITE_CUSTOM, type = UNIQUE) + pixel.fire(ONBOARDING_VISIT_SITE_CUSTOM, type = Unique()) } } } @@ -1045,7 +1180,7 @@ class BrowserTabViewModel @Inject constructor( if (triggeredByUser) { site?.realBrokenSiteContext?.onUserTriggeredRefresh() - privacyProtectionsPopupManager.onPageRefreshTriggeredByUser() + privacyProtectionsPopupManager.onPageRefreshTriggeredByUser(isOmnibarAtTheTop = settingsDataStore.omnibarPosition == TOP) } } @@ -1159,7 +1294,7 @@ class BrowserTabViewModel @Inject constructor( if (!currentBrowserViewState().browserShowing) return - if (loadingBarExperimentManager.isExperimentEnabled()) { + if (loadingBarExperimentManager.isExperimentEnabled() || settingsDataStore.omnibarPosition == BOTTOM) { showOmniBar() } @@ -1240,6 +1375,8 @@ class BrowserTabViewModel @Inject constructor( title: String?, ) { Timber.v("Page changed: $url") + cleanupBlobDownloadReplyProxyMaps() + hasCtaBeenShownForCurrentPage.set(false) buildSiteFactory(url, title, urlUnchangedForExternalLaunchPurposes(site?.url, url)) setAdClickActiveTabData(url) @@ -1319,13 +1456,18 @@ class BrowserTabViewModel @Inject constructor( isLinkOpenedInNewTab = false automaticSavedLoginsMonitor.clearAutoSavedLoginId(tabId) - + command.value = PageChanged site?.run { val hasBrowserError = currentBrowserViewState().browserError != OMITTED privacyProtectionsPopupManager.onPageLoaded(url, httpErrorCodeEvents, hasBrowserError) } } + private fun cleanupBlobDownloadReplyProxyMaps() { + fixedReplyProxyMap.clear() + replyProxyMap.clear() + } + private fun setAdClickActiveTabData(url: String?) { val sourceTabId = tabRepository.liveSelectedTab.value?.sourceTabId val sourceTabUrl = tabRepository.liveTabs.value?.firstOrNull { it.tabId == sourceTabId }?.url @@ -1754,10 +1896,16 @@ class BrowserTabViewModel @Inject constructor( showErrorWithAction(R.string.dosErrorMessage) } - override fun titleReceived(newTitle: String, url: String?) { + override fun titleReceived(newTitle: String) { site?.title = newTitle - command.postValue(ShowWebPageTitle(newTitle, url)) - onSiteChanged() + val url = site?.url + viewModelScope.launch(dispatchers.main()) { + val isDuckPlayerUrl = withContext(dispatchers.io()) { + url != null && duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isDuckPlayerUri(url) + } + command.postValue(ShowWebPageTitle(newTitle, url, isDuckPlayerUrl)) + onSiteChanged() + } } @AnyThread @@ -2058,7 +2206,7 @@ class BrowserTabViewModel @Inject constructor( onDeleteFavoriteRequested(favorite) } else { pixel.fire(AppPixelName.MENU_ACTION_ADD_FAVORITE_PRESSED.pixelName) - pixel.fire(SavedSitesPixelName.MENU_ACTION_ADD_FAVORITE_PRESSED_DAILY.pixelName, type = DAILY) + pixel.fire(SavedSitesPixelName.MENU_ACTION_ADD_FAVORITE_PRESSED_DAILY.pixelName, type = Daily()) saveFavoriteSite(url, title ?: "") } } @@ -2270,7 +2418,7 @@ class BrowserTabViewModel @Inject constructor( if (clickedFromCustomTab) { pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_DISABLE_PROTECTIONS_ALLOW_LIST_ADD) } else { - pixel.fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD, pixelParams, type = COUNT) + pixel.fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD, pixelParams, type = Count) } privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = false) userAllowListRepository.addDomainToUserAllowList(domain) @@ -2285,7 +2433,7 @@ class BrowserTabViewModel @Inject constructor( if (clickedFromCustomTab) { pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_DISABLE_PROTECTIONS_ALLOW_LIST_REMOVE) } else { - pixel.fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE, pixelParams, type = COUNT) + pixel.fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE, pixelParams, type = Count) } privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = true) userAllowListRepository.removeDomainFromUserAllowList(domain) @@ -2771,7 +2919,7 @@ class BrowserTabViewModel @Inject constructor( fun userLaunchingTabSwitcher() { command.value = LaunchTabSwitcher pixel.fire(AppPixelName.TAB_MANAGER_CLICKED) - pixel.fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, emptyMap(), emptyMap(), DAILY) + pixel.fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, emptyMap(), emptyMap(), Daily()) } private fun isFireproofWebsite(domain: String? = site?.domain): Boolean { @@ -2873,9 +3021,9 @@ class BrowserTabViewModel @Inject constructor( }.getOrNull() ?: return false } - fun showEmailProtectionChooseEmailPrompt() { + fun showEmailProtectionChooseEmailPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { emailManager.getEmailAddress()?.let { - command.postValue(ShowEmailProtectionChooseEmailPrompt(it)) + command.postValue(ShowEmailProtectionChooseEmailPrompt(it, autofillWebMessageRequest)) } } @@ -2893,23 +3041,6 @@ class BrowserTabViewModel @Inject constructor( } } - /** - * API called after user selected to autofill a private alias into a form - */ - fun usePrivateDuckAddress( - originalUrl: String, - duckAddress: String, - ) { - command.postValue(InjectEmailAddress(duckAddress = duckAddress, originalUrl = originalUrl, autoSaveLogin = true)) - } - - fun usePersonalDuckAddress( - originalUrl: String, - duckAddress: String, - ) { - command.postValue(InjectEmailAddress(duckAddress = duckAddress, originalUrl = originalUrl, autoSaveLogin = false)) - } - fun download(pendingFileDownload: PendingFileDownload) { fileDownloader.enqueueDownload(pendingFileDownload) } @@ -3033,10 +3164,6 @@ class BrowserTabViewModel @Inject constructor( command.postValue(LoadExtractedUrl(extractedUrl = destinationUrl)) } - fun returnNoCredentialsWithPage(originalUrl: String) { - command.postValue(CancelIncomingAutofillRequest(originalUrl)) - } - fun onConfigurationChanged() { browserViewState.value = currentBrowserViewState().copy( forceRenderingTicker = System.currentTimeMillis(), @@ -3455,7 +3582,7 @@ class BrowserTabViewModel @Inject constructor( "daysSinceInstall" to userBrowserProperties.daysSinceInstalled().toString(), "from_onboarding" to "true", ), - type = UNIQUE, + type = Unique(), ) } } @@ -3577,6 +3704,8 @@ class BrowserTabViewModel @Inject constructor( ) } + fun hasOmnibarPositionChanged(currentPosition: OmnibarPosition): Boolean = settingsDataStore.omnibarPosition != currentPosition + private fun firePixelBasedOnCurrentUrl(emptyUrlPixel: AppPixelName, duckDuckGoQueryUrlPixel: AppPixelName, websiteUrlPixel: AppPixelName) { val text = url.orEmpty() if (text.isEmpty()) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index 4ba3a6481f2d..572a606c7280 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -63,7 +63,6 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT import com.duckduckgo.autoconsent.api.Autoconsent -import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.InternalTestUserChecker import com.duckduckgo.browser.api.JsInjectorPlugin import com.duckduckgo.common.utils.CurrentTimeProvider @@ -97,7 +96,6 @@ class BrowserWebViewClient @Inject constructor( private val thirdPartyCookieManager: ThirdPartyCookieManager, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, - private val browserAutofillConfigurator: BrowserAutofill.Configurator, private val ampLinks: AmpLinks, private val printInjector: PrintInjector, private val internalTestUserChecker: InternalTestUserChecker, @@ -130,7 +128,7 @@ class BrowserWebViewClient @Inject constructor( request: WebResourceRequest, ): Boolean { val url = request.url - return shouldOverride(view, url, request.isForMainFrame) + return shouldOverride(view, url, request.isForMainFrame, request.isRedirect) } /** @@ -140,6 +138,7 @@ class BrowserWebViewClient @Inject constructor( webView: WebView, url: Uri, isForMainFrame: Boolean, + isRedirect: Boolean, ): Boolean { try { Timber.v("shouldOverride webViewUrl: ${webView.url} URL: $url") @@ -177,7 +176,18 @@ class BrowserWebViewClient @Inject constructor( } false } - + is SpecialUrlDetector.UrlType.ShouldLaunchDuckPlayerLink -> { + if (isRedirect) { + /* + This forces shouldInterceptRequest to be called with the YouTube URL, otherwise that method is never executed and + therefore the Duck Player page is never launched if YouTube comes from a redirect. + */ + webView.loadUrl(url.toString()) + return true + } else { + shouldOverrideWebRequest(url, webView, isForMainFrame) + } + } is SpecialUrlDetector.UrlType.NonHttpAppLink -> { Timber.i("Found non-http app link for ${urlType.uriString}") if (isForMainFrame) { @@ -198,29 +208,7 @@ class BrowserWebViewClient @Inject constructor( is SpecialUrlDetector.UrlType.SearchQuery -> false is SpecialUrlDetector.UrlType.Web -> { - if (requestRewriter.shouldRewriteRequest(url)) { - webViewClientListener?.let { listener -> - val newUri = requestRewriter.rewriteRequestWithCustomQueryParams(url) - loadUrl(listener, webView, newUri.toString()) - return true - } - } - if (isForMainFrame) { - webViewClientListener?.let { listener -> - listener.willOverrideUrl(url.toString()) - clientProvider?.let { provider -> - if (provider.shouldChangeBranding(url.toString())) { - provider.setOn(webView.settings, url.toString()) - loadUrl(listener, webView, url.toString()) - return true - } else { - return false - } - } - return false - } - } - false + shouldOverrideWebRequest(url, webView, isForMainFrame) } is SpecialUrlDetector.UrlType.ExtractedAmpLink -> { @@ -292,6 +280,36 @@ class BrowserWebViewClient @Inject constructor( } } + private fun shouldOverrideWebRequest( + url: Uri, + webView: WebView, + isForMainFrame: Boolean, + ): Boolean { + if (requestRewriter.shouldRewriteRequest(url)) { + webViewClientListener?.let { listener -> + val newUri = requestRewriter.rewriteRequestWithCustomQueryParams(url) + loadUrl(listener, webView, newUri.toString()) + return true + } + } + if (isForMainFrame) { + webViewClientListener?.let { listener -> + listener.willOverrideUrl(url.toString()) + clientProvider?.let { provider -> + if (provider.shouldChangeBranding(url.toString())) { + provider.setOn(webView.settings, url.toString()) + loadUrl(listener, webView, url.toString()) + return true + } else { + return false + } + } + return false + } + } + return false + } + @UiThread override fun onPageCommitVisible(webView: WebView, url: String) { Timber.v("onPageCommitVisible webViewUrl: ${webView.url} URL: $url progress: ${webView.progress}") @@ -342,7 +360,6 @@ class BrowserWebViewClient @Inject constructor( webViewClientListener?.pageRefreshed(url) } lastPageStarted = url - browserAutofillConfigurator.configureAutofillForCurrentPage(webView, url) jsPlugins.getPlugins().forEach { it.onPageStarted(webView, url, webViewClientListener?.getSite()) } @@ -358,11 +375,11 @@ class BrowserWebViewClient @Inject constructor( } @UiThread - override fun onPageFinished( - webView: WebView, - url: String?, - ) { - Timber.v("onPageFinished webViewUrl: ${webView.url} URL: $url progress: ${webView.progress}") + override fun onPageFinished(webView: WebView, url: String?) { + Timber.v( + "onPageFinished webViewUrl: ${webView.url} URL: $url progress: ${webView.progress}", + ) + // See https://app.asana.com/0/0/1206159443951489/f (WebView limitations) if (webView.progress == 100) { jsPlugins.getPlugins().forEach { diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt index 236b236adc73..dc1fe3011316 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt @@ -35,12 +35,9 @@ import androidx.core.view.NestedScrollingChildHelper import androidx.core.view.ViewCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewCompat.WebMessageListener -import androidx.webkit.WebViewFeature +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList -import com.duckduckgo.browser.api.WebViewVersionProvider -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.common.utils.extensions.compareSemanticVersion -import kotlinx.coroutines.withContext import timber.log.Timber /** @@ -246,6 +243,7 @@ class DuckDuckGoWebView : WebView, NestedScrollingChild3 { returnValue = super.onTouchEvent(event) stopNestedScroll() } + MotionEvent.ACTION_MOVE -> { var deltaY = lastY - eventY @@ -410,25 +408,14 @@ class DuckDuckGoWebView : WebView, NestedScrollingChild3 { } } - suspend fun isWebMessageListenerSupported( - dispatchers: DispatcherProvider, - webViewVersionProvider: WebViewVersionProvider, - ): Boolean { - return withContext(dispatchers.io()) { - webViewVersionProvider.getFullVersion() - .compareSemanticVersion(WEB_MESSAGE_LISTENER_WEBVIEW_VERSION)?.let { it >= 0 } ?: false - } && WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) - } - @SuppressLint("RequiresFeature", "AddWebMessageListenerUsage") suspend fun safeAddWebMessageListener( - dispatchers: DispatcherProvider, - webViewVersionProvider: WebViewVersionProvider, + webViewCapabilityChecker: WebViewCapabilityChecker, jsObjectName: String, allowedOriginRules: Set, listener: WebMessageListener, ): Boolean = runCatching { - if (isWebMessageListenerSupported(dispatchers, webViewVersionProvider) && !isDestroyed) { + if (webViewCapabilityChecker.isSupported(WebViewCapability.WebMessageListener) && !isDestroyed) { WebViewCompat.addWebMessageListener( this, jsObjectName, @@ -446,11 +433,10 @@ class DuckDuckGoWebView : WebView, NestedScrollingChild3 { @SuppressLint("RequiresFeature", "RemoveWebMessageListenerUsage") suspend fun safeRemoveWebMessageListener( - dispatchers: DispatcherProvider, - webViewVersionProvider: WebViewVersionProvider, + webViewCapabilityChecker: WebViewCapabilityChecker, jsObjectName: String, ): Boolean = runCatching { - if (isWebMessageListenerSupported(dispatchers, webViewVersionProvider) && !isDestroyed) { + if (webViewCapabilityChecker.isSupported(WebViewCapability.WebMessageListener) && !isDestroyed) { WebViewCompat.removeWebMessageListener( this, jsObjectName, @@ -471,6 +457,5 @@ class DuckDuckGoWebView : WebView, NestedScrollingChild3 { * We can't use that value directly as it was only added on Oreo, but we can apply the value anyway. */ private const val IME_FLAG_NO_PERSONALIZED_LEARNING = 0x1000000 - private const val WEB_MESSAGE_LISTENER_WEBVIEW_VERSION = "126.0.6478.40" } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt b/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt new file mode 100644 index 000000000000..0e4e74e89521 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser + +import androidx.webkit.WebViewFeature +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DocumentStartJavaScript +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.WebMessageListener +import com.duckduckgo.browser.api.WebViewVersionProvider +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.compareSemanticVersion +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext + +@ContributesBinding(FragmentScope::class) +class RealWebViewCapabilityChecker @Inject constructor( + private val dispatchers: DispatcherProvider, + private val webViewVersionProvider: WebViewVersionProvider, +) : WebViewCapabilityChecker { + + override suspend fun isSupported(capability: WebViewCapability): Boolean { + return when (capability) { + DocumentStartJavaScript -> isDocumentStartJavaScriptSupported() + WebMessageListener -> isWebMessageListenerSupported() + } + } + + private suspend fun isWebMessageListenerSupported(): Boolean { + return withContext(dispatchers.io()) { + webViewVersionProvider.getFullVersion() + .compareSemanticVersion(WEB_MESSAGE_LISTENER_WEBVIEW_VERSION)?.let { it >= 0 } ?: false + } && WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + } + + private fun isDocumentStartJavaScriptSupported(): Boolean { + return WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) + } + + companion object { + // critical fixes didn't exist until this WebView version + private const val WEB_MESSAGE_LISTENER_WEBVIEW_VERSION = "126.0.6478.40" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt index 90f470be0920..acbfe1e5fdb7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt @@ -94,7 +94,9 @@ class SpecialUrlDetectorImpl( val willNavigateToDuckPlayer = runBlocking { willNavigateToDuckPlayerDeferred.await() } - if (!willNavigateToDuckPlayer) { + if (willNavigateToDuckPlayer) { + return UrlType.ShouldLaunchDuckPlayerLink(url = uri) + } else { try { val browsableIntent = Intent.parseUri(uriString, URI_ANDROID_APP_SCHEME).apply { addCategory(Intent.CATEGORY_BROWSABLE) diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt index 0154562032d5..d158359bb860 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -51,7 +51,7 @@ interface WebViewClientListener { callback: GeolocationPermissions.Callback, ) - fun titleReceived(newTitle: String, url: String?) + fun titleReceived(newTitle: String) fun trackerDetected(event: TrackingEvent) fun pageHasHttpResources(page: String) fun pageHasHttpResources(page: Uri) diff --git a/app/src/main/java/com/duckduckgo/app/browser/autocomplete/BrowserAutoCompleteSuggestionsAdapter.kt b/app/src/main/java/com/duckduckgo/app/browser/autocomplete/BrowserAutoCompleteSuggestionsAdapter.kt index 7b9e6caa970b..af698625923f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/autocomplete/BrowserAutoCompleteSuggestionsAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/autocomplete/BrowserAutoCompleteSuggestionsAdapter.kt @@ -33,6 +33,7 @@ import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAda import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter.Type.HISTORY_TYPE import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter.Type.IN_APP_MESSAGE_TYPE import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter.Type.SUGGESTION_TYPE +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition class BrowserAutoCompleteSuggestionsAdapter( private val immediateSearchClickListener: (AutoCompleteSuggestion) -> Unit, @@ -40,6 +41,7 @@ class BrowserAutoCompleteSuggestionsAdapter( private val autoCompleteInAppMessageDismissedListener: () -> Unit, private val autoCompleteOpenSettingsClickListener: () -> Unit, private val autoCompleteLongPressClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) : RecyclerView.Adapter() { private val deleteClickListener: (AutoCompleteSuggestion) -> Unit = { @@ -50,12 +52,12 @@ class BrowserAutoCompleteSuggestionsAdapter( private val viewHolderFactoryMap: Map = mapOf( EMPTY_TYPE to EmptySuggestionViewHolderFactory(), - SUGGESTION_TYPE to SearchSuggestionViewHolderFactory(), - BOOKMARK_TYPE to BookmarkSuggestionViewHolderFactory(), - HISTORY_TYPE to HistorySuggestionViewHolderFactory(), - HISTORY_SEARCH_TYPE to HistorySearchSuggestionViewHolderFactory(), + SUGGESTION_TYPE to SearchSuggestionViewHolderFactory(omnibarPosition), + BOOKMARK_TYPE to BookmarkSuggestionViewHolderFactory(omnibarPosition), + HISTORY_TYPE to HistorySuggestionViewHolderFactory(omnibarPosition), + HISTORY_SEARCH_TYPE to HistorySearchSuggestionViewHolderFactory(omnibarPosition), IN_APP_MESSAGE_TYPE to InAppMessageViewHolderFactory(), - DEFAULT_TYPE to DefaultSuggestionViewHolderFactory(), + DEFAULT_TYPE to DefaultSuggestionViewHolderFactory(omnibarPosition), ) private var phrase = "" diff --git a/app/src/main/java/com/duckduckgo/app/browser/autocomplete/SuggestionViewHolderFactory.kt b/app/src/main/java/com/duckduckgo/app/browser/autocomplete/SuggestionViewHolderFactory.kt index 0aa4e993e474..676b2bf0186b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/autocomplete/SuggestionViewHolderFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/autocomplete/SuggestionViewHolderFactory.kt @@ -33,6 +33,7 @@ import com.duckduckgo.app.browser.databinding.ItemAutocompleteDefaultBinding import com.duckduckgo.app.browser.databinding.ItemAutocompleteHistorySuggestionBinding import com.duckduckgo.app.browser.databinding.ItemAutocompleteInAppMessageBinding import com.duckduckgo.app.browser.databinding.ItemAutocompleteSearchSuggestionBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.common.ui.view.MessageCta.Message interface SuggestionViewHolderFactory { @@ -50,7 +51,7 @@ interface SuggestionViewHolderFactory { ) } -class SearchSuggestionViewHolderFactory : SuggestionViewHolderFactory { +class SearchSuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -68,11 +69,16 @@ class SearchSuggestionViewHolderFactory : SuggestionViewHolderFactory { longPressClickListener: (AutoCompleteSuggestion) -> Unit, ) { val searchSuggestionViewHolder = holder as AutoCompleteViewHolder.SearchSuggestionViewHolder - searchSuggestionViewHolder.bind(suggestion as AutoCompleteSearchSuggestion, immediateSearchClickListener, editableSearchClickListener) + searchSuggestionViewHolder.bind( + suggestion as AutoCompleteSearchSuggestion, + immediateSearchClickListener, + editableSearchClickListener, + omnibarPosition, + ) } } -class HistorySuggestionViewHolderFactory : SuggestionViewHolderFactory { +class HistorySuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -95,11 +101,12 @@ class HistorySuggestionViewHolderFactory : SuggestionViewHolderFactory { immediateSearchClickListener, editableSearchClickListener, longPressClickListener, + omnibarPosition, ) } } -class HistorySearchSuggestionViewHolderFactory : SuggestionViewHolderFactory { +class HistorySearchSuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -122,11 +129,12 @@ class HistorySearchSuggestionViewHolderFactory : SuggestionViewHolderFactory { immediateSearchClickListener, editableSearchClickListener, longPressClickListener, + omnibarPosition, ) } } -class BookmarkSuggestionViewHolderFactory : SuggestionViewHolderFactory { +class BookmarkSuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -144,7 +152,12 @@ class BookmarkSuggestionViewHolderFactory : SuggestionViewHolderFactory { longPressClickListener: (AutoCompleteSuggestion) -> Unit, ) { val bookmarkSuggestionViewHolder = holder as AutoCompleteViewHolder.BookmarkSuggestionViewHolder - bookmarkSuggestionViewHolder.bind(suggestion as AutoCompleteBookmarkSuggestion, immediateSearchClickListener, editableSearchClickListener) + bookmarkSuggestionViewHolder.bind( + suggestion as AutoCompleteBookmarkSuggestion, + immediateSearchClickListener, + editableSearchClickListener, + omnibarPosition, + ) } } @@ -172,7 +185,7 @@ class EmptySuggestionViewHolderFactory : SuggestionViewHolderFactory { } } -class DefaultSuggestionViewHolderFactory : SuggestionViewHolderFactory { +class DefaultSuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -189,7 +202,7 @@ class DefaultSuggestionViewHolderFactory : SuggestionViewHolderFactory { longPressClickListener: (AutoCompleteSuggestion) -> Unit, ) { val viewholder = holder as AutoCompleteViewHolder.DefaultSuggestionViewHolder - viewholder.bind(suggestion as AutoCompleteDefaultSuggestion, immediateSearchClickListener) + viewholder.bind(suggestion as AutoCompleteDefaultSuggestion, immediateSearchClickListener, omnibarPosition) } } @@ -220,6 +233,7 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it item: AutoCompleteSearchSuggestion, immediateSearchListener: (AutoCompleteSuggestion) -> Unit, editableSearchClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) = with(binding) { phrase.text = item.phrase @@ -228,6 +242,10 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it editQueryImage.setOnClickListener { editableSearchClickListener(item) } root.setOnClickListener { immediateSearchListener(item) } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + editQueryImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } @@ -237,6 +255,7 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it immediateSearchListener: (AutoCompleteSuggestion) -> Unit, editableSearchClickListener: (AutoCompleteSuggestion) -> Unit, longPressClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) = with(binding) { phrase.text = item.phrase @@ -248,6 +267,10 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it longPressClickListener(item) true } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + editQueryImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } @@ -256,6 +279,7 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it item: AutoCompleteBookmarkSuggestion, immediateSearchListener: (AutoCompleteSuggestion) -> Unit, editableSearchClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) = with(binding) { title.text = item.title url.text = item.phrase @@ -263,6 +287,10 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it bookmarkIndicator.setImageResource(if (item.isFavorite) R.drawable.ic_bookmark_favorite_20 else R.drawable.ic_bookmark_20) goToBookmarkImage.setOnClickListener { editableSearchClickListener(item) } root.setOnClickListener { immediateSearchListener(item) } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + goToBookmarkImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } @@ -272,6 +300,7 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it immediateSearchListener: (AutoCompleteSuggestion) -> Unit, editableSearchClickListener: (AutoCompleteSuggestion) -> Unit, longPressClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) = with(binding) { title.text = item.title url.text = item.phrase @@ -282,6 +311,10 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it longPressClickListener(item) true } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + goToSuggestionImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } @@ -291,9 +324,14 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it fun bind( item: AutoCompleteDefaultSuggestion, immediateSearchListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) { binding.phrase.text = item.phrase binding.root.setOnClickListener { immediateSearchListener(item) } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + binding.editQueryImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt index 599092851335..a07a513151ef 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt @@ -37,6 +37,7 @@ import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.viewstate.SavedSiteChangedViewState import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.js.messaging.api.JsCallbackData @@ -157,6 +158,7 @@ sealed class Command { class ShowWebPageTitle( val title: String, val url: String?, + val showDuckPlayerIcon: Boolean = false, ) : Command() class CheckSystemLocationPermission( val domain: String, @@ -191,19 +193,16 @@ sealed class Command { object ChildTabClosed : Command() class CopyAliasToClipboard(val alias: String) : Command() - class InjectEmailAddress( + class ShowEmailProtectionChooseEmailPrompt( val duckAddress: String, - val originalUrl: String, - val autoSaveLogin: Boolean, + val autofillWebMessageRequest: AutofillWebMessageRequest, ) : Command() - - class ShowEmailProtectionChooseEmailPrompt(val address: String) : Command() + object PageChanged : Command() object ShowEmailProtectionInContextSignUpPrompt : Command() class CancelIncomingAutofillRequest(val url: String) : Command() data class LaunchAutofillSettings(val privacyProtectionEnabled: Boolean) : Command() class EditWithSelectedQuery(val query: String) : Command() class ShowBackNavigationHistory(val history: List) : Command() - object EmailSignEvent : Command() class ShowSitePermissionsDialog( val permissionsToRequest: SitePermissions, val request: PermissionRequest, diff --git a/app/src/main/java/com/duckduckgo/app/browser/indonesiamessage/IndonesiaNewTabSectionViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/indonesiamessage/IndonesiaNewTabSectionViewModel.kt index 40410a66d00d..07d5753b19b7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/indonesiamessage/IndonesiaNewTabSectionViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/indonesiamessage/IndonesiaNewTabSectionViewModel.kt @@ -82,7 +82,7 @@ class IndonesiaNewTabSectionViewModel @Inject constructor( } fun onMessageShown() { - pixel.fire(AppPixelName.INDONESIA_MESSAGE_SHOWN, type = Pixel.PixelType.DAILY) + pixel.fire(AppPixelName.INDONESIA_MESSAGE_SHOWN, type = Pixel.PixelType.Daily()) } companion object { diff --git a/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt b/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt index 8aa5263318df..b955233543cb 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt @@ -18,28 +18,213 @@ package com.duckduckgo.app.browser.menu import android.content.Context import android.view.LayoutInflater +import android.view.View import androidx.core.view.isVisible import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.SSLErrorType.NONE import com.duckduckgo.app.browser.databinding.PopupWindowBrowserMenuBinding +import com.duckduckgo.app.browser.databinding.PopupWindowBrowserMenuBottomBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.viewstate.BrowserViewState import com.duckduckgo.common.ui.menu.PopupMenu +import com.duckduckgo.common.ui.view.MenuItemView import com.duckduckgo.mobile.android.R.dimen import com.duckduckgo.mobile.android.R.drawable class BrowserPopupMenu( - context: Context, + private val context: Context, layoutInflater: LayoutInflater, - displayedInCustomTabScreen: Boolean, + private val omnibarPosition: OmnibarPosition, ) : PopupMenu( layoutInflater, - resourceId = R.layout.popup_window_browser_menu, + resourceId = if (omnibarPosition == TOP) R.layout.popup_window_browser_menu else R.layout.popup_window_browser_menu_bottom, width = context.resources.getDimensionPixelSize(dimen.popupMenuWidth), ) { - private val binding = PopupWindowBrowserMenuBinding.inflate(layoutInflater) + private val topBinding = PopupWindowBrowserMenuBinding.bind(contentView) + private val bottomBinding = PopupWindowBrowserMenuBottomBinding.bind(contentView) init { - contentView = binding.root + contentView = when (omnibarPosition) { + TOP -> topBinding.root + BOTTOM -> bottomBinding.root + } + } + + internal val backMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.backMenuItem + BOTTOM -> bottomBinding.backMenuItem + } + } + + internal val forwardMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.forwardMenuItem + BOTTOM -> bottomBinding.forwardMenuItem + } + } + + internal val refreshMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.refreshMenuItem + BOTTOM -> bottomBinding.refreshMenuItem + } + } + + internal val printPageMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.printPageMenuItem + BOTTOM -> bottomBinding.printPageMenuItem + } + } + + internal val newTabMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.newTabMenuItem + BOTTOM -> bottomBinding.newTabMenuItem + } + } + + internal val sharePageMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.sharePageMenuItem + BOTTOM -> bottomBinding.sharePageMenuItem + } + } + + internal val bookmarksMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.bookmarksMenuItem + BOTTOM -> bottomBinding.bookmarksMenuItem + } + } + + internal val downloadsMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.downloadsMenuItem + BOTTOM -> bottomBinding.downloadsMenuItem + } + } + + internal val settingsMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.settingsMenuItem + BOTTOM -> bottomBinding.settingsMenuItem + } + } + + internal val addBookmarksMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.addBookmarksMenuItem + BOTTOM -> bottomBinding.addBookmarksMenuItem + } + } + + internal val fireproofWebsiteMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.fireproofWebsiteMenuItem + BOTTOM -> bottomBinding.fireproofWebsiteMenuItem + } + } + + internal val createAliasMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.createAliasMenuItem + BOTTOM -> bottomBinding.createAliasMenuItem + } + } + + internal val changeBrowserModeMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.changeBrowserModeMenuItem + BOTTOM -> bottomBinding.changeBrowserModeMenuItem + } + } + + internal val openInAppMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.openInAppMenuItem + BOTTOM -> bottomBinding.openInAppMenuItem + } + } + + internal val findInPageMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.findInPageMenuItem + BOTTOM -> bottomBinding.findInPageMenuItem + } + } + + internal val addToHomeMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.addToHomeMenuItem + BOTTOM -> bottomBinding.addToHomeMenuItem + } + } + + internal val privacyProtectionMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.privacyProtectionMenuItem + BOTTOM -> bottomBinding.privacyProtectionMenuItem + } + } + + internal val brokenSiteMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.brokenSiteMenuItem + BOTTOM -> bottomBinding.brokenSiteMenuItem + } + } + + internal val autofillMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.autofillMenuItem + BOTTOM -> bottomBinding.autofillMenuItem + } + } + + internal val runningInDdgBrowserMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.runningInDdgBrowserMenuItem + BOTTOM -> bottomBinding.runningInDdgBrowserMenuItem + } + } + + internal val siteOptionsMenuDivider: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.siteOptionsMenuDivider + BOTTOM -> bottomBinding.siteOptionsMenuDivider + } + } + + internal val browserOptionsMenuDivider: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.browserOptionsMenuDivider + BOTTOM -> bottomBinding.browserOptionsMenuDivider + } + } + + internal val settingsMenuDivider: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.settingsMenuDivider + BOTTOM -> bottomBinding.settingsMenuDivider + } + } + + internal val customTabsMenuDivider: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.customTabsMenuDivider + BOTTOM -> bottomBinding.customTabsMenuDivider + } + } + + internal val openInDdgBrowserMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.openInDdgBrowserMenuItem + BOTTOM -> bottomBinding.openInDdgBrowserMenuItem + } } fun renderState( @@ -47,92 +232,89 @@ class BrowserPopupMenu( viewState: BrowserViewState, displayedInCustomTabScreen: Boolean, ) { - contentView.apply { - binding.backMenuItem.isEnabled = viewState.canGoBack - binding.forwardMenuItem.isEnabled = viewState.canGoForward - binding.refreshMenuItem.isEnabled = browserShowing - binding.printPageMenuItem.isEnabled = browserShowing - - binding.newTabMenuItem.isVisible = browserShowing && !displayedInCustomTabScreen - binding.sharePageMenuItem.isVisible = viewState.canSharePage - - binding.bookmarksMenuItem.isVisible = !displayedInCustomTabScreen - binding.downloadsMenuItem.isVisible = !displayedInCustomTabScreen - binding.settingsMenuItem.isVisible = !displayedInCustomTabScreen - - binding.addBookmarksMenuItem.isVisible = viewState.canSaveSite && !displayedInCustomTabScreen - val isBookmark = viewState.bookmark != null - binding.addBookmarksMenuItem.label { - context.getString(if (isBookmark) R.string.editBookmarkMenuTitle else R.string.addBookmarkMenuTitle) - } - binding.addBookmarksMenuItem.setIcon(if (isBookmark) drawable.ic_bookmark_solid_16 else drawable.ic_bookmark_16) - - binding.fireproofWebsiteMenuItem.isVisible = viewState.canFireproofSite && !displayedInCustomTabScreen - binding.fireproofWebsiteMenuItem.label { - context.getString( - if (viewState.isFireproofWebsite) { - R.string.fireproofWebsiteMenuTitleRemove - } else { - R.string.fireproofWebsiteMenuTitleAdd - }, - ) - } - binding.fireproofWebsiteMenuItem.setIcon(if (viewState.isFireproofWebsite) drawable.ic_fire_16 else drawable.ic_fireproofed_16) - - binding.createAliasMenuItem.isVisible = viewState.isEmailSignedIn && !displayedInCustomTabScreen - - binding.changeBrowserModeMenuItem.isVisible = viewState.canChangeBrowsingMode - binding.changeBrowserModeMenuItem.label { - context.getString( - if (viewState.isDesktopBrowsingMode) { - R.string.requestMobileSiteMenuTitle - } else { - R.string.requestDesktopSiteMenuTitle - }, - ) - } - binding.changeBrowserModeMenuItem.setIcon( - if (viewState.isDesktopBrowsingMode) drawable.ic_device_mobile_16 else drawable.ic_device_desktop_16, - ) + backMenuItem.isEnabled = viewState.canGoBack + forwardMenuItem.isEnabled = viewState.canGoForward + refreshMenuItem.isEnabled = browserShowing + printPageMenuItem.isEnabled = browserShowing - binding.openInAppMenuItem.isVisible = viewState.previousAppLink != null - binding.findInPageMenuItem.isVisible = viewState.canFindInPage - binding.addToHomeMenuItem.isVisible = viewState.addToHomeVisible && viewState.addToHomeEnabled && !displayedInCustomTabScreen - binding.privacyProtectionMenuItem.isVisible = viewState.canChangePrivacyProtection - binding.privacyProtectionMenuItem.label { - context.getText( - if (viewState.isPrivacyProtectionDisabled) { - R.string.enablePrivacyProtection - } else { - R.string.disablePrivacyProtection - }, - ).toString() - } - binding.privacyProtectionMenuItem.setIcon( - if (viewState.isPrivacyProtectionDisabled) drawable.ic_protections_16 else drawable.ic_protections_blocked_16, + newTabMenuItem.isVisible = browserShowing && !displayedInCustomTabScreen + sharePageMenuItem.isVisible = viewState.canSharePage + + bookmarksMenuItem.isVisible = !displayedInCustomTabScreen + downloadsMenuItem.isVisible = !displayedInCustomTabScreen + settingsMenuItem.isVisible = !displayedInCustomTabScreen + + addBookmarksMenuItem.isVisible = viewState.canSaveSite && !displayedInCustomTabScreen + val isBookmark = viewState.bookmark != null + addBookmarksMenuItem.label { + context.getString(if (isBookmark) R.string.editBookmarkMenuTitle else R.string.addBookmarkMenuTitle) + } + addBookmarksMenuItem.setIcon(if (isBookmark) drawable.ic_bookmark_solid_16 else drawable.ic_bookmark_16) + + fireproofWebsiteMenuItem.isVisible = viewState.canFireproofSite && !displayedInCustomTabScreen + fireproofWebsiteMenuItem.label { + context.getString( + if (viewState.isFireproofWebsite) { + R.string.fireproofWebsiteMenuTitleRemove + } else { + R.string.fireproofWebsiteMenuTitleAdd + }, ) - binding.brokenSiteMenuItem.isVisible = viewState.canReportSite && !displayedInCustomTabScreen + } + fireproofWebsiteMenuItem.setIcon(if (viewState.isFireproofWebsite) drawable.ic_fire_16 else drawable.ic_fireproofed_16) - binding.siteOptionsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen - binding.browserOptionsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen - binding.settingsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen - binding.printPageMenuItem.isVisible = viewState.canPrintPage && !displayedInCustomTabScreen - binding.autofillMenuItem.isVisible = viewState.showAutofill && !displayedInCustomTabScreen + createAliasMenuItem.isVisible = viewState.isEmailSignedIn && !displayedInCustomTabScreen - binding.openInDdgBrowserMenuItem.isVisible = displayedInCustomTabScreen - binding.customTabsMenuDivider.isVisible = displayedInCustomTabScreen - binding.runningInDdgBrowserMenuItem.isVisible = displayedInCustomTabScreen - overrideForSSlError(binding, viewState) + changeBrowserModeMenuItem.isVisible = viewState.canChangeBrowsingMode + changeBrowserModeMenuItem.label { + context.getString( + if (viewState.isDesktopBrowsingMode) { + R.string.requestMobileSiteMenuTitle + } else { + R.string.requestDesktopSiteMenuTitle + }, + ) + } + changeBrowserModeMenuItem.setIcon( + if (viewState.isDesktopBrowsingMode) drawable.ic_device_mobile_16 else drawable.ic_device_desktop_16, + ) + + openInAppMenuItem.isVisible = viewState.previousAppLink != null + findInPageMenuItem.isVisible = viewState.canFindInPage + addToHomeMenuItem.isVisible = viewState.addToHomeVisible && viewState.addToHomeEnabled && !displayedInCustomTabScreen + privacyProtectionMenuItem.isVisible = viewState.canChangePrivacyProtection + privacyProtectionMenuItem.label { + context.getText( + if (viewState.isPrivacyProtectionDisabled) { + R.string.enablePrivacyProtection + } else { + R.string.disablePrivacyProtection + }, + ).toString() } + privacyProtectionMenuItem.setIcon( + if (viewState.isPrivacyProtectionDisabled) drawable.ic_protections_16 else drawable.ic_protections_blocked_16, + ) + brokenSiteMenuItem.isVisible = viewState.canReportSite && !displayedInCustomTabScreen + + siteOptionsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen + browserOptionsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen + settingsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen + printPageMenuItem.isVisible = viewState.canPrintPage && !displayedInCustomTabScreen + autofillMenuItem.isVisible = viewState.showAutofill && !displayedInCustomTabScreen + + openInDdgBrowserMenuItem.isVisible = displayedInCustomTabScreen + customTabsMenuDivider.isVisible = displayedInCustomTabScreen + runningInDdgBrowserMenuItem.isVisible = displayedInCustomTabScreen + overrideForSSlError(viewState) } private fun overrideForSSlError( - binding: PopupWindowBrowserMenuBinding, viewState: BrowserViewState, ) { if (viewState.sslError != NONE) { - binding.newTabMenuItem.isVisible = true - binding.siteOptionsMenuDivider.isVisible = true + newTabMenuItem.isVisible = true + siteOptionsMenuDivider.isVisible = true } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt new file mode 100644 index 000000000000..153427b4dc1d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.animation.DecelerateInterpolator +import android.widget.RelativeLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.ViewCompat.NestedScrollType +import androidx.core.view.updateLayoutParams +import com.duckduckgo.app.browser.R +import com.google.android.material.snackbar.Snackbar +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/* + * This custom behavior for the bottom omnibar is necessary because the default `HideBottomViewOnScrollBehavior` does not work. + * The reason is that the `DuckDuckGoWebView` is passing only unconsumed movement, which `HideBottomViewOnScrollBehavior` ignores. + */ +class BottomAppBarBehavior( + context: Context, + private val toolbar: LegacyOmnibarView, + attrs: AttributeSet? = null, +) : CoordinatorLayout.Behavior(context, attrs) { + @NestedScrollType + private var lastStartedType: Int = 0 + private var offsetAnimator: ValueAnimator? = null + + private var browserLayout: RelativeLayout? = null + + @SuppressLint("RestrictedApi") + override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean { + if (dependency is Snackbar.SnackbarLayout) { + updateSnackbar(child, dependency) + } + + if (dependency.id == R.id.browserLayout) { + browserLayout = dependency as RelativeLayout + offsetBottomByToolbar(browserLayout) + } + + return super.layoutDependsOn(parent, child, dependency) + } + + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + directTargetChild: View, + target: View, + axes: Int, + type: Int, + ): Boolean { + if (axes == ViewCompat.SCROLL_AXIS_VERTICAL) { + lastStartedType = type + offsetAnimator?.cancel() + return true + } else { + return false + } + } + + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + toolbar: V, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int, + ) { + super.onNestedPreScroll(coordinatorLayout, toolbar, target, dx, dy, consumed, type) + + // only hide the app bar in the browser layout + if (target.id == R.id.browserWebView) { + toolbar.translationY = max(0f, min(toolbar.height.toFloat(), toolbar.translationY + dy)) + offsetBottomByToolbar(browserLayout) + } + + offsetBottomByToolbar(target) + } + + private fun offsetBottomByToolbar(view: View?) { + if (view?.layoutParams is CoordinatorLayout.LayoutParams) { + view.updateLayoutParams { + this.bottomMargin = toolbar.measuredHeight - toolbar.translationY.roundToInt() + } + view.requestLayout() + } + } + + override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: V, target: View, type: Int) { + if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) { + val dY = child.translationY + val threshold = child.height * 0.5f + if (dY >= threshold) { + // slide down + animateToolbarVisibility(isVisible = false) + } else { + // slide up + animateToolbarVisibility(isVisible = true) + } + } + } + + fun animateToolbarVisibility(isVisible: Boolean) { + if (offsetAnimator == null) { + offsetAnimator = ValueAnimator().apply { + interpolator = DecelerateInterpolator() + duration = 300L + } + } else { + offsetAnimator?.cancel() + } + + offsetAnimator?.addUpdateListener { animation -> + val animatedValue = animation.animatedValue as Float + toolbar.translationY = animatedValue + offsetBottomByToolbar(browserLayout) + } + + val targetTranslation = if (isVisible) 0f else toolbar.height.toFloat() + offsetAnimator?.setFloatValues(toolbar.translationY, targetTranslation) + offsetAnimator?.start() + } + + @SuppressLint("RestrictedApi") + private fun updateSnackbar(child: View, snackbarLayout: Snackbar.SnackbarLayout) { + if (snackbarLayout.layoutParams is CoordinatorLayout.LayoutParams) { + val params = snackbarLayout.layoutParams as CoordinatorLayout.LayoutParams + + params.anchorId = child.id + params.anchorGravity = Gravity.TOP + params.gravity = Gravity.TOP + snackbarLayout.layoutParams = params + + // add a padding to the snackbar to avoid it touching the anchor view + if (snackbarLayout.translationY == 0f) { + snackbarLayout.translationY -= child.context.resources.getDimension(com.duckduckgo.mobile.android.R.dimen.keyline_2) + } + } + } +} diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/emailprotection/EmailInjector.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/ChangeOmnibarPositionFeature.kt similarity index 52% rename from autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/emailprotection/EmailInjector.kt rename to app/src/main/java/com/duckduckgo/app/browser/omnibar/ChangeOmnibarPositionFeature.kt index aafe79802ab9..91e6cba5bd53 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/emailprotection/EmailInjector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/ChangeOmnibarPositionFeature.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 DuckDuckGo + * Copyright (c) 2024 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,26 +14,21 @@ * limitations under the License. */ -package com.duckduckgo.autofill.api.emailprotection +package com.duckduckgo.app.browser.omnibar -import android.webkit.WebView +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle -interface EmailInjector { +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "changeOmnibarPosition", +) +interface ChangeOmnibarPositionFeature { + @Toggle.DefaultValue(false) + @Toggle.InternalAlwaysEnabled + fun self(): Toggle - fun addJsInterface( - webView: WebView, - onSignedInEmailProtectionPromptShown: () -> Unit, - onInContextEmailProtectionSignupPromptShown: () -> Unit, - ) - - fun injectAddressInEmailField( - webView: WebView, - alias: String?, - url: String?, - ) - - fun notifyWebAppSignEvent( - webView: WebView, - url: String?, - ) + @Toggle.DefaultValue(false) + fun refactor(): Toggle } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/LegacyOmnibarView.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/LegacyOmnibarView.kt index 0bb9724dd3c0..c7d8f923bf08 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/LegacyOmnibarView.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/LegacyOmnibarView.kt @@ -18,8 +18,21 @@ package com.duckduckgo.app.browser.omnibar import android.content.Context import android.util.AttributeSet -import com.duckduckgo.app.browser.databinding.ViewLegacyOmnibarBinding -import com.duckduckgo.common.ui.viewbinding.viewBinding +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior +import com.airbnb.lottie.LottieAnimationView +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.TabSwitcherButton +import com.duckduckgo.app.browser.databinding.IncludeCustomTabToolbarBinding +import com.duckduckgo.app.browser.databinding.IncludeFindInPageBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.common.ui.view.KeyboardAwareEditText import com.google.android.material.appbar.AppBarLayout class LegacyOmnibarView @JvmOverloads constructor( @@ -28,77 +41,63 @@ class LegacyOmnibarView @JvmOverloads constructor( defStyle: Int = 0, ) : AppBarLayout(context, attrs, defStyle) { - private val binding: ViewLegacyOmnibarBinding by viewBinding() - - val findInPage - get() = binding.findInPage - - val omnibarTextInput - get() = binding.omnibarTextInput - - val tabsMenu - get() = binding.tabsMenu - - val fireIconMenu - get() = binding.fireIconMenu - - val browserMenu - get() = binding.browserMenu - - val cookieDummyView - get() = binding.cookieDummyView - - val cookieAnimation - get() = binding.cookieAnimation - - val sceneRoot - get() = binding.sceneRoot - - val omniBarContainer - get() = binding.omniBarContainer - - val toolbar - get() = binding.toolbar - - val toolbarContainer - get() = binding.toolbarContainer - - val customTabToolbarContainer - get() = binding.customTabToolbarContainer - - val browserMenuImageView - get() = binding.browserMenuImageView - - val shieldIcon - get() = binding.shieldIcon - - val pageLoadingIndicator - get() = binding.pageLoadingIndicator - - val searchIcon - get() = binding.searchIcon - - val daxIcon - get() = binding.daxIcon - - val clearTextButton - get() = binding.clearTextButton - - val fireIconImageView - get() = binding.fireIconImageView - - val placeholder - get() = binding.placeholder - - val voiceSearchButton - get() = binding.voiceSearchButton - - val spacer - get() = binding.spacer - - val trackersAnimation - get() = binding.trackersAnimation - - val duckPlayerIcon - get() = binding.duckPlayerIcon + private val omnibarPosition: OmnibarPosition + + init { + val attr = context.theme.obtainStyledAttributes(attrs, R.styleable.LegacyOmnibarView, defStyle, 0) + omnibarPosition = OmnibarPosition.entries[attr.getInt(R.styleable.LegacyOmnibarView_omnibarPosition, 0)] + + val layout = if (omnibarPosition == OmnibarPosition.BOTTOM) { + R.layout.view_legacy_omnibar_bottom + } else { + R.layout.view_legacy_omnibar + } + inflate(context, layout, this) + } + + override fun setExpanded(expanded: Boolean) { + when (omnibarPosition) { + OmnibarPosition.TOP -> super.setExpanded(expanded) + OmnibarPosition.BOTTOM -> (behavior as BottomAppBarBehavior).animateToolbarVisibility(expanded) + } + } + + override fun setExpanded(expanded: Boolean, animate: Boolean) { + when (omnibarPosition) { + OmnibarPosition.TOP -> super.setExpanded(expanded, animate) + OmnibarPosition.BOTTOM -> (behavior as BottomAppBarBehavior).animateToolbarVisibility(expanded) + } + } + + val findInPage by lazy { IncludeFindInPageBinding.bind(findViewById(R.id.findInPage)) } + val omnibarTextInput: KeyboardAwareEditText by lazy { findViewById(R.id.omnibarTextInput) } + val tabsMenu: TabSwitcherButton by lazy { findViewById(R.id.tabsMenu) } + val fireIconMenu: FrameLayout by lazy { findViewById(R.id.fireIconMenu) } + val browserMenu: FrameLayout by lazy { findViewById(R.id.browserMenu) } + val cookieDummyView: View by lazy { findViewById(R.id.cookieDummyView) } + val cookieAnimation: LottieAnimationView by lazy { findViewById(R.id.cookieAnimation) } + val sceneRoot: ViewGroup by lazy { findViewById(R.id.sceneRoot) } + val omniBarContainer: View by lazy { findViewById(R.id.omniBarContainer) } + val toolbar: Toolbar by lazy { findViewById(R.id.toolbar) } + val toolbarContainer: View by lazy { findViewById(R.id.toolbarContainer) } + val customTabToolbarContainer by lazy { IncludeCustomTabToolbarBinding.bind(findViewById(R.id.customTabToolbarContainer)) } + val browserMenuImageView: ImageView by lazy { findViewById(R.id.browserMenuImageView) } + val shieldIcon: LottieAnimationView by lazy { findViewById(R.id.shieldIcon) } + val pageLoadingIndicator: ProgressBar by lazy { findViewById(R.id.pageLoadingIndicator) } + val searchIcon: ImageView by lazy { findViewById(R.id.searchIcon) } + val daxIcon: ImageView by lazy { findViewById(R.id.daxIcon) } + val clearTextButton: ImageView by lazy { findViewById(R.id.clearTextButton) } + val fireIconImageView: ImageView by lazy { findViewById(R.id.fireIconImageView) } + val placeholder: View by lazy { findViewById(R.id.placeholder) } + val voiceSearchButton: ImageView by lazy { findViewById(R.id.voiceSearchButton) } + val spacer: View by lazy { findViewById(R.id.spacer) } + val trackersAnimation: LottieAnimationView by lazy { findViewById(R.id.trackersAnimation) } + val duckPlayerIcon: ImageView by lazy { findViewById(R.id.duckPlayerIcon) } + + override fun getBehavior(): CoordinatorLayout.Behavior { + return when (omnibarPosition) { + OmnibarPosition.TOP -> TopAppBarBehavior(context) + OmnibarPosition.BOTTOM -> BottomAppBarBehavior(context, this) + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt new file mode 100644 index 000000000000..b5ce06d2447c --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import android.annotation.SuppressLint +import android.content.res.TypedArray +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.updateLayoutParams +import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.mobile.android.R as CommonR + +@SuppressLint("ClickableViewAccessibility") +class Omnibar( + val omnibarPosition: OmnibarPosition, + private val binding: FragmentBrowserTabBinding, +) { + private val actionBarSize: Int by lazy { + val array: TypedArray = binding.rootView.context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize)) + val actionBarSize = array.getDimensionPixelSize(0, -1) + array.recycle() + actionBarSize + } + + val appBarLayout: LegacyOmnibarView by lazy { + when (omnibarPosition) { + OmnibarPosition.TOP -> { + binding.rootView.removeView(binding.legacyOmnibarBottom) + binding.legacyOmnibar + } + OmnibarPosition.BOTTOM -> { + binding.rootView.removeView(binding.legacyOmnibar) + + // remove the default top abb bar behavior + removeAppBarBehavior(binding.autoCompleteSuggestionsList) + removeAppBarBehavior(binding.browserLayout) + removeAppBarBehavior(binding.focusedView) + + // add padding to the NTP to prevent the bottom toolbar from overlapping the settings button + binding.includeNewBrowserTab.browserBackground.apply { + setPadding(paddingLeft, context.resources.getDimensionPixelSize(CommonR.dimen.keyline_2), paddingRight, actionBarSize) + } + + // prevent the touch event leaking to the webView below + binding.legacyOmnibarBottom.setOnTouchListener { _, _ -> true } + + binding.legacyOmnibarBottom + } + } + } + + private fun removeAppBarBehavior(view: View) { + view.updateLayoutParams { + behavior = null + } + } + + val findInPage = appBarLayout.findInPage + val omnibarTextInput = appBarLayout.omnibarTextInput + val tabsMenu = appBarLayout.tabsMenu + val fireIconMenu = appBarLayout.fireIconMenu + val browserMenu = appBarLayout.browserMenu + val cookieDummyView = appBarLayout.cookieDummyView + val cookieAnimation = appBarLayout.cookieAnimation + val sceneRoot = appBarLayout.sceneRoot + val omniBarContainer = appBarLayout.omniBarContainer + val toolbar = appBarLayout.toolbar + val toolbarContainer = appBarLayout.toolbarContainer + val customTabToolbarContainer = appBarLayout.customTabToolbarContainer + val browserMenuImageView = appBarLayout.browserMenuImageView + val shieldIcon = appBarLayout.shieldIcon + val pageLoadingIndicator = appBarLayout.pageLoadingIndicator + val searchIcon = appBarLayout.searchIcon + val daxIcon = appBarLayout.daxIcon + val clearTextButton = appBarLayout.clearTextButton + val fireIconImageView = appBarLayout.fireIconImageView + val placeholder = appBarLayout.placeholder + val voiceSearchButton = appBarLayout.voiceSearchButton + val spacer = appBarLayout.spacer + val trackersAnimation = appBarLayout.trackersAnimation + val duckPlayerIcon = appBarLayout.duckPlayerIcon +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt new file mode 100644 index 000000000000..dd6f36d1429a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesMultibinding( + scope = AppScope::class, + boundType = PrivacyConfigCallbackPlugin::class, +) +@SingleInstanceIn(AppScope::class) +class OmnibarFeatureFlagObserver @Inject constructor( + private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature, + private val settingsDataStore: SettingsDataStore, + private val dispatchers: DispatcherProvider, + private val loadingBarExperimentManager: LoadingBarExperimentManager, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : PrivacyConfigCallbackPlugin { + override fun onPrivacyConfigDownloaded() { + appCoroutineScope.launch(dispatchers.io()) { + // If the feature is not enabled, set the omnibar position to top in case it was set to bottom. + // The feature will only available if the loading experiment is disabled to avoid conflicts. + if (!changeOmnibarPositionFeature.self().isEnabled() || loadingBarExperimentManager.isExperimentEnabled()) { + settingsDataStore.omnibarPosition = OmnibarPosition.TOP + } + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarPositionDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarPositionDetector.kt new file mode 100644 index 000000000000..df332394c22f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarPositionDetector.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.statistics.api.BrowserFeatureStateReporterPlugin +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +interface OmnibarPositionReporterPlugin + +@ContributesMultibinding(scope = AppScope::class, boundType = BrowserFeatureStateReporterPlugin::class) +@ContributesBinding(scope = AppScope::class, boundType = OmnibarPositionReporterPlugin::class) +@SingleInstanceIn(AppScope::class) +class OmnibarPositionDetector @Inject constructor( + private val settingsDataStore: SettingsDataStore, +) : OmnibarPositionReporterPlugin, BrowserFeatureStateReporterPlugin { + override fun featureStateParams(): Map { + return mapOf(PixelParameter.ADDRESS_BAR to settingsDataStore.omnibarPosition.name) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/TopAppBarBehavior.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/TopAppBarBehavior.kt new file mode 100644 index 000000000000..cb568164cf3b --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/TopAppBarBehavior.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.duckduckgo.app.browser.R +import com.google.android.material.appbar.AppBarLayout + +/* + * This custom behavior prevents the top omnibar from hiding everywhere except for the browser view (i.e. the autocomplete suggestions) + */ +class TopAppBarBehavior(context: Context, attrs: AttributeSet? = null) : AppBarLayout.Behavior(context, attrs) { + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + child: AppBarLayout, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int, + ) { + if (target.id == R.id.browserWebView) { + super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) + } + } +} diff --git a/browser-api/src/main/java/com/duckduckgo/app/autofill/EmailProtectionJavascriptInjector.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/model/OmnibarPosition.kt similarity index 64% rename from browser-api/src/main/java/com/duckduckgo/app/autofill/EmailProtectionJavascriptInjector.kt rename to app/src/main/java/com/duckduckgo/app/browser/omnibar/model/OmnibarPosition.kt index fdefaa4d20ab..6e266d963efe 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/autofill/EmailProtectionJavascriptInjector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/model/OmnibarPosition.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * Copyright (c) 2024 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,8 @@ * limitations under the License. */ -package com.duckduckgo.app.autofill +package com.duckduckgo.app.browser.omnibar.model -import android.content.Context - -interface EmailProtectionJavascriptInjector { - - fun getAliasFunctions( - context: Context, - alias: String?, - ): String - - fun getSignOutFunctions( - context: Context, - ): String +enum class OmnibarPosition { + TOP, BOTTOM } diff --git a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt index 2dd941504888..17d1766b4ce9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt @@ -19,7 +19,7 @@ package com.duckduckgo.app.browser.pageloadpixel import com.duckduckgo.app.browser.WebViewPixelName import com.duckduckgo.app.statistics.api.OfflinePixel import com.duckduckgo.app.statistics.api.PixelSender -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import io.reactivex.Completable @@ -57,7 +57,7 @@ class PageLoadedOfflinePixelSender @Inject constructor( WebViewPixelName.WEB_PAGE_LOADED.pixelName, params, mapOf(), - COUNT, + Count, ).ignoreElement().doOnComplete { Timber.d("Sent page loaded pixel with params: $params") pageLoadedPixelDao.delete(it) diff --git a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/firstpaint/PagePaintedOfflinePixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/firstpaint/PagePaintedOfflinePixelSender.kt index e888b0c96ca4..7ca3e9db8dac 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/firstpaint/PagePaintedOfflinePixelSender.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/firstpaint/PagePaintedOfflinePixelSender.kt @@ -47,7 +47,7 @@ class PagePaintedOfflinePixelSender @Inject constructor( WebViewPixelName.WEB_PAGE_PAINTED.pixelName, params, mapOf(), - Pixel.PixelType.COUNT, + Pixel.PixelType.Count, ).ignoreElement().doOnComplete { dao.delete(it) } diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt index d3874668838a..0c2cad73aeb5 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt @@ -30,6 +30,7 @@ import androidx.transition.TransitionManager import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding import com.duckduckgo.app.browser.databinding.IncludeOnboardingViewDaxDialogBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.ui.DaxBubbleCta.DaxDialogIntroOption import com.duckduckgo.app.cta.ui.DaxCta.Companion.MAX_DAYS_ALLOWED @@ -37,6 +38,7 @@ import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.install.daysInstalled import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelValues.DAX_FIRE_DIALOG_CTA import com.duckduckgo.app.trackerdetection.model.Entity @@ -176,6 +178,7 @@ sealed class OnboardingDaxDialogCta( override val onboardingStore: OnboardingStore, override val appInstallStore: AppInstallStore, val trackers: List, + val settingsDataStore: SettingsDataStore, ) : OnboardingDaxDialogCta( CtaId.DAX_DIALOG_TRACKERS_FOUND, null, @@ -217,8 +220,10 @@ sealed class OnboardingDaxDialogCta( val quantityString = if (size == 0) { context.resources.getQuantityString(R.plurals.onboardingTrackersBlockedZeroDialogDescription, trackersFiltered.size) + .getStringForOmnibarPosition(settingsDataStore.omnibarPosition) } else { context.resources.getQuantityString(R.plurals.onboardingTrackersBlockedDialogDescription, size, size) + .getStringForOmnibarPosition(settingsDataStore.omnibarPosition) } return "$trackersText$quantityString" } @@ -308,6 +313,7 @@ sealed class OnboardingDaxDialogCta( class DaxFireButtonCta( override val onboardingStore: OnboardingStore, override val appInstallStore: AppInstallStore, + val settingsDataStore: SettingsDataStore, ) : OnboardingDaxDialogCta( CtaId.DAX_FIRE_BUTTON, R.string.onboardingFireButtonDaxDialogDescription, @@ -326,7 +332,7 @@ sealed class OnboardingDaxDialogCta( ) { val context = binding.root.context val daxDialog = binding.includeOnboardingDaxDialog - val daxText = description?.let { context.getString(it) }.orEmpty() + val daxText = description?.let { context.getString(it) }?.getStringForOmnibarPosition(settingsDataStore.omnibarPosition).orEmpty() daxDialog.primaryCta.gone() daxDialog.dialogTextCta.text = "" @@ -395,6 +401,7 @@ sealed class OnboardingDaxDialogCta( class DaxEndCta( override val onboardingStore: OnboardingStore, override val appInstallStore: AppInstallStore, + val settingsDataStore: SettingsDataStore, ) : OnboardingDaxDialogCta( CtaId.DAX_END, R.string.onboardingEndDaxDialogDescription, @@ -416,7 +423,7 @@ sealed class OnboardingDaxDialogCta( val context = binding.root.context setOnboardingDialogView( daxTitle = context.getString(R.string.onboardingEndDaxDialogTitle), - daxText = description?.let { context.getString(it) }.orEmpty(), + daxText = description?.let { context.getString(it) }?.getStringForOmnibarPosition(settingsDataStore.omnibarPosition).orEmpty(), buttonText = buttonText?.let { context.getString(it) }, binding = binding, ) @@ -692,3 +699,10 @@ fun DaxCta.canSendShownPixel(): Boolean { val param = onboardingStore.onboardingDialogJourney?.split("-").orEmpty().toMutableList() return !(param.isNotEmpty() && param.any { it.split(":").firstOrNull().orEmpty() == ctaPixelParam }) } + +fun String.getStringForOmnibarPosition(position: OmnibarPosition): String { + return when (position) { + OmnibarPosition.TOP -> this + OmnibarPosition.BOTTOM -> replace("☝", "\uD83D\uDC47") + } +} diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index a44aa5f1e79e..b44611d0a386 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -35,7 +35,7 @@ import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_SKIP_MAJOR_NETWORK_UNIQ import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.widget.ui.WidgetCapabilities import com.duckduckgo.common.utils.DispatcherProvider @@ -181,7 +181,7 @@ class CtaViewModel @Inject constructor( suspend fun getFireDialogCta(): OnboardingDaxDialogCta.DaxFireButtonCta? { if (!daxOnboardingActive() || daxDialogFireEducationShown()) return null return withContext(dispatchers.io()) { - return@withContext OnboardingDaxDialogCta.DaxFireButtonCta(onboardingStore, appInstallStore) + return@withContext OnboardingDaxDialogCta.DaxFireButtonCta(onboardingStore, appInstallStore, settingsDataStore) } } @@ -281,6 +281,7 @@ class CtaViewModel @Inject constructor( onboardingStore, appInstallStore, it.orderedTrackerBlockedEntities(), + settingsDataStore, ) } @@ -308,7 +309,7 @@ class CtaViewModel @Inject constructor( // End if (canShowDaxCtaEndOfJourney() && daxDialogFireEducationShown()) { - return OnboardingDaxDialogCta.DaxEndCta(onboardingStore, appInstallStore) + return OnboardingDaxDialogCta.DaxEndCta(onboardingStore, appInstallStore, settingsDataStore) } return null @@ -328,7 +329,7 @@ class CtaViewModel @Inject constructor( ) if (isDuckPlayerUrl) { - pixel.fire(pixel = ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE, type = UNIQUE) + pixel.fire(pixel = ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE, type = Unique()) } return isDuckPlayerUrl diff --git a/app/src/main/java/com/duckduckgo/app/downloads/DownloadsNewTabShortcutPlugin.kt b/app/src/main/java/com/duckduckgo/app/downloads/DownloadsNewTabShortcutPlugin.kt index 476b8c452c0c..1539939fb6b1 100644 --- a/app/src/main/java/com/duckduckgo/app/downloads/DownloadsNewTabShortcutPlugin.kt +++ b/app/src/main/java/com/duckduckgo/app/downloads/DownloadsNewTabShortcutPlugin.kt @@ -58,9 +58,9 @@ class DownloadsNewTabShortcutPlugin @Inject constructor( override suspend fun setUserEnabled(enabled: Boolean) { if (enabled) { - setting.self().setEnabled(Toggle.State(true)) + setting.self().setRawStoredState(Toggle.State(true)) } else { - setting.self().setEnabled(Toggle.State(false)) + setting.self().setRawStoredState(Toggle.State(false)) } } } diff --git a/app/src/main/java/com/duckduckgo/app/email/EmailInjectorJs.kt b/app/src/main/java/com/duckduckgo/app/email/EmailInjectorJs.kt deleted file mode 100644 index 1999021a1fdf..000000000000 --- a/app/src/main/java/com/duckduckgo/app/email/EmailInjectorJs.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2020 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.app.email - -import android.webkit.WebView -import androidx.annotation.UiThread -import com.duckduckgo.app.autofill.EmailProtectionJavascriptInjector -import com.duckduckgo.app.browser.DuckDuckGoUrlDetector -import com.duckduckgo.app.email.EmailJavascriptInterface.Companion.JAVASCRIPT_INTERFACE_NAME -import com.duckduckgo.autofill.api.Autofill -import com.duckduckgo.autofill.api.AutofillFeature -import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.emailprotection.EmailInjector -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject - -@ContributesBinding(AppScope::class) -class EmailInjectorJs @Inject constructor( - private val emailManager: EmailManager, - private val urlDetector: DuckDuckGoUrlDetector, - private val dispatcherProvider: DispatcherProvider, - private val autofillFeature: AutofillFeature, - private val emailProtectionJavascriptInjector: EmailProtectionJavascriptInjector, - private val autofill: Autofill, -) : EmailInjector { - - override fun addJsInterface( - webView: WebView, - onSignedInEmailProtectionPromptShown: () -> Unit, - onInContextEmailProtectionSignupPromptShown: () -> Unit, - ) { - // We always add the interface irrespectively if the feature is enabled or not - webView.addJavascriptInterface( - EmailJavascriptInterface( - emailManager, - webView, - urlDetector, - dispatcherProvider, - autofillFeature, - autofill, - onSignedInEmailProtectionPromptShown, - ), - JAVASCRIPT_INTERFACE_NAME, - ) - } - - @UiThread - override fun injectAddressInEmailField( - webView: WebView, - alias: String?, - url: String?, - ) { - url?.let { - if (isFeatureEnabled() && !autofill.isAnException(url)) { - webView.evaluateJavascript("javascript:${emailProtectionJavascriptInjector.getAliasFunctions(webView.context, alias)}", null) - } - } - } - - @UiThread - override fun notifyWebAppSignEvent( - webView: WebView, - url: String?, - ) { - url?.let { - if (isFeatureEnabled() && isDuckDuckGoUrl(url) && !emailManager.isSignedIn()) { - webView.evaluateJavascript("javascript:${emailProtectionJavascriptInjector.getSignOutFunctions(webView.context)}", null) - } - } - } - - private fun isFeatureEnabled() = autofillFeature.self().isEnabled() - - private fun isDuckDuckGoUrl(url: String?): Boolean = (url != null && urlDetector.isDuckDuckGoEmailUrl(url)) -} diff --git a/app/src/main/java/com/duckduckgo/app/email/EmailJavascriptInterface.kt b/app/src/main/java/com/duckduckgo/app/email/EmailJavascriptInterface.kt deleted file mode 100644 index b17215186201..000000000000 --- a/app/src/main/java/com/duckduckgo/app/email/EmailJavascriptInterface.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2020 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.app.email - -import android.webkit.JavascriptInterface -import android.webkit.WebView -import com.duckduckgo.app.browser.DuckDuckGoUrlDetector -import com.duckduckgo.autofill.api.Autofill -import com.duckduckgo.autofill.api.AutofillFeature -import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.common.utils.DispatcherProvider -import kotlinx.coroutines.runBlocking -import org.json.JSONObject - -class EmailJavascriptInterface( - private val emailManager: EmailManager, - private val webView: WebView, - private val urlDetector: DuckDuckGoUrlDetector, - private val dispatcherProvider: DispatcherProvider, - private val autofillFeature: AutofillFeature, - private val autofill: Autofill, - private val showNativeTooltip: () -> Unit, -) { - - private fun getUrl(): String? { - return runBlocking(dispatcherProvider.main()) { - webView.url - } - } - - private fun isUrlFromDuckDuckGoEmail(): Boolean { - val url = getUrl() - return (url != null && urlDetector.isDuckDuckGoEmailUrl(url)) - } - - private fun isAutofillEnabled() = autofillFeature.self().isEnabled() - - @JavascriptInterface - fun isSignedIn(): String { - return if (isUrlFromDuckDuckGoEmail()) { - emailManager.isSignedIn().toString() - } else { - "" - } - } - - @JavascriptInterface - fun getUserData(): String { - return if (isUrlFromDuckDuckGoEmail()) { - emailManager.getUserData() - } else { - "" - } - } - - @JavascriptInterface - fun getDeviceCapabilities(): String { - return if (isUrlFromDuckDuckGoEmail()) { - JSONObject().apply { - put("addUserData", true) - put("getUserData", true) - put("removeUserData", true) - }.toString() - } else { - "" - } - } - - @JavascriptInterface - fun storeCredentials( - token: String, - username: String, - cohort: String, - ) { - if (isUrlFromDuckDuckGoEmail()) { - emailManager.storeCredentials(token, username, cohort) - } - } - - @JavascriptInterface - fun removeCredentials() { - if (isUrlFromDuckDuckGoEmail()) { - emailManager.signOut() - } - } - - @JavascriptInterface - fun showTooltip() { - getUrl()?.let { - if (isAutofillEnabled() && !autofill.isAnException(it)) { - showNativeTooltip() - } - } - } - - companion object { - const val JAVASCRIPT_INTERFACE_NAME = "EmailInterface" - } -} diff --git a/app/src/main/java/com/duckduckgo/app/global/model/SiteFactoryImpl.kt b/app/src/main/java/com/duckduckgo/app/global/model/SiteFactoryImpl.kt index d967c6149977..9c7725a149fe 100644 --- a/app/src/main/java/com/duckduckgo/app/global/model/SiteFactoryImpl.kt +++ b/app/src/main/java/com/duckduckgo/app/global/model/SiteFactoryImpl.kt @@ -27,6 +27,7 @@ import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.privacy.config.api.ContentBlocking import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn @@ -43,6 +44,7 @@ class SiteFactoryImpl @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, + private val duckPlayer: DuckPlayer, ) : SiteFactory { private val siteCache = LruCache(1) @@ -74,6 +76,7 @@ class SiteFactoryImpl @Inject constructor( appCoroutineScope, dispatcherProvider, RealBrokenSiteContext(duckDuckGoUrlDetector), + duckPlayer, ).also { siteCache.put(cacheKey, it) } diff --git a/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt b/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt index c20d347385fb..bbd3323bfdc9 100644 --- a/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt +++ b/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt @@ -34,6 +34,7 @@ import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.browser.api.brokensite.BrokenSiteContext import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.isHttps +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.privacy.config.api.ContentBlocking import java.util.concurrent.CopyOnWriteArrayList import kotlinx.coroutines.CoroutineScope @@ -51,6 +52,7 @@ class SiteMonitor( appCoroutineScope: CoroutineScope, dispatcherProvider: DispatcherProvider, brokenSiteContext: BrokenSiteContext, + private val duckPlayer: DuckPlayer, ) : Site { override var url: String = url @@ -163,6 +165,7 @@ class SiteMonitor( override fun privacyProtection(): PrivacyShield { userAllowList = domain?.let { isAllowListed(it) } ?: false + if (duckPlayer.isDuckPlayerUri(url)) return UNKNOWN if (userAllowList || !isHttps) return UNPROTECTED if (!fullSiteDetailsAvailable) { diff --git a/app/src/main/java/com/duckduckgo/app/global/shortcut/AppShortcutCreator.kt b/app/src/main/java/com/duckduckgo/app/global/shortcut/AppShortcutCreator.kt index bba796ad7ace..a8dfdee7068f 100644 --- a/app/src/main/java/com/duckduckgo/app/global/shortcut/AppShortcutCreator.kt +++ b/app/src/main/java/com/duckduckgo/app/global/shortcut/AppShortcutCreator.kt @@ -29,6 +29,10 @@ import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.app.settings.SettingsActivity +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.appbuildconfig.api.isInternalBuild +import com.duckduckgo.common.ui.themepreview.ui.AppComponentsActivity import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.savedsites.impl.bookmarks.BookmarksActivity @@ -68,6 +72,7 @@ class AppShortcutCreatorLifecycleObserver( class AppShortcutCreator @Inject constructor( private val context: Context, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val appBuildConfig: AppBuildConfig, private val dispatchers: DispatcherProvider, ) { @@ -79,6 +84,10 @@ class AppShortcutCreator @Inject constructor( shortcutList.add(buildClearDataShortcut(context)) shortcutList.add(buildBookmarksShortcut(context)) + if (appBuildConfig.isInternalBuild()) { + shortcutList.add(buildAndroidDesignSystemShortcut(context)) + } + val shortcutManager = context.getSystemService(ShortcutManager::class.java) kotlin.runCatching { shortcutManager.dynamicShortcuts = shortcutList } } @@ -125,9 +134,27 @@ class AppShortcutCreator @Inject constructor( .build().toShortcutInfo() } + private fun buildAndroidDesignSystemShortcut(context: Context): ShortcutInfo { + val browserActivity = BrowserActivity.intent(context).also { it.action = Intent.ACTION_VIEW } + val settingsActivity = SettingsActivity.intent(context).also { it.action = Intent.ACTION_VIEW } + val adsActivity = AppComponentsActivity.intent(context).also { it.action = Intent.ACTION_VIEW } + + val stackBuilder = TaskStackBuilder.create(context) + .addNextIntent(browserActivity) + .addNextIntent(settingsActivity) + .addNextIntent(adsActivity) + + return ShortcutInfoCompat.Builder(context, SHORTCUT_ID_DESIGN_SYSTEM_DEMO) + .setShortLabel(context.getString(com.duckduckgo.mobile.android.R.string.ads_demo_activity_title)) + .setIcon(IconCompat.createWithResource(context, com.duckduckgo.mobile.android.R.drawable.ic_dax_icon)) + .setIntents(stackBuilder.intents) + .build().toShortcutInfo() + } + companion object { private const val SHORTCUT_ID_CLEAR_DATA = "clearData" private const val SHORTCUT_ID_NEW_TAB = "newTab" private const val SHORTCUT_ID_SHOW_BOOKMARKS = "showBookmarks" + private const val SHORTCUT_ID_DESIGN_SYSTEM_DEMO = "designSystemDemo" } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt index bf3baf92d6ea..1c2b9e723094 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt @@ -40,7 +40,7 @@ import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_COMPARISON_CHART_SHO import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_INTRO_SHOWN_UNIQUE import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.di.scopes.FragmentScope import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -140,9 +140,9 @@ class WelcomePageViewModel @Inject constructor( fun onDialogShown(onboardingDialogType: PreOnboardingDialogType) { when (onboardingDialogType) { - INITIAL -> pixel.fire(PREONBOARDING_INTRO_SHOWN_UNIQUE, type = UNIQUE) - COMPARISON_CHART -> pixel.fire(PREONBOARDING_COMPARISON_CHART_SHOWN_UNIQUE, type = UNIQUE) - CELEBRATION -> pixel.fire(PREONBOARDING_AFFIRMATION_SHOWN_UNIQUE, type = UNIQUE) + INITIAL -> pixel.fire(PREONBOARDING_INTRO_SHOWN_UNIQUE, type = Unique()) + COMPARISON_CHART -> pixel.fire(PREONBOARDING_COMPARISON_CHART_SHOWN_UNIQUE, type = Unique()) + CELEBRATION -> pixel.fire(PREONBOARDING_AFFIRMATION_SHOWN_UNIQUE, type = Unique()) } } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/extendedonboarding/ExtendedOnboardingFeatureToggles.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/extendedonboarding/ExtendedOnboardingFeatureToggles.kt index 75afe4976196..d473fa95efca 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/extendedonboarding/ExtendedOnboardingFeatureToggles.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/extendedonboarding/ExtendedOnboardingFeatureToggles.kt @@ -30,9 +30,6 @@ interface ExtendedOnboardingFeatureToggles { @Toggle.DefaultValue(false) fun self(): Toggle - @Toggle.DefaultValue(true) - fun aestheticUpdates(): Toggle - @Toggle.DefaultValue(false) @Experiment fun noBrowserCtas(): Toggle diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index d43daef4b3d5..737422bcc7bf 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -123,6 +123,9 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { SETTINGS_PERMISSIONS_PRESSED("ms_permissions_setting_pressed"), SETTINGS_APPEARANCE_PRESSED("ms_appearance_setting_pressed"), SETTINGS_APP_ICON_PRESSED("ms_app_icon_setting_pressed"), + SETTINGS_ADDRESS_BAR_POSITION_PRESSED("ms_address_bar_position_setting_pressed"), + SETTINGS_ADDRESS_BAR_POSITION_SELECTED_TOP("ms_address_bar_position_setting_selected_top"), + SETTINGS_ADDRESS_BAR_POSITION_SELECTED_BOTTOM("ms_address_bar_position_setting_selected_bottom"), SETTINGS_MAC_APP_PRESSED("ms_mac_app_setting_pressed"), SETTINGS_WINDOWS_APP_PRESSED("ms_windows_app_setting_pressed"), SETTINGS_EMAIL_PROTECTION_PRESSED("ms_email_protection_setting_pressed"), @@ -215,6 +218,7 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { AUTOCOMPLETE_DISPLAYED_LOCAL_HISTORY_SEARCH("m_autocomplete_displayed_history_search"), AUTOCOMPLETE_RESULT_DELETED("m_autocomplete_result_deleted"), + AUTOCOMPLETE_RESULT_DELETED_DAILY("m_autocomplete_result_deleted_daily"), SERP_REQUERY("rq_%s"), @@ -346,7 +350,6 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { TAB_MANAGER_REARRANGE_TABS_DAILY("m_tab_manager_rearrange_tabs_daily"), TAB_MANAGER_GRID_VIEW_BUTTON_CLICKED("m_tab_manager_grid_view_button_clicked"), TAB_MANAGER_LIST_VIEW_BUTTON_CLICKED("m_tab_manager_list_view_button_clicked"), - TAB_MANAGER_VIEW_MODE_TOGGLED_DAILY("m_tab_manager_view_mode_toggled_daily"), DUCK_PLAYER_SETTING_ALWAYS_OVERLAY_YOUTUBE("duck-player_setting_always_overlay_youtube"), DUCK_PLAYER_SETTING_ALWAYS_SERP("duck-player_setting_always_overlay_serp"), diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index b8ae6427a0aa..491d4f5772ab 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -43,7 +43,7 @@ import com.duckduckgo.app.pixels.AppPixelName.PRIVACY_PRO_IS_ENABLED_AND_ELIGIBL import com.duckduckgo.app.privatesearch.PrivateSearchScreenNoParams import com.duckduckgo.app.settings.SettingsViewModel.Command import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.webtrackingprotection.WebTrackingProtectionScreenNoParams import com.duckduckgo.app.widget.AddWidgetLauncher import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -227,7 +227,7 @@ class SettingsActivity : DuckDuckGoActivity() { private fun updatePrivacyPro(isPrivacyProEnabled: Boolean) { if (isPrivacyProEnabled) { - pixel.fire(PRIVACY_PRO_IS_ENABLED_AND_ELIGIBLE, type = DAILY) + pixel.fire(PRIVACY_PRO_IS_ENABLED_AND_ELIGIBLE, type = Daily()) viewsPro.show() } else { viewsPro.gone() diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsNewTabShortcutPlugin.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsNewTabShortcutPlugin.kt index 339905680c12..7a38505986d5 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsNewTabShortcutPlugin.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsNewTabShortcutPlugin.kt @@ -58,9 +58,9 @@ class SettingsNewTabShortcutPlugin @Inject constructor( override suspend fun setUserEnabled(enabled: Boolean) { if (enabled) { - setting.self().setEnabled(Toggle.State(true)) + setting.self().setRawStoredState(Toggle.State(true)) } else { - setting.self().setEnabled(Toggle.State(false)) + setting.self().setRawStoredState(Toggle.State(false)) } } } diff --git a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt index fd06ac011e08..f643381b048b 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.settings.db import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ASK_EVERY_TIME import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.NEVER @@ -52,6 +53,7 @@ interface SettingsDataStore { var appLinksEnabled: Boolean var showAppLinksPrompt: Boolean var showAutomaticFireproofDialog: Boolean + var omnibarPosition: OmnibarPosition /** * This will be checked upon app startup and used to decide whether it should perform a clear or not. @@ -176,6 +178,10 @@ class SettingsSharedPreferences @Inject constructor( get() = preferences.getBoolean(SHOW_AUTOMATIC_FIREPROOF_DIALOG, true) set(enabled) = preferences.edit { putBoolean(SHOW_AUTOMATIC_FIREPROOF_DIALOG, enabled) } + override var omnibarPosition: OmnibarPosition + get() = OmnibarPosition.valueOf(preferences.getString(KEY_OMNIBAR_POSITION, OmnibarPosition.TOP.name) ?: OmnibarPosition.TOP.name) + set(value) = preferences.edit { putString(KEY_OMNIBAR_POSITION, value.name) } + override fun hasBackgroundTimestampRecorded(): Boolean = preferences.contains(KEY_APP_BACKGROUNDED_TIMESTAMP) override fun clearAppBackgroundTimestamp() = preferences.edit { remove(KEY_APP_BACKGROUNDED_TIMESTAMP) } @@ -248,6 +254,7 @@ class SettingsSharedPreferences @Inject constructor( const val SHOW_AUTOMATIC_FIREPROOF_DIALOG = "SHOW_AUTOMATIC_FIREPROOF_DIALOG" const val KEY_NOTIFY_ME_IN_DOWNLOADS_DISMISSED = "KEY_NOTIFY_ME_IN_DOWNLOADS_DISMISSED" const val KEY_EXPERIMENTAL_SITE_DARK_MODE = "KEY_EXPERIMENTAL_SITE_DARK_MODE" + const val KEY_OMNIBAR_POSITION = "KEY_OMNIBAR_POSITION" } private class FireAnimationPrefsMapper { diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt index 5f71cfd22746..fe018674b0fb 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -52,6 +52,7 @@ import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel import com.duckduckgo.app.global.view.TextChangedWatcher import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privatesearch.PrivateSearchScreenNoParams +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.* import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator @@ -103,6 +104,9 @@ class SystemSearchActivity : DuckDuckGoActivity() { @Inject lateinit var globalActivityStarter: GlobalActivityStarter + @Inject + lateinit var settingsDataStore: SettingsDataStore + private val viewModel: SystemSearchViewModel by bindViewModel() private val binding: ActivitySystemSearchBinding by viewBinding() private lateinit var quickAccessItemsBinding: IncludeQuickAccessItemsBinding @@ -236,6 +240,7 @@ class SystemSearchActivity : DuckDuckGoActivity() { autoCompleteLongPressClickListener = { viewModel.userLongPressedAutocomplete(it) }, + omnibarPosition = settingsDataStore.omnibarPosition, ) binding.autocompleteSuggestions.adapter = autocompleteSuggestionsAdapter diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt index 67df33fe8354..4fa9f01abee0 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt @@ -35,7 +35,7 @@ import com.duckduckgo.app.onboarding.store.isNewUser import com.duckduckgo.app.pixels.AppPixelName.* import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.UpdateVoiceSearch import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.SingleLiveEvent @@ -316,6 +316,8 @@ class SystemSearchViewModel @Inject constructor( fun onRemoveSearchSuggestionConfirmed(suggestion: AutoCompleteSuggestion, omnibarText: String) { appCoroutineScope.launch(dispatchers.io()) { pixel.fire(AUTOCOMPLETE_RESULT_DELETED) + pixel.fire(AUTOCOMPLETE_RESULT_DELETED_DAILY, type = Daily()) + when (suggestion) { is AutoCompleteHistorySuggestion -> { history.removeHistoryEntryByUrl(suggestion.url) @@ -403,7 +405,7 @@ class SystemSearchViewModel @Inject constructor( override fun onFavoriteAdded() { pixel.fire(SavedSitesPixelName.EDIT_BOOKMARK_ADD_FAVORITE_TOGGLED) - pixel.fire(SavedSitesPixelName.EDIT_BOOKMARK_ADD_FAVORITE_TOGGLED_DAILY, type = DAILY) + pixel.fire(SavedSitesPixelName.EDIT_BOOKMARK_ADD_FAVORITE_TOGGLED_DAILY, type = Daily()) } override fun onFavoriteRemoved() { diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt index fb5055b1e943..88b1e74ef9ac 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt @@ -462,7 +462,6 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine override fun finish() { clearObserversEarlyToStopViewUpdates() super.finish() - overridePendingTransition(R.anim.slide_from_bottom, R.anim.tab_anim_fade_out) } override fun onDestroy() { diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt index 9071103b364e..6121400e4375 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt @@ -25,7 +25,7 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType.GRID @@ -152,14 +152,12 @@ class TabSwitcherViewModel @Inject constructor( fun onTabDraggingStarted() { viewModelScope.launch(dispatcherProvider.io()) { val params = mapOf("userState" to tabRepository.tabSwitcherData.first().userState.name) - pixel.fire(AppPixelName.TAB_MANAGER_REARRANGE_TABS_DAILY, parameters = params, encodedParameters = emptyMap(), DAILY) + pixel.fire(AppPixelName.TAB_MANAGER_REARRANGE_TABS_DAILY, parameters = params, encodedParameters = emptyMap(), Daily()) } } fun onLayoutTypeToggled() { viewModelScope.launch(dispatcherProvider.io()) { - pixel.fire(AppPixelName.TAB_MANAGER_VIEW_MODE_TOGGLED_DAILY, emptyMap(), emptyMap(), DAILY) - val newLayoutType = if (layoutType.value == GRID) { pixel.fire(AppPixelName.TAB_MANAGER_LIST_VIEW_BUTTON_CLICKED) LIST diff --git a/app/src/main/res/anim/slide_to_bottom.xml b/app/src/main/res/anim/slide_to_bottom.xml deleted file mode 100644 index 4ad750321c4c..000000000000 --- a/app/src/main/res/anim/slide_to_bottom.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_autocomplete_24dp.xml b/app/src/main/res/drawable/ic_autocomplete_20dp.xml similarity index 100% rename from app/src/main/res/drawable/ic_autocomplete_24dp.xml rename to app/src/main/res/drawable/ic_autocomplete_20dp.xml diff --git a/app/src/main/res/drawable/ic_autocomplete_down_20dp.xml b/app/src/main/res/drawable/ic_autocomplete_down_20dp.xml new file mode 100644 index 000000000000..2edaf5453ce0 --- /dev/null +++ b/app/src/main/res/drawable/ic_autocomplete_down_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_appearance.xml b/app/src/main/res/layout/activity_appearance.xml index e17a9b80cb75..d0f9a4dc94a2 100644 --- a/app/src/main/res/layout/activity_appearance.xml +++ b/app/src/main/res/layout/activity_appearance.xml @@ -87,6 +87,20 @@ + + + + diff --git a/app/src/main/res/layout/activity_browser.xml b/app/src/main/res/layout/activity_browser.xml index 7e30ddc6ebae..49022e924971 100644 --- a/app/src/main/res/layout/activity_browser.xml +++ b/app/src/main/res/layout/activity_browser.xml @@ -16,13 +16,18 @@ ~ limitations under the License. --> - - + - + + + diff --git a/app/src/main/res/layout/fragment_browser_tab.xml b/app/src/main/res/layout/fragment_browser_tab.xml index 4820dc917802..fb00b6add984 100644 --- a/app/src/main/res/layout/fragment_browser_tab.xml +++ b/app/src/main/res/layout/fragment_browser_tab.xml @@ -13,7 +13,6 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + android:layout_height="?attr/actionBarSize" + app:omnibarPosition="top" /> + android:visibility="gone" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/include_custom_tab_toolbar.xml b/app/src/main/res/layout/include_custom_tab_toolbar.xml index 2435500ba219..c88e10748661 100644 --- a/app/src/main/res/layout/include_custom_tab_toolbar.xml +++ b/app/src/main/res/layout/include_custom_tab_toolbar.xml @@ -45,7 +45,19 @@ android:importantForAccessibility="no" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="@id/customTabCloseIcon" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent"/> + + - + android:layout_height="?attr/actionBarSize" + android:background="?daxColorSurface" + android:theme="@style/Widget.DuckDuckGo.ToolbarTheme"> - + - + android:gravity="center" + android:importantForAccessibility="no" + android:paddingVertical="6dp" + android:paddingHorizontal="10dp" + android:src="@drawable/ic_find_search_20_a05" /> - + - + - + - + android:layout_height="wrap_content" + android:layout_gravity="center" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/browserPopupMenu" + android:src="@drawable/ic_fire" /> + - - + - + - - - + android:layout_height="wrap_content" + android:layout_gravity="center" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/browserPopupMenu" + android:src="@drawable/ic_menu_vertical_24" /> - - - \ No newline at end of file + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_bookmark_suggestion.xml b/app/src/main/res/layout/item_autocomplete_bookmark_suggestion.xml index 26bbbaf906b1..ca16a97c84c0 100644 --- a/app/src/main/res/layout/item_autocomplete_bookmark_suggestion.xml +++ b/app/src/main/res/layout/item_autocomplete_bookmark_suggestion.xml @@ -76,7 +76,7 @@ android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/editQueryBeforeSubmitting" android:padding="3dp" - android:src="@drawable/ic_autocomplete_24dp" + android:src="@drawable/ic_autocomplete_20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"/> diff --git a/app/src/main/res/layout/item_autocomplete_default.xml b/app/src/main/res/layout/item_autocomplete_default.xml index e212c8299ccb..478d3ac282df 100644 --- a/app/src/main/res/layout/item_autocomplete_default.xml +++ b/app/src/main/res/layout/item_autocomplete_default.xml @@ -60,7 +60,7 @@ android:layout_marginStart="6dp" android:contentDescription="@string/editQueryBeforeSubmitting" android:padding="3dp" - android:src="@drawable/ic_autocomplete_24dp" + android:src="@drawable/ic_autocomplete_20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" diff --git a/app/src/main/res/layout/item_autocomplete_history_suggestion.xml b/app/src/main/res/layout/item_autocomplete_history_suggestion.xml index a280a9655631..d801e97d9e61 100644 --- a/app/src/main/res/layout/item_autocomplete_history_suggestion.xml +++ b/app/src/main/res/layout/item_autocomplete_history_suggestion.xml @@ -78,7 +78,7 @@ android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/editQueryBeforeSubmitting" android:padding="3dp" - android:src="@drawable/ic_autocomplete_24dp" + android:src="@drawable/ic_autocomplete_20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"/> diff --git a/app/src/main/res/layout/item_autocomplete_search_suggestion.xml b/app/src/main/res/layout/item_autocomplete_search_suggestion.xml index d626eecde7a9..4da36a46f166 100644 --- a/app/src/main/res/layout/item_autocomplete_search_suggestion.xml +++ b/app/src/main/res/layout/item_autocomplete_search_suggestion.xml @@ -61,7 +61,7 @@ android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/editQueryBeforeSubmitting" android:padding="3dp" - android:src="@drawable/ic_autocomplete_24dp" + android:src="@drawable/ic_autocomplete_20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"/> diff --git a/app/src/main/res/layout/popup_window_browser_menu_bottom.xml b/app/src/main/res/layout/popup_window_browser_menu_bottom.xml new file mode 100644 index 000000000000..5b0ff818e2fd --- /dev/null +++ b/app/src/main/res/layout/popup_window_browser_menu_bottom.xml @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_legacy_omnibar.xml b/app/src/main/res/layout/view_legacy_omnibar.xml index 96861f4874b8..28f0854bf0ec 100644 --- a/app/src/main/res/layout/view_legacy_omnibar.xml +++ b/app/src/main/res/layout/view_legacy_omnibar.xml @@ -15,303 +15,301 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + android:layout_height="wrap_content" + android:theme="@style/Widget.DuckDuckGo.ToolbarTheme" + tools:parentTag="androidx.coordinatorlayout.widget.CoordinatorLayout"> + + + + - - - - - + + + - + + + android:importantForAccessibility="no" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="@id/omniBarContainer" + app:layout_constraintTop_toTopOf="parent" /> - - - + + - - - - - - - - - - - - - - - - - - + android:padding="@dimen/keyline_1" + android:visibility="gone" + app:srcCompat="@drawable/ic_ddg_logo" /> - - - - - - - + app:srcCompat="@drawable/ic_duckplayer" /> + android:layout_height="match_parent" + android:gravity="center" + android:importantForAccessibility="no" + android:padding="6dp" + android:src="@drawable/ic_find_search_20_a05" /> - + - + + - - - + + + + + + + + + + - - - - - + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/spacer" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_microphone_24" + tools:visibility="visible" /> + + + android:layout_marginEnd="@dimen/keyline_1" + android:src="@drawable/ic_close_24" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:visibility="visible" /> - + - - - + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_legacy_omnibar_bottom.xml b/app/src/main/res/layout/view_legacy_omnibar_bottom.xml new file mode 100644 index 000000000000..9ce2ced3ff01 --- /dev/null +++ b/app/src/main/res/layout/view_legacy_omnibar_bottom.xml @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/raw/inject_alias.js b/app/src/main/res/raw/inject_alias.js deleted file mode 100644 index 4b938bc50230..000000000000 --- a/app/src/main/res/raw/inject_alias.js +++ /dev/null @@ -1,21 +0,0 @@ -// -// DuckDuckGo -// -// Copyright © 2020 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -(function() { - window.postMessage({type: 'getAliasResponse', alias: '%s'}, window.origin); -})(); \ No newline at end of file diff --git a/app/src/main/res/raw/signout_autofill.js b/app/src/main/res/raw/signout_autofill.js deleted file mode 100644 index 635651815639..000000000000 --- a/app/src/main/res/raw/signout_autofill.js +++ /dev/null @@ -1,21 +0,0 @@ -// -// DuckDuckGo -// -// Copyright © 2020 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -(function() { - window.postMessage({ emailProtectionSignedOut: true }, window.origin); -})(); \ No newline at end of file diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 8161436a27c5..838fbf340889 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -804,4 +804,9 @@ Изпробвайте го Пропускане + + Адресна лента + Най-горе + Отдолу + \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 422bb50038d8..170c3250054a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -808,4 +808,9 @@ Vyzkoušejte ho Přeskočit + + Adresní řádek + Nahoru + Dole + \ No newline at end of file diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index a3bf45069a0b..f79dd945be36 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -804,4 +804,9 @@ Prøv det Spring over + + Adresselinje + Top + Nederst + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 894a32e87eaa..9818af03cb78 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -804,4 +804,9 @@ Ausprobieren Überspringen + + Adresszeile + Nach oben + Unten + \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 5fded24fe51f..e61a211df4fe 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -804,4 +804,9 @@ Δοκιμάστε το Παράλειψη + + Γραμμή διευθύνσεων + Κορυφή + Κάτω μέρος + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3304f5f9d577..a3e1bb7d820c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -804,4 +804,9 @@ Pruébalo Omitir + + Barra de direcciones + Arriba + Inferior + \ No newline at end of file diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 02787d4afe39..ce8364d055ad 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -804,4 +804,9 @@ Proovi seda Jäta vahele + + Aadressiriba + Tipp + All + \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 9f5156b7101f..389aaa9eae94 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -804,4 +804,9 @@ Kokeile sitä Ohita + + Osoitekenttä + Ylös + Alareuna + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a1dda1311df5..38e4e4322453 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -804,4 +804,9 @@ Essayez Ignorer + + Barre d\'adresse + Haut de page + En bas + \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 581fb976678c..92c0d0ac7eb2 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -808,4 +808,9 @@ Isprobaj ga Preskoči + + Adresna traka + Vrh + Dno + \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 9356d23c557b..8e4b791ba1a4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -804,4 +804,9 @@ Kipróbálom Kihagyás + + Címsor + Fel + Alul + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f174a40d6662..4d13c4bac549 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -804,4 +804,9 @@ Provalo Salta + + Barra degli indirizzi + Inizio + Parte inferiore + \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index aee51daa759d..a35ba8ccd515 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -808,4 +808,9 @@ Išbandykite Praleisti + + Adreso juosta + Viršus + Apačia + \ No newline at end of file diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index aa511c0b9385..0ab5b8f08729 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -806,4 +806,9 @@ Izmēģini Izlaist + + Adreses josla + Populārākie + Apakšā + \ No newline at end of file diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 98ad6c994096..bd06e8aaf17f 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -804,4 +804,9 @@ Prøv det Hopp over + + Adressefelt + Topp + Nederst + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 58ab7c7d4620..b37d37c24752 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -804,4 +804,9 @@ Probeer het zelf Overslaan + + Adresbalk + Boven + Onderkant + \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 96ae2a15ab5e..b6f63f2714b4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -808,4 +808,9 @@ Wypróbuj Pomiń + + Pasek adresu + Do góry + Dół + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1d97c164e0fe..1dd39739c696 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -804,4 +804,9 @@ Experimenta-o Ignorar + + Barra de endereço + Topo + Parte inferior + \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ba2788d7b2bb..047cc4541618 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -806,4 +806,9 @@ Încearcă-l Ignorare + + Bara de adrese + Sus + Partea de jos + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 14a438cd1928..211520c70a8d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -808,4 +808,9 @@ Попробовать Пропустить + + Адресная строка + Вверх + Внизу + \ No newline at end of file diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 42487000ef4e..c1747c42a6af 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -808,4 +808,9 @@ Vyskúšajte to Preskočiť + + Riadok adresy + Hore + Spodná časť + \ No newline at end of file diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index a556e128a75b..d98e9b87ec15 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -808,4 +808,9 @@ Preizkusite Preskoči + + Naslovna vrstica + Vrh + Spodaj + \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index ced34339ff33..79e322e47631 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -804,4 +804,9 @@ Prova Hoppa över + + Adressfält + Topp + Botten + \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0d1db20540bc..6cdd0cf53059 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -804,4 +804,9 @@ Deneyin Atla + + Adres Çubuğu + Başa dön + Alt + \ No newline at end of file diff --git a/app/src/main/res/anim/tab_anim_fade_in.xml b/app/src/main/res/values/attrs-omnibar-view.xml similarity index 62% rename from app/src/main/res/anim/tab_anim_fade_in.xml rename to app/src/main/res/values/attrs-omnibar-view.xml index 02577c907761..877b35a85d7e 100644 --- a/app/src/main/res/anim/tab_anim_fade_in.xml +++ b/app/src/main/res/values/attrs-omnibar-view.xml @@ -1,5 +1,5 @@ + + + + + + + - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 6a1e7fd62151..727181f5eb9f 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -62,4 +62,5 @@ DuckDuckGo Blocked in Indonesia The government may be blocking access to duckduckgo.com on this network provider, which could affect this app\'s functionality. Other providers may not be affected. Okay + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a20c026f17f1..29f751867945 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -803,4 +803,9 @@ Try it Skip + + Address Bar + Top + Bottom + \ No newline at end of file diff --git a/app/src/play/java/com/duckduckgo/referral/AppReferrerInstallPixelSender.kt b/app/src/play/java/com/duckduckgo/referral/AppReferrerInstallPixelSender.kt index 912ac94c1c09..2f8604593a0f 100644 --- a/app/src/play/java/com/duckduckgo/referral/AppReferrerInstallPixelSender.kt +++ b/app/src/play/java/com/duckduckgo/referral/AppReferrerInstallPixelSender.kt @@ -70,7 +70,7 @@ class AppReferrerInstallPixelSender @Inject constructor( params[PIXEL_PARAM_ORIGIN] = originAttribute } - pixel.fire(pixel = AppPixelName.REFERRAL_INSTALL_UTM_CAMPAIGN, type = Pixel.PixelType.UNIQUE, parameters = params) + pixel.fire(pixel = AppPixelName.REFERRAL_INSTALL_UTM_CAMPAIGN, type = Pixel.PixelType.Unique(), parameters = params) } companion object { diff --git a/app/src/test/java/com/duckduckgo/app/Fakes.kt b/app/src/test/java/com/duckduckgo/app/Fakes.kt index 49c107ca3292..b8720c50c6a1 100644 --- a/app/src/test/java/com/duckduckgo/app/Fakes.kt +++ b/app/src/test/java/com/duckduckgo/app/Fakes.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ASK_EVERY_TIME import com.duckduckgo.app.icon.api.AppIcon @@ -108,6 +109,10 @@ class FakeSettingsDataStore : SettingsDataStore { get() = store["showAutomaticFireproofDialog"] as Boolean? ?: true set(value) { store["showAutomaticFireproofDialog"] = value } + override var omnibarPosition: OmnibarPosition + get() = OmnibarPosition.valueOf(store["omnibarPosition"] as String) + set(value) { store["omnibarPosition"] = value.name } + override var notifyMeInDownloadsDismissed: Boolean get() = store["notifyMeInDownloadsDismissed"] as Boolean? ?: false set(value) { store["notifyMeInDownloadsDismissed"] = value } diff --git a/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt index 9543541db36b..cecbe415cd75 100644 --- a/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt @@ -19,6 +19,9 @@ package com.duckduckgo.app.appearance import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test import com.duckduckgo.app.appearance.AppearanceViewModel.Command +import com.duckduckgo.app.browser.omnibar.ChangeOmnibarPositionFeature +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.icon.api.AppIcon import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.settings.clear.FireAnimation @@ -28,8 +31,13 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.ui.DuckDuckGoTheme import com.duckduckgo.common.ui.store.AppTheme import com.duckduckgo.common.ui.store.ThemingDataStore +import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -61,6 +69,11 @@ internal class AppearanceViewModelTest { @Mock private lateinit var mockAppTheme: AppTheme + @Mock + private lateinit var loadingBarExperimentManager: LoadingBarExperimentManager + + private val featureFlag = FakeFeatureToggleFactory.create(ChangeOmnibarPositionFeature::class.java) + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -68,12 +81,18 @@ internal class AppearanceViewModelTest { whenever(mockAppSettingsDataStore.appIcon).thenReturn(AppIcon.DEFAULT) whenever(mockThemeSettingsDataStore.theme).thenReturn(DuckDuckGoTheme.SYSTEM_DEFAULT) whenever(mockAppSettingsDataStore.selectedFireAnimation).thenReturn(FireAnimation.HeroFire) + whenever(mockAppSettingsDataStore.omnibarPosition).thenReturn(TOP) + whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) + + featureFlag.self().setRawStoredState(Toggle.State(enable = true)) testee = AppearanceViewModel( mockThemeSettingsDataStore, mockAppSettingsDataStore, mockPixel, coroutineTestRule.testDispatcherProvider, + featureFlag, + loadingBarExperimentManager, ) } @@ -182,6 +201,60 @@ internal class AppearanceViewModelTest { verify(mockPixel).fire(AppPixelName.FORCE_DARK_MODE_DISABLED) } + @Test + fun whenOmnibarPositionSettingPressed() = runTest { + testee.commands().test { + testee.userRequestedToChangeAddressBarPosition() + assertEquals(Command.LaunchOmnibarPositionSettings(TOP), awaitItem()) + verify(mockPixel).fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_PRESSED) + } + } + + @Test + fun whenOmnibarPositionUpdatedToBottom() = runTest { + testee.onOmnibarPositionUpdated(BOTTOM) + verify(mockAppSettingsDataStore).omnibarPosition = BOTTOM + verify(mockPixel).fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_BOTTOM) + } + + @Test + fun whenOmnibarPositionUpdatedToTop() = runTest { + testee.onOmnibarPositionUpdated(TOP) + verify(mockAppSettingsDataStore).omnibarPosition = TOP + verify(mockPixel).fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_TOP) + } + + @Test + fun whenLoadingBarExperimentDisabledAndFeatureFlagEnabledTheOmnibarFeatureIsEnabled() = runTest { + whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) + + testee.viewState().test { + val value = awaitItem() + assertTrue(value.isOmnibarPositionFeatureEnabled) + } + } + + @Test + fun whenLoadingBarExperimentDisabledAndFeatureFlagDisabledTheOmnibarFeatureIsDisabled() = runTest { + whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) + featureFlag.self().setRawStoredState(Toggle.State(enable = false)) + + testee.viewState().test { + val value = awaitItem() + assertFalse(value.isOmnibarPositionFeatureEnabled) + } + } + + @Test + fun whenLoadingBarExperimentEnabledTheBottomOmnibarIsDisabled() = runTest { + whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) + + testee.viewState().test { + val value = awaitItem() + assertFalse(value.isOmnibarPositionFeatureEnabled) + } + } + @Test fun whenInitialisedAndLightThemeThenViewStateEmittedWithProperValues() = runTest { whenever(mockThemeSettingsDataStore.theme).thenReturn(DuckDuckGoTheme.LIGHT) diff --git a/app/src/test/java/com/duckduckgo/app/brokensite/BrokenSiteDataTest.kt b/app/src/test/java/com/duckduckgo/app/brokensite/BrokenSiteDataTest.kt index 1e9f747753d1..7b283c23d35a 100644 --- a/app/src/test/java/com/duckduckgo/app/brokensite/BrokenSiteDataTest.kt +++ b/app/src/test/java/com/duckduckgo/app/brokensite/BrokenSiteDataTest.kt @@ -30,11 +30,14 @@ import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU import com.duckduckgo.browser.api.brokensite.BrokenSiteOpenerContext import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.privacy.config.api.ContentBlocking import org.junit.Assert.* +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -48,8 +51,14 @@ class BrokenSiteDataTest { private val mockContentBlocking: ContentBlocking = mock() private val mockBrokenSiteContext: BrokenSiteContext = mock() + private val mockDuckPlayer: DuckPlayer = mock() private val mockBypassedSSLCertificatesRepository: BypassedSSLCertificatesRepository = mock() + @Before + fun setup() { + whenever(mockDuckPlayer.isDuckPlayerUri(any())).thenReturn(false) + } + @Test fun whenSiteIsNullThenDataIsEmptyAndUpgradedIsFalse() { val data = BrokenSiteData.fromSite(null, reportFlow = MENU) @@ -259,6 +268,7 @@ class BrokenSiteDataTest { coroutineRule.testScope, coroutineRule.testDispatcherProvider, mockBrokenSiteContext, + mockDuckPlayer, ) } 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 072e559151b3..666277f35099 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 @@ -7,7 +7,7 @@ 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 -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +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 @@ -134,7 +134,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("false", params["vpnOn"]) @@ -149,7 +149,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("true", params["vpnOn"]) @@ -166,7 +166,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("false", params["protectionsState"]) @@ -183,7 +183,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("false", params["protectionsState"]) @@ -200,7 +200,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("false", params["protectionsState"]) @@ -219,7 +219,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("false", params["protectionsState"]) @@ -236,7 +236,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("true", params["protectionsState"]) @@ -252,7 +252,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals(lastSentDay, params["lastSentDay"]) @@ -271,7 +271,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("", params["loginSite"]) @@ -289,7 +289,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertFalse(params.containsKey("loginSite")) @@ -303,7 +303,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("menu", params["reportFlow"]) @@ -317,7 +317,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("dashboard", params["reportFlow"]) @@ -331,7 +331,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertFalse("reportFlow" in params) @@ -345,7 +345,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(getBrokenSite()) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) assertEquals("test_value", paramsCaptor.firstValue["test_key"]) } @@ -364,7 +364,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("en-US", params["locale"]) @@ -377,7 +377,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("0", params["userRefreshCount"]) @@ -391,7 +391,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("5", params["userRefreshCount"]) @@ -405,7 +405,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("serp", params["openerContext"]) @@ -419,7 +419,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("external", params["openerContext"]) @@ -433,7 +433,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("navigation", params["openerContext"]) @@ -446,7 +446,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("", params["openerContext"]) @@ -459,7 +459,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("", params["jsPerformance"]) @@ -473,7 +473,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals("123.45", params["jsPerformance"]) @@ -487,7 +487,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(getBrokenSite()) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals(webViewVersion, params["wvVersion"]) @@ -498,10 +498,10 @@ class BrokenSiteSubmitterTest { val brokenSite = getBrokenSite() testee.submitBrokenSiteFeedback(brokenSite) - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), any(), any(), eq(COUNT)) + 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)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORTED), parameters = paramsCaptor.capture(), any(), eq(Count)) val params = paramsCaptor.firstValue assertEquals(brokenSite.siteUrl, params[Pixel.PixelParameter.URL]) } @@ -514,10 +514,10 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + 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)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORTED), parameters = paramsCaptor.capture(), any(), eq(Count)) assertEquals(brokenSite.siteUrl, paramsCaptor.lastValue[Pixel.PixelParameter.URL]) } @@ -529,10 +529,10 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + 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)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORTED), parameters = paramsCaptor.capture(), any(), eq(Count)) assertEquals(brokenSite.siteUrl, paramsCaptor.lastValue[Pixel.PixelParameter.URL]) } @@ -544,10 +544,10 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), parameters = paramsCaptor.capture(), any(), eq(COUNT)) + 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)) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORTED), parameters = paramsCaptor.capture(), any(), eq(Count)) assertEquals(TRACKING_URL, paramsCaptor.lastValue[Pixel.PixelParameter.URL]) } diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt index f660ae9b8b3c..feef944ee93b 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt @@ -268,7 +268,7 @@ class BrowserViewModelTest { } private fun configureSkipUrlConversionInNewTabState(enabled: Boolean) { - skipUrlConversionOnNewTabFeature.self().setEnabled(State(enable = enabled)) + skipUrlConversionOnNewTabFeature.self().setRawStoredState(State(enable = enabled)) } companion object { diff --git a/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt b/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt index 384ecd63ef98..48e314859823 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt @@ -170,7 +170,7 @@ class SpecialUrlDetectorImplTest { } @Test - fun whenWillNavigateToDuckPlayerThenDoNotReturnAppLink() = runTest { + fun whenWillNavigateToDuckPlayerThenReturnShouldLaunchDuckPlayerLink() = runTest { whenever(mockDuckPlayer.willNavigateToDuckPlayer(any())).thenReturn(true) val type = testee.determineType("https://example.com") whenever(mockPackageManager.resolveActivity(any(), eq(PackageManager.MATCH_DEFAULT_ONLY))).thenReturn(null) @@ -181,7 +181,7 @@ class SpecialUrlDetectorImplTest { ResolveInfo(), ), ) - assertTrue(type is Web) + assertTrue(type is ShouldLaunchDuckPlayerLink) } @Test diff --git a/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/DefaultBrowserObserverTest.kt b/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/DefaultBrowserObserverTest.kt index c158c8165db4..9afc7fad1017 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/DefaultBrowserObserverTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/DefaultBrowserObserverTest.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import org.junit.Before import org.junit.Test import org.mockito.Mock @@ -68,7 +68,7 @@ class DefaultBrowserObserverTest { testee.onResume(mockOwner) - verify(mockPixel, never()).fire(eq(AppPixelName.DEFAULT_BROWSER_SET), any(), any(), eq(COUNT)) + verify(mockPixel, never()).fire(eq(AppPixelName.DEFAULT_BROWSER_SET), any(), any(), eq(Count)) } @Test @@ -78,7 +78,7 @@ class DefaultBrowserObserverTest { testee.onResume(mockOwner) - verify(mockPixel, never()).fire(eq(AppPixelName.DEFAULT_BROWSER_SET), any(), any(), eq(COUNT)) + verify(mockPixel, never()).fire(eq(AppPixelName.DEFAULT_BROWSER_SET), any(), any(), eq(Count)) } @Test diff --git a/app/src/test/java/com/duckduckgo/app/browser/httperrors/RealHttpErrorPixelsTest.kt b/app/src/test/java/com/duckduckgo/app/browser/httperrors/RealHttpErrorPixelsTest.kt index f2ba20d958bf..e7eb76b45f34 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/httperrors/RealHttpErrorPixelsTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/httperrors/RealHttpErrorPixelsTest.kt @@ -21,7 +21,7 @@ import android.content.SharedPreferences import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.common.test.api.InMemorySharedPreferences import java.time.Instant import java.util.concurrent.TimeUnit @@ -74,7 +74,7 @@ class RealHttpErrorPixelsTest { pixel = eq(HttpErrorPixelName.WEBVIEW_RECEIVED_HTTP_ERROR_400_DAILY), parameters = any(), encodedParameters = any(), - type = eq(COUNT), + type = eq(Count), ) } @@ -90,7 +90,7 @@ class RealHttpErrorPixelsTest { pixel = eq(HttpErrorPixelName.WEBVIEW_RECEIVED_HTTP_ERROR_400_DAILY), parameters = eq(mapOf(HttpErrorPixelParameters.HTTP_ERROR_CODE_COUNT to "1")), encodedParameters = any(), - type = eq(COUNT), + type = eq(Count), ) } @@ -109,7 +109,7 @@ class RealHttpErrorPixelsTest { pixel = eq(HttpErrorPixelName.WEBVIEW_RECEIVED_HTTP_ERROR_400_DAILY), parameters = any(), encodedParameters = any(), - type = eq(COUNT), + type = eq(Count), ) } @@ -128,7 +128,7 @@ class RealHttpErrorPixelsTest { pixel = eq(HttpErrorPixelName.WEBVIEW_RECEIVED_HTTP_ERROR_400_DAILY), parameters = eq(mapOf(HttpErrorPixelParameters.HTTP_ERROR_CODE_COUNT to "1")), encodedParameters = any(), - type = eq(COUNT), + type = eq(Count), ) } } diff --git a/app/src/test/java/com/duckduckgo/app/browser/indonesiamessage/IndonesiaNewTabSectionViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/indonesiamessage/IndonesiaNewTabSectionViewModelTest.kt index 5ed1a503634c..bbe8501714c3 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/indonesiamessage/IndonesiaNewTabSectionViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/indonesiamessage/IndonesiaNewTabSectionViewModelTest.kt @@ -88,6 +88,6 @@ class IndonesiaNewTabSectionViewModelTest { fun whenOnMessageShownThenDailyPixelFired() = runTest { testee.onMessageShown() - verify(mockPixel).fire(AppPixelName.INDONESIA_MESSAGE_SHOWN, type = Pixel.PixelType.DAILY) + verify(mockPixel).fire(AppPixelName.INDONESIA_MESSAGE_SHOWN, type = Pixel.PixelType.Daily()) } } diff --git a/app/src/test/java/com/duckduckgo/app/cta/ui/CtaTest.kt b/app/src/test/java/com/duckduckgo/app/cta/ui/CtaTest.kt index 2d840efcbc99..06e1eebc6797 100644 --- a/app/src/test/java/com/duckduckgo/app/cta/ui/CtaTest.kt +++ b/app/src/test/java/com/duckduckgo/app/cta/ui/CtaTest.kt @@ -19,12 +19,15 @@ package com.duckduckgo.app.cta.ui import android.content.res.Resources import android.net.Uri import androidx.fragment.app.FragmentActivity +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.orderedTrackerBlockedEntities import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.TestingEntity +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.CTA_SHOWN import com.duckduckgo.app.trackerdetection.model.Entity import com.duckduckgo.app.trackerdetection.model.TrackerStatus @@ -56,6 +59,9 @@ class CtaTest { @Mock private lateinit var mockResources: Resources + @Mock + private lateinit var mockSettingsDataStore: SettingsDataStore + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -63,6 +69,7 @@ class CtaTest { whenever(mockActivity.resources).thenReturn(mockResources) whenever(mockResources.getQuantityString(any(), any())).thenReturn("withZero") whenever(mockResources.getQuantityString(any(), any(), any())).thenReturn("withMultiple") + whenever(mockSettingsDataStore.omnibarPosition).thenReturn(TOP) } @Test @@ -197,6 +204,19 @@ class CtaTest { assertTrue(testee.canSendShownPixel()) } + @Test + fun whenOmnibarPositionIsTopKeepTopPointingEmoji() { + val inputString = "
☝️ Tap the shield for more info.️]]" + assertEquals(inputString.getStringForOmnibarPosition(TOP), inputString) + } + + @Test + fun whenOmnibarPositionIsBottomUpdateHandEmojiToPointDown() { + val inputString = "
☝️ Tap the shield for more info.️]]" + val expectedString = "
\uD83D\uDC47️ Tap the shield for more info.️]]" + assertEquals(inputString.getStringForOmnibarPosition(BOTTOM), expectedString) + } + @Test fun whenCanSendPixelAndCtaNotPartOfHistoryButIsASubstringThenReturnTrue() { whenever(mockOnboardingStore.onboardingDialogJourney).thenReturn("s:0-te:0") @@ -265,7 +285,7 @@ class CtaTest { TestingEntity("Amazon", "Amazon", 9.0), ) - val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers) + val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers, mockSettingsDataStore) val value = testee.getTrackersDescription(mockActivity, trackers) assertEquals("Facebook, OtherwithMultiple", value) @@ -278,7 +298,7 @@ class CtaTest { TestingEntity("Other", "Other", 9.0), ) - val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers) + val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers, mockSettingsDataStore) val value = testee.getTrackersDescription(mockActivity, trackers) assertEquals("Facebook, OtherwithZero", value) @@ -313,6 +333,7 @@ class CtaTest { mockOnboardingStore, mockAppInstallStore, site.orderedTrackerBlockedEntities(), + mockSettingsDataStore, ) val value = testee.getTrackersDescription(mockActivity, site.orderedTrackerBlockedEntities()) @@ -347,6 +368,7 @@ class CtaTest { mockOnboardingStore, mockAppInstallStore, site.orderedTrackerBlockedEntities(), + mockSettingsDataStore, ) val value = testee.getTrackersDescription(mockActivity, site.orderedTrackerBlockedEntities()) @@ -381,6 +403,7 @@ class CtaTest { mockOnboardingStore, mockAppInstallStore, site.orderedTrackerBlockedEntities(), + mockSettingsDataStore, ) val value = testee.getTrackersDescription(mockActivity, site.orderedTrackerBlockedEntities()) @@ -395,7 +418,7 @@ class CtaTest { TestingEntity("Facebook", "Facebook", 9.0), ) - val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers) + val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers, mockSettingsDataStore) val value = testee.getTrackersDescription(mockActivity, trackers) assertEquals("FacebookwithZero", value) @@ -406,7 +429,7 @@ class CtaTest { val existingJourney = "s:0-t:1" whenever(mockOnboardingStore.onboardingDialogJourney).thenReturn(existingJourney) whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) - val testee = OnboardingDaxDialogCta.DaxFireButtonCta(mockOnboardingStore, mockAppInstallStore) + val testee = OnboardingDaxDialogCta.DaxFireButtonCta(mockOnboardingStore, mockAppInstallStore, mockSettingsDataStore) val expectedValue = "$existingJourney-${testee.ctaPixelParam}:1" val value = testee.pixelShownParameters() diff --git a/app/src/test/java/com/duckduckgo/app/email/EmailJavascriptInterfaceTest.kt b/app/src/test/java/com/duckduckgo/app/email/EmailJavascriptInterfaceTest.kt deleted file mode 100644 index d5ca5b1ea56c..000000000000 --- a/app/src/test/java/com/duckduckgo/app/email/EmailJavascriptInterfaceTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (c) 2022 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.app.email - -import android.webkit.WebView -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.app.browser.DuckDuckGoUrlDetectorImpl -import com.duckduckgo.autofill.api.Autofill -import com.duckduckgo.autofill.api.AutofillFeature -import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.feature.toggles.api.Toggle -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -@RunWith(AndroidJUnit4::class) -class EmailJavascriptInterfaceTest { - - @get:Rule - val coroutineRule = CoroutineTestRule() - - private val mockEmailManager: EmailManager = mock() - private val mockWebView: WebView = mock() - private lateinit var autofillFeature: AutofillFeature - private val mockAutofill: Autofill = mock() - lateinit var testee: EmailJavascriptInterface - private var counter = 0 - - @Before - fun setup() { - autofillFeature = com.duckduckgo.autofill.api.FakeAutofillFeature.create() - - testee = EmailJavascriptInterface( - mockEmailManager, - mockWebView, - DuckDuckGoUrlDetectorImpl(), - coroutineRule.testDispatcherProvider, - autofillFeature, - mockAutofill, - ) { counter++ } - - autofillFeature.self().setEnabled(Toggle.State(enable = true)) - whenever(mockAutofill.isAnException(any())).thenReturn(false) - } - - @Test - fun whenIsSignedInAndUrlIsDuckDuckGoEmailThenIsSignedInCalled() { - whenever(mockWebView.url).thenReturn(DUCKDUCKGO_EMAIL_URL) - - testee.isSignedIn() - - verify(mockEmailManager).isSignedIn() - } - - @Test - fun whenIsSignedInAndUrlIsNotDuckDuckGoEmailThenIsSignedInNotCalled() { - whenever(mockWebView.url).thenReturn(NON_EMAIL_URL) - - testee.isSignedIn() - - verify(mockEmailManager, never()).isSignedIn() - } - - @Test - fun whenStoreCredentialsAndUrlIsDuckDuckGoEmailThenStoreCredentialsCalledWithCorrectParameters() { - whenever(mockWebView.url).thenReturn(DUCKDUCKGO_EMAIL_URL) - - testee.storeCredentials("token", "username", "cohort") - - verify(mockEmailManager).storeCredentials("token", "username", "cohort") - } - - @Test - fun whenStoreCredentialsAndUrlIsNotDuckDuckGoEmailThenStoreCredentialsNotCalled() { - whenever(mockWebView.url).thenReturn(NON_EMAIL_URL) - - testee.storeCredentials("token", "username", "cohort") - - verify(mockEmailManager, never()).storeCredentials("token", "username", "cohort") - } - - @Test - fun whenGetUserDataAndUrlIsDuckDuckGoEmailThenGetUserDataCalled() { - whenever(mockWebView.url).thenReturn(DUCKDUCKGO_EMAIL_URL) - - testee.getUserData() - - verify(mockEmailManager).getUserData() - } - - @Test - fun whenGetUserDataAndUrlIsNotDuckDuckGoEmailThenGetUserDataIsNotCalled() { - whenever(mockWebView.url).thenReturn(NON_EMAIL_URL) - - testee.getUserData() - - verify(mockEmailManager, never()).getUserData() - } - - @Test - fun whenShowTooltipThenLambdaCalled() { - whenever(mockWebView.url).thenReturn(NON_EMAIL_URL) - - testee.showTooltip() - - assertEquals(1, counter) - } - - @Test - fun whenShowTooltipAndFeatureDisabledThenLambdaNotCalled() { - whenever(mockWebView.url).thenReturn(NON_EMAIL_URL) - autofillFeature.self().setEnabled(Toggle.State(enable = false)) - - testee.showTooltip() - - assertEquals(0, counter) - } - - @Test - fun whenShowTooltipAndUrlIsAnExceptionThenLambdaNotCalled() { - whenever(mockWebView.url).thenReturn(NON_EMAIL_URL) - whenever(mockAutofill.isAnException(any())).thenReturn(true) - - testee.showTooltip() - - assertEquals(0, counter) - } - - @Test - fun whenGetDeviceCapabilitiesAndUrlIsDuckDuckGoEmailThenReturnNonEmptyString() { - whenever(mockWebView.url).thenReturn(DUCKDUCKGO_EMAIL_URL) - - assert(testee.getDeviceCapabilities().isNotBlank()) - } - - @Test - fun whenGetDeviceCapabilitiesAndUrlIsNotDuckDuckGoEmailThenReturnEmptyString() { - whenever(mockWebView.url).thenReturn(NON_EMAIL_URL) - - assert(testee.getDeviceCapabilities().isBlank()) - } - - companion object { - const val DUCKDUCKGO_EMAIL_URL = "https://duckduckgo.com/email" - const val NON_EMAIL_URL = "https://example.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 56c1964de8b9..e21380b34884 100644 --- a/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt @@ -26,7 +26,7 @@ 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.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.brokensite.api.BrokenSite import com.duckduckgo.brokensite.api.BrokenSiteSender import com.duckduckgo.brokensite.api.ReportFlow @@ -744,11 +744,11 @@ class BrokenSiteViewModelTest { ) testee.onProtectionsToggled(protectionsEnabled = false) - verify(mockPixel).fire(AppPixelName.BROKEN_SITE_ALLOWLIST_ADD, params, type = COUNT) + verify(mockPixel).fire(AppPixelName.BROKEN_SITE_ALLOWLIST_ADD, params, type = Count) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled = false) testee.onProtectionsToggled(protectionsEnabled = true) - verify(mockPixel).fire(AppPixelName.BROKEN_SITE_ALLOWLIST_REMOVE, params, type = COUNT) + verify(mockPixel).fire(AppPixelName.BROKEN_SITE_ALLOWLIST_REMOVE, params, type = Count) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled = true) } diff --git a/app/src/test/java/com/duckduckgo/app/global/model/SiteMonitorTest.kt b/app/src/test/java/com/duckduckgo/app/global/model/SiteMonitorTest.kt index 4f9421f1b26c..a8b5548a035b 100644 --- a/app/src/test/java/com/duckduckgo/app/global/model/SiteMonitorTest.kt +++ b/app/src/test/java/com/duckduckgo/app/global/model/SiteMonitorTest.kt @@ -31,13 +31,16 @@ import com.duckduckgo.app.trackerdetection.model.TrackerType import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.browser.api.brokensite.BrokenSiteContext import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.privacy.config.api.ContentBlocking import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -71,8 +74,15 @@ class SiteMonitorTest { private val mockBrokenSiteContext: BrokenSiteContext = mock() + private val mockDuckPlayer: DuckPlayer = mock() + private val mockBypassedSSLCertificatesRepository: BypassedSSLCertificatesRepository = mock() + @Before + fun setup() { + whenever(mockDuckPlayer.isDuckPlayerUri(any())).thenReturn(false) + } + @Test fun whenUrlIsHttpsThenHttpsStatusIsSecure() { val testee = SiteMonitor( @@ -85,6 +95,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) assertEquals(HttpsStatus.SECURE, testee.https) } @@ -101,6 +112,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) assertEquals(HttpsStatus.NONE, testee.https) } @@ -117,6 +129,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.hasHttpResources = true assertEquals(HttpsStatus.MIXED, testee.https) @@ -134,6 +147,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) assertEquals(HttpsStatus.NONE, testee.https) } @@ -150,6 +164,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) assertEquals(document, testee.url) } @@ -166,6 +181,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) assertEquals(0, testee.trackerCount) } @@ -182,6 +198,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.trackerDetected( TrackingEvent( @@ -231,6 +248,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.trackerDetected( TrackingEvent( @@ -280,6 +298,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.trackerDetected( TrackingEvent( @@ -329,6 +348,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.trackerDetected( TrackingEvent( @@ -356,6 +376,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.trackerDetected( TrackingEvent( @@ -383,6 +404,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.trackerDetected( TrackingEvent( @@ -421,6 +443,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) assertFalse(testee.upgradedHttps) } @@ -437,6 +460,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) assertEquals(0, testee.surrogates.size) } @@ -453,6 +477,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.surrogateDetected(SurrogateResponse()) assertEquals(1, testee.surrogates.size) @@ -470,6 +495,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.trackerDetected( TrackingEvent( @@ -519,6 +545,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) testee.trackerDetected( TrackingEvent( @@ -653,6 +680,7 @@ class SiteMonitorTest { appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, brokenSiteContext = mockBrokenSiteContext, + duckPlayer = mockDuckPlayer, ) private fun givenSitePrivacyData( diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt index 9dec5c49e0a6..c13322975561 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt @@ -34,7 +34,7 @@ import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_COMPARISON_CHART_SHO import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_INTRO_SHOWN_UNIQUE import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Assert @@ -68,21 +68,21 @@ class WelcomePageViewModelTest { fun whenInitialDialogIsShownThenSendPixel() { testee.onDialogShown(PreOnboardingDialogType.INITIAL) - verify(mockPixel).fire(PREONBOARDING_INTRO_SHOWN_UNIQUE, type = UNIQUE) + verify(mockPixel).fire(PREONBOARDING_INTRO_SHOWN_UNIQUE, type = Unique()) } @Test fun whenComparisonChartDialogIsShownThenSendPixel() { testee.onDialogShown(PreOnboardingDialogType.COMPARISON_CHART) - verify(mockPixel).fire(PREONBOARDING_COMPARISON_CHART_SHOWN_UNIQUE, type = UNIQUE) + verify(mockPixel).fire(PREONBOARDING_COMPARISON_CHART_SHOWN_UNIQUE, type = Unique()) } @Test fun whenAffirmationDialogIsShownThenSendPixel() { testee.onDialogShown(PreOnboardingDialogType.CELEBRATION) - verify(mockPixel).fire(PREONBOARDING_AFFIRMATION_SHOWN_UNIQUE, type = UNIQUE) + verify(mockPixel).fire(PREONBOARDING_AFFIRMATION_SHOWN_UNIQUE, type = Unique()) } @Test diff --git a/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt b/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt index 147d4ea0a238..d977767b4b55 100644 --- a/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt @@ -220,8 +220,8 @@ class EnqueuedPixelWorkerTest { } private fun setupRemoteConfig(browserEnabled: Boolean, collectFullWebViewVersionEnabled: Boolean) { - androidBrowserConfigFeature.self().setEnabled(State(enable = browserEnabled)) - androidBrowserConfigFeature.collectFullWebViewVersion().setEnabled(State(enable = collectFullWebViewVersionEnabled)) + androidBrowserConfigFeature.self().setRawStoredState(State(enable = browserEnabled)) + androidBrowserConfigFeature.collectFullWebViewVersion().setRawStoredState(State(enable = collectFullWebViewVersionEnabled)) } } diff --git a/app/src/test/java/com/duckduckgo/app/pixels/campaign/CampaignPixelParamsAdditionInterceptorTest.kt b/app/src/test/java/com/duckduckgo/app/pixels/campaign/CampaignPixelParamsAdditionInterceptorTest.kt index 665e3a2e769d..e39316ca30ae 100644 --- a/app/src/test/java/com/duckduckgo/app/pixels/campaign/CampaignPixelParamsAdditionInterceptorTest.kt +++ b/app/src/test/java/com/duckduckgo/app/pixels/campaign/CampaignPixelParamsAdditionInterceptorTest.kt @@ -49,7 +49,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsDisabledThenNoChangesInEligiblePixelUrls() { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = false)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = false)) val startUrl = URL_PIXEL_BASE + "m_subscribe_android_phone?origin=valid_test_origin1" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -58,7 +58,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsOriginSubscribeWithValidCampaignThenAppendAdditionalParams() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_subscribe_android_phone?origin=valid_test_origin1" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -67,7 +67,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsOriginSubscribeWithInValidCampaignThenNoChangesInPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_subscribe_android_phone?origin=invalid_origin" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -76,7 +76,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsInvalidThenNoChangesInPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_subscribe_something_else_android_phone?origin=valid_test_origin1" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -85,7 +85,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsRMFShownWithValidMessageThenAppendAdditionalParamsToPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_remote_message_shown_android_phone?message=valid_test_origin1" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -94,7 +94,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsRMFShownWithInvalidMessageThenNoChangesInPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_remote_message_shown_android_phone?message=invalid" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -103,7 +103,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsRMFDismissedWithValidMessageThenAppendAdditionalParamsToPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_remote_message_dismissed_android_phone?message=valid_test_origin1" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -112,7 +112,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsRMFDismissedWithInvalidMessageThenNoChangesInPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_remote_message_dismissed_android_phone?message=invalid" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -121,7 +121,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsRMFPrimaryClickedWithValidMessageThenAppendAdditionalParamsToPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_remote_message_primary_action_clicked_android_phone?message=valid_test_origin1" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -130,7 +130,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsRMFPrimaryClickedWithInvalidMessageThenNoChangesInPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_remote_message_primary_action_clicked_android_phone?message=invalid" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url @@ -139,7 +139,7 @@ class CampaignPixelParamsAdditionInterceptorTest { @Test fun whenFeatureIsEnabledAndPixelIsRMFPrimaryClickedWithNoParamshenNoChangesInPixel() = runTest { - additionalPixelParamsFeature.self().setEnabled(Toggle.State(enable = true)) + additionalPixelParamsFeature.self().setRawStoredState(Toggle.State(enable = true)) val startUrl = URL_PIXEL_BASE + "m_remote_message_primary_action_clicked_android_phone" val resultUrl = interceptor.intercept(FakeChain(startUrl)).request.url 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 4ef45111bea6..36ca03a39377 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 @@ -21,7 +21,7 @@ import com.duckduckgo.app.brokensite.api.BrokenSiteSubmitter import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +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 @@ -195,7 +195,7 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(AppPixelName.BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(AppPixelName.BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue val encodedParams = encodedParamsCaptor.firstValue 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 c3d10997fa34..e51aa891e919 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 @@ -23,7 +23,7 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +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 @@ -183,7 +183,7 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(AppPixelName.BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) + verify(mockPixel).fire(eq(AppPixelName.BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(Count)) val params = paramsCaptor.firstValue.toMutableMap() params["locale"] = "en-US" diff --git a/app/src/test/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt index 5d4532c5e65e..4f46aa2b2b61 100644 --- a/app/src/test/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt @@ -30,6 +30,7 @@ import com.duckduckgo.app.onboarding.store.* import com.duckduckgo.app.pixels.AppPixelName.* import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.AutocompleteItemRemoved import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.LaunchDuckDuckGo @@ -510,7 +511,7 @@ class SystemSearchViewModelTest { } @Test - fun whenOnRemoveSearchSuggestionConfirmedForHistorySuggestionThenPixelFiredAndHistoryEntryRemoved() = runBlocking { + fun whenOnRemoveSearchSuggestionConfirmedForHistorySuggestionThenPixelsFiredAndHistoryEntryRemoved() = runBlocking { val suggestion = AutoCompleteHistorySuggestion(phrase = "phrase", title = "title", url = "url", isAllowedInTopHits = false) val omnibarText = "foo" @@ -520,13 +521,14 @@ class SystemSearchViewModelTest { testee.onRemoveSearchSuggestionConfirmed(suggestion, omnibarText) verify(mockPixel).fire(AUTOCOMPLETE_RESULT_DELETED) + verify(mockPixel).fire(AUTOCOMPLETE_RESULT_DELETED_DAILY, type = Daily()) verify(mockHistory).removeHistoryEntryByUrl(suggestion.url) testObserver.assertValue(omnibarText) assertCommandIssued() } @Test - fun whenOnRemoveSearchSuggestionConfirmedForHistorySearchSuggestionThenPixelFiredAndHistoryEntryRemoved() = runBlocking { + fun whenOnRemoveSearchSuggestionConfirmedForHistorySearchSuggestionThenPixelsFiredAndHistoryEntryRemoved() = runBlocking { val suggestion = AutoCompleteHistorySearchSuggestion(phrase = "phrase", isAllowedInTopHits = false) val omnibarText = "foo" @@ -536,6 +538,7 @@ class SystemSearchViewModelTest { testee.onRemoveSearchSuggestionConfirmed(suggestion, omnibarText) verify(mockPixel).fire(AUTOCOMPLETE_RESULT_DELETED) + verify(mockPixel).fire(AUTOCOMPLETE_RESULT_DELETED_DAILY, type = Daily()) verify(mockHistory).removeHistoryEntryByQuery(suggestion.phrase) testObserver.assertValue(omnibarText) assertCommandIssued() diff --git a/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt index 8e6904cba61f..af368d013686 100644 --- a/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt @@ -25,7 +25,7 @@ import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository @@ -305,7 +305,7 @@ class TabSwitcherViewModelTest { testee.onTabDraggingStarted() val params = mapOf("userState" to EXISTING.name) - verify(mockPixel).fire(AppPixelName.TAB_MANAGER_REARRANGE_TABS_DAILY, params, emptyMap(), DAILY) + verify(mockPixel).fire(AppPixelName.TAB_MANAGER_REARRANGE_TABS_DAILY, params, emptyMap(), Daily()) } @OptIn(ExperimentalCoroutinesApi::class) @@ -316,7 +316,7 @@ class TabSwitcherViewModelTest { advanceUntilIdle() val params = mapOf("userState" to NEW.name) - verify(mockPixel).fire(AppPixelName.TAB_MANAGER_REARRANGE_TABS_DAILY, params, emptyMap(), DAILY) + verify(mockPixel).fire(AppPixelName.TAB_MANAGER_REARRANGE_TABS_DAILY, params, emptyMap(), Daily()) } @Test @@ -337,7 +337,6 @@ class TabSwitcherViewModelTest { testee.onLayoutTypeToggled() - verify(mockPixel).fire(AppPixelName.TAB_MANAGER_VIEW_MODE_TOGGLED_DAILY, emptyMap(), emptyMap(), Pixel.PixelType.DAILY) verify(mockPixel).fire(AppPixelName.TAB_MANAGER_LIST_VIEW_BUTTON_CLICKED) } @@ -354,7 +353,6 @@ class TabSwitcherViewModelTest { testee.onLayoutTypeToggled() - verify(mockPixel).fire(AppPixelName.TAB_MANAGER_VIEW_MODE_TOGGLED_DAILY, emptyMap(), emptyMap(), Pixel.PixelType.DAILY) verify(mockPixel).fire(AppPixelName.TAB_MANAGER_GRID_VIEW_BUTTON_CLICKED) } diff --git a/app/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/EmailProtectionUrlTest.kt b/app/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/EmailProtectionUrlTest.kt new file mode 100644 index 000000000000..1a87ac4777f7 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/EmailProtectionUrlTest.kt @@ -0,0 +1,32 @@ +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email + +import org.junit.Assert.* +import org.junit.Test + +class EmailProtectionUrlTest { + + @Test + fun whenNotADuckDuckGoAddressThenNotIdentifiedAsEmailProtectionUrl() { + assertFalse(EmailProtectionUrl.isEmailProtectionUrl("https://example.com")) + } + + @Test + fun whenADuckDuckGoAddressButNotEmailThenNotIdentifiedAsEmailProtectionUrl() { + assertFalse(EmailProtectionUrl.isEmailProtectionUrl("https://duckduckgo.com")) + } + + @Test + fun whenIsDuckDuckGoEmailUrlThenIdentifiedAsEmailProtectionUrl() { + assertTrue(EmailProtectionUrl.isEmailProtectionUrl("https://duckduckgo.com/email")) + } + + @Test + fun whenIsDuckDuckGoEmailUrlWithTrailingSlashThenIdentifiedAsEmailProtectionUrl() { + assertTrue(EmailProtectionUrl.isEmailProtectionUrl("https://duckduckgo.com/email/")) + } + + @Test + fun whenIsDuckDuckGoEmailUrlWithExtraUrlPartsThenIdentifiedAsEmailProtectionUrl() { + assertTrue(EmailProtectionUrl.isEmailProtectionUrl("https://duckduckgo.com/email/foo/bar")) + } +} diff --git a/app/src/testPlay/java/com/duckduckgo/app/referrer/AppReferrerInstallPixelSenderTest.kt b/app/src/testPlay/java/com/duckduckgo/app/referrer/AppReferrerInstallPixelSenderTest.kt index 15e09102c772..6746f2eb13bd 100644 --- a/app/src/testPlay/java/com/duckduckgo/app/referrer/AppReferrerInstallPixelSenderTest.kt +++ b/app/src/testPlay/java/com/duckduckgo/app/referrer/AppReferrerInstallPixelSenderTest.kt @@ -3,7 +3,7 @@ package com.duckduckgo.app.referrer import com.duckduckgo.app.pixels.AppPixelName.REFERRAL_INSTALL_UTM_CAMPAIGN import com.duckduckgo.app.referral.AppReferrerDataStore import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule @@ -114,7 +114,7 @@ class AppReferrerInstallPixelSenderTest { eq(REFERRAL_INSTALL_UTM_CAMPAIGN), parameters = captor.capture(), encodedParameters = any(), - type = eq(UNIQUE), + type = eq(Unique()), ) } diff --git a/app/version/version.properties b/app/version/version.properties index 123895c56b1c..32516e80f982 100644 --- a/app/version/version.properties +++ b/app/version/version.properties @@ -1 +1 @@ -VERSION=5.215.0 \ No newline at end of file +VERSION=5.216.0 \ No newline at end of file diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCapabilityChecker.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCapabilityChecker.kt index 12109b062c24..678d1c829359 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCapabilityChecker.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCapabilityChecker.kt @@ -17,37 +17,12 @@ package com.duckduckgo.autofill.api /** - * Used to check the status of various Autofill features. - * - * Whether autofill features are enabled depends on a variety of inputs. This class provides a single way to query the status of all of them. + * Used to check the status of Autofill features. + * This is the public API that should be used by the app to check the status of Autofill features. + + * see also: InternalAutofillCapabilityChecker */ interface AutofillCapabilityChecker { - /** - * Whether autofill can inject credentials into a WebView for the given page. - * @param url The URL of the webpage to check. - */ - suspend fun canInjectCredentialsToWebView(url: String): Boolean - - /** - * Whether autofill can save credentials from a WebView for the given page. - * @param url The URL of the webpage to check. - */ - suspend fun canSaveCredentialsFromWebView(url: String): Boolean - - /** - * Whether autofill can generate a password into a WebView for the given page. - * @param url The URL of the webpage to check. - */ - suspend fun canGeneratePasswordFromWebView(url: String): Boolean - - /** - * Whether a user can access the credential management screen. - */ suspend fun canAccessCredentialManagementScreen(): Boolean - - /** - * Whether autofill is configured to be enabled. This is a configuration value, not a user preference. - */ - suspend fun isAutofillEnabledByConfiguration(url: String): Boolean } diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt index 7e58d3eb9036..390e2f3aadee 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt @@ -53,8 +53,8 @@ interface CredentialAutofillPickerDialog { const val TAG = "CredentialAutofillPickerDialog" const val KEY_CANCELLED = "cancelled" - const val KEY_URL = "url" const val KEY_CREDENTIALS = "credentials" + const val KEY_URL_REQUEST = "url" const val KEY_TRIGGER_TYPE = "triggerType" const val KEY_TAB_ID = "tabId" } @@ -181,6 +181,7 @@ interface EmailProtectionInContextSignUpDialog { const val TAG = "EmailProtectionInContextSignUpDialog" const val KEY_RESULT = "result" + const val KEY_URL = "url" } } @@ -193,7 +194,7 @@ interface CredentialAutofillDialogFactory { * Creates a dialog which prompts the user to choose which saved credential to autofill */ fun autofillSelectCredentialsDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: List, triggerType: LoginTriggerType, tabId: String, @@ -203,7 +204,7 @@ interface CredentialAutofillDialogFactory { * Creates a dialog which prompts the user to choose whether to save credentials or not */ fun autofillSavingCredentialsDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, tabId: String, ): DialogFragment @@ -212,7 +213,7 @@ interface CredentialAutofillDialogFactory { * Creates a dialog which prompts the user to choose whether to update an existing credential's password */ fun autofillSavingUpdatePasswordDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, tabId: String, ): DialogFragment @@ -221,7 +222,7 @@ interface CredentialAutofillDialogFactory { * Creates a dialog which prompts the user to choose whether to update an existing credential's username */ fun autofillSavingUpdateUsernameDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, tabId: String, ): DialogFragment @@ -230,7 +231,7 @@ interface CredentialAutofillDialogFactory { * Creates a dialog which prompts the user to choose whether to use generated password or not */ fun autofillGeneratePasswordDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, username: String?, generatedPassword: String, tabId: String, @@ -240,7 +241,7 @@ interface CredentialAutofillDialogFactory { * Creates a dialog which prompts the user to choose whether to use their personal duck address or a private alias address */ fun autofillEmailProtectionEmailChooserDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, personalDuckAddress: String, tabId: String, ): DialogFragment @@ -248,7 +249,7 @@ interface CredentialAutofillDialogFactory { /** * Creates a dialog which prompts the user to sign up for Email Protection */ - fun emailProtectionInContextSignUpDialog(tabId: String): DialogFragment + fun emailProtectionInContextSignUpDialog(tabId: String, autofillWebMessageRequest: AutofillWebMessageRequest): DialogFragment } private fun prefix( diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillEventListener.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillEventListener.kt index 247c9a520573..08170b770e80 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillEventListener.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillEventListener.kt @@ -25,54 +25,10 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials @MainThread interface AutofillEventListener { - /** - * Called when user chooses to use a generated password when prompted. - * @param originalUrl the URL of the page that prompted the user to use a generated password - */ - fun onAcceptGeneratedPassword(originalUrl: String) - - /** - * Called when user chooses not to use a generated password when prompted. - * @param originalUrl the URL of the page that prompted the user to use a generated password - */ - fun onRejectGeneratedPassword(originalUrl: String) - - /** - * Called when user chooses to autofill their personal duck address. - * @param originalUrl the URL of the page that prompted the user to use their personal duck address - * @param duckAddress the personal duck address that the user chose to autofill - */ - fun onUseEmailProtectionPersonalAddress(originalUrl: String, duckAddress: String) - - /** - * Called when user chooses to autofill a private duck address (private alias). - * @param originalUrl the URL of the page that prompted the user to use a private duck address - * @param duckAddress the private duck address that the user chose to autofill - */ - fun onUseEmailProtectionPrivateAlias(originalUrl: String, duckAddress: String) - /** * Called when user chooses to sign up for in-context email protection. */ - fun onSelectedToSignUpForInContextEmailProtection() - - /** - * Called when the Email Protection in-context flow ends, for any reason - */ - fun onEndOfEmailProtectionInContextSignupFlow() - - /** - * Called when user chooses to autofill a login credential to a web page. - * @param originalUrl the URL of the page that prompted the user to use a login credential - * @param selectedCredentials the login credential that the user chose to autofill - */ - fun onShareCredentialsForAutofill(originalUrl: String, selectedCredentials: LoginCredentials) - - /** - * Called when user chooses not to autofill any login credential to a web page. - * @param originalUrl the URL of the page that prompted the user to use a login credential - */ - fun onNoCredentialsChosenForAutofill(originalUrl: String) + fun onSelectedToSignUpForInContextEmailProtection(autofillWebMessageRequest: AutofillWebMessageRequest) /** * Called when a login credential was saved. This API could be used to show visual confirmation to the user. diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/BrowserAutofill.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/BrowserAutofill.kt index 941a1ef02fe1..3d97714c1236 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/BrowserAutofill.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/BrowserAutofill.kt @@ -16,98 +16,37 @@ package com.duckduckgo.autofill.api +import android.os.Parcelable import android.webkit.WebView import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType +import kotlinx.parcelize.Parcelize /** * Public interface for accessing and configuring browser autofill functionality for a WebView instance */ interface BrowserAutofill { - interface Configurator { - /** - * Configures autofill for the current webpage. - * This should be called once per page load (e.g., onPageStarted()) - * - * Responsible for injecting the required autofill configuration to the JS layer - */ - fun configureAutofillForCurrentPage( - webView: WebView, - url: String?, - ) - } /** * Adds the native->JS interface to the given WebView * This should be called once per WebView where autofill is to be available in it */ - fun addJsInterface( + suspend fun addJsInterface( webView: WebView, autofillCallback: Callback, - emailProtectionInContextCallback: EmailProtectionUserPromptListener? = null, - emailProtectionInContextSignupFlowCallback: EmailProtectionInContextSignupFlowListener? = null, tabId: String, ) /** - * Removes the JS interface as a clean-up. Recommended to call from onDestroy() of Fragment/Activity containing the WebView + * Notifies that there has been a change in web page, and the autofill state should be re-evaluated */ - fun removeJsInterface() - - /** - * Communicates with the JS layer to pass the given credentials - * - * @param credentials The credentials to be passed to the JS layer. Can be null to indicate credentials won't be autofilled. - */ - fun injectCredentials(credentials: LoginCredentials?) + fun notifyPageChanged() /** * Cancels any ongoing autofill operations which would show the user the prompt to choose credentials * This would only normally be needed if a user-interaction happened such that showing autofill prompt would be undesirable. */ fun cancelPendingAutofillRequestToChooseCredentials() - - /** - * Informs the JS layer to use the generated password and fill it into the password field(s) - */ - fun acceptGeneratedPassword() - - /** - * Informs the JS layer not to use the generated password - */ - fun rejectGeneratedPassword() - - /** - * Informs the JS layer that the in-context Email Protection flow has finished - */ - fun inContextEmailProtectionFlowFinished() -} - -/** - * Callback for Email Protection prompts, signalling when to show the native UI to the user - */ -interface EmailProtectionUserPromptListener { - - /** - * Called when the user should be shown prompt to sign up for Email Protection - */ - fun showNativeInContextEmailProtectionSignupPrompt() - - /** - * Called when the user should be shown prompt to choose an email address to use for email protection autofill - */ - fun showNativeChooseEmailAddressPrompt() -} - -/** - * Callback for Email Protection events that might happen during the in-context signup flow - */ -interface EmailProtectionInContextSignupFlowListener { - - /** - * Called when the in-context email protection signup flow should be closed - */ - fun closeInContextSignup() } /** @@ -120,7 +59,7 @@ interface Callback { * When this is called, we should present the list to the user for them to choose which one, if any, to autofill. */ suspend fun onCredentialsAvailableToInject( - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: List, triggerType: LoginTriggerType, ) @@ -130,7 +69,7 @@ interface Callback { * When this is called, we'd typically want to prompt the user if they want to save the credentials. */ suspend fun onCredentialsAvailableToSave( - currentUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, ) @@ -139,18 +78,46 @@ interface Callback { * When this is called, we should present the generated password to the user for them to choose whether to use it or not. */ suspend fun onGeneratedPasswordAvailableToUse( - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, username: String?, generatedPassword: String, ) /** - * Called when we've been asked which credentials we have available to autofill, but the answer is none. + * Called when the user should be shown prompt to choose an email address to use for email protection autofill + */ + fun showNativeChooseEmailAddressPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) + + /** + * Called when the user should be shown prompt to sign up for Email Protection */ - fun noCredentialsAvailable(originalUrl: String) + fun showNativeInContextEmailProtectionSignupPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) /** * Called when credentials have been saved, and we want to show the user some visual confirmation. */ fun onCredentialsSaved(savedCredentials: LoginCredentials) } + +/** + * When there is an autofill request to be handled that requires user-interaction, we need to know where the request came from when later responding + * + * This is metadata about the WebMessage request that was received from the JS. + */ +@Parcelize +data class AutofillWebMessageRequest( + /** + * The origin of the request. Note, this may be a different origin than the page the user is currently on if the request came from an iframe + */ + val requestOrigin: String, + + /** + * The user-facing URL of the page where the autofill request originated + */ + val originalPageUrl: String?, + + /** + * The ID of the original request from the JS. This request ID is required in order to later provide a response using the web message reply API + */ + val requestId: String, +) : Parcelable diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/EmailProtectionInContextSignUpScreens.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/EmailProtectionInContextSignUpScreens.kt index df39b2538ce3..563afc6f93c4 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/EmailProtectionInContextSignUpScreens.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/EmailProtectionInContextSignUpScreens.kt @@ -20,15 +20,16 @@ import com.duckduckgo.navigation.api.GlobalActivityStarter /** * Launch params for starting In-Context Email Protection flow + * @param messageRequestId The ID of the original web message that triggered the flow, used to send a reply back to the web page */ -object EmailProtectionInContextSignUpScreenNoParams : GlobalActivityStarter.ActivityParams { - private fun readResolve(): Any = EmailProtectionInContextSignUpScreenNoParams -} +data class EmailProtectionInContextSignUpStartScreen(val messageRequestId: String) : GlobalActivityStarter.ActivityParams /** * Launch params for resuming In-Context Email Protection flow from an email verification link + * @param url The URL of the email verification link + * @param messageRequestId The ID of the original web message that triggered the flow, used to send a reply back to the web page */ -data class EmailProtectionInContextSignUpHandleVerificationLink(val url: String) : GlobalActivityStarter.ActivityParams +data class EmailProtectionInContextSignUpHandleVerificationLink(val url: String, val messageRequestId: String) : GlobalActivityStarter.ActivityParams /** * Activity result codes @@ -36,4 +37,7 @@ data class EmailProtectionInContextSignUpHandleVerificationLink(val url: String) object EmailProtectionInContextSignUpScreenResult { const val SUCCESS = 1 const val CANCELLED = 2 + + const val RESULT_KEY_MESSAGE = "message" + const val RESULT_KEY_REQUEST_ID = "requestId" } diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/promotion/PasswordsScreenPromotionPlugin.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/promotion/PasswordsScreenPromotionPlugin.kt index f004da904785..7b59482c1424 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/promotion/PasswordsScreenPromotionPlugin.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/promotion/PasswordsScreenPromotionPlugin.kt @@ -41,6 +41,8 @@ interface PasswordsScreenPromotionPlugin { } companion object { + const val PRIORITY_KEY_AUTOFILL_SUPPORT_WARNING = 50 + const val PRIORITY_KEY_AUTOFILL_DISABLED_CONFIG_WARNING = 60 const val PRIORITY_KEY_SURVEY = 100 const val PRIORITY_KEY_SYNC_PROMO = 200 } diff --git a/autofill/autofill-impl/lint-baseline.xml b/autofill/autofill-impl/lint-baseline.xml index ba1c416d8ed5..855a3d582afa 100644 --- a/autofill/autofill-impl/lint-baseline.xml +++ b/autofill/autofill-impl/lint-baseline.xml @@ -37,8 +37,8 @@ + errorLine1=" remoteFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" remoteFeature.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - + errorLine1=" setting.self().setRawStoredState(Toggle.State(true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(Toggle.State(false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" surveysFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -239,7 +184,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -250,7 +195,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -301,8 +246,8 @@ + errorLine1=" autofillFeature.onByDefault().setRawStoredState(State(enable = onByDefaultNewUsers))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" autofillFeature.onForExistingUsers().setRawStoredState(State(enable = onByDefaultExistingUsers))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" autofillFeature.canCategorizeUnknownUsername().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -345,8 +290,8 @@ + errorLine1=" emailProtectionInContextSignupFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" emailProtectionInContextSignupFeature.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - @@ -448,7 +338,7 @@ errorLine2=" ^"> @@ -459,7 +349,7 @@ errorLine2=" ^"> @@ -470,7 +360,7 @@ errorLine2=" ^"> @@ -481,7 +371,7 @@ errorLine2=" ^"> @@ -492,7 +382,7 @@ errorLine2=" ^"> @@ -503,7 +393,7 @@ errorLine2=" ^"> @@ -514,7 +404,7 @@ errorLine2=" ^"> @@ -525,7 +415,7 @@ errorLine2=" ^"> @@ -536,7 +426,7 @@ errorLine2=" ^"> @@ -547,7 +437,7 @@ errorLine2=" ^"> @@ -558,7 +448,7 @@ errorLine2=" ^"> @@ -569,7 +459,7 @@ errorLine2=" ^"> @@ -580,7 +470,7 @@ errorLine2=" ^"> @@ -591,7 +481,7 @@ errorLine2=" ^"> @@ -602,7 +492,7 @@ errorLine2=" ^"> @@ -613,7 +503,7 @@ errorLine2=" ^"> @@ -624,7 +514,7 @@ errorLine2=" ^"> @@ -635,7 +525,7 @@ errorLine2=" ^"> @@ -646,7 +536,7 @@ errorLine2=" ^"> @@ -657,7 +547,7 @@ errorLine2=" ^"> @@ -668,7 +558,7 @@ errorLine2=" ^"> @@ -679,7 +569,7 @@ errorLine2=" ^"> @@ -690,7 +580,7 @@ errorLine2=" ^"> @@ -701,7 +591,7 @@ errorLine2=" ^"> @@ -1768,7 +1658,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1779,7 +1669,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1790,7 +1680,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1801,7 +1691,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1812,7 +1702,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1823,7 +1713,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1834,7 +1724,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1845,7 +1735,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1856,7 +1746,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1867,7 +1757,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1878,7 +1768,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1889,7 +1779,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1900,7 +1790,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1911,7 +1801,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1922,7 +1812,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1933,7 +1823,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1944,7 +1834,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1955,7 +1845,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1966,7 +1856,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1977,7 +1867,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1988,7 +1878,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1999,7 +1889,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2010,7 +1900,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2021,7 +1911,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2032,7 +1922,7 @@ errorLine2=" ^"> @@ -2043,7 +1933,7 @@ errorLine2=" ^"> @@ -2054,7 +1944,7 @@ errorLine2=" ^"> @@ -2065,7 +1955,7 @@ errorLine2=" ^"> @@ -2076,7 +1966,7 @@ errorLine2=" ^"> @@ -2087,7 +1977,7 @@ errorLine2=" ^"> diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillDisabledByConfigWarningUI.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillDisabledByConfigWarningUI.kt new file mode 100644 index 000000000000..90c90256373f --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillDisabledByConfigWarningUI.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import androidx.core.view.isVisible +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin +import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin.Companion.PRIORITY_KEY_AUTOFILL_DISABLED_CONFIG_WARNING +import com.duckduckgo.autofill.impl.databinding.ViewAutofillConfigDisabledWarningBinding +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.di.scopes.ViewScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.android.support.AndroidSupportInjection +import javax.inject.Inject + +@ContributesMultibinding(scope = AppScope::class) +@PriorityKey(PRIORITY_KEY_AUTOFILL_DISABLED_CONFIG_WARNING) +class AutofillDisabledByConfigWarningUI @Inject constructor( + private val internalAutofillCapabilityChecker: InternalAutofillCapabilityChecker, +) : PasswordsScreenPromotionPlugin { + + override suspend fun getView(context: Context, numberSavedPasswords: Int): View? { + val autofillConfigEnabled = internalAutofillCapabilityChecker.isAutofillEnabledByConfiguration("") + if (autofillConfigEnabled) return null + + return AutofillDisabledByConfigWarningView(context) + } +} + +@InjectWith(ViewScope::class) +class AutofillDisabledByConfigWarningView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : FrameLayout(context, attrs, defStyle) { + + private val binding: ViewAutofillConfigDisabledWarningBinding by viewBinding() + + override fun onAttachedToWindow() { + AndroidSupportInjection.inject(this) + super.onAttachedToWindow() + binding.webViewUnsupportedWarningPanel.isVisible = true + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInjector.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInjector.kt new file mode 100644 index 000000000000..8a9c327224a5 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInjector.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl + +import android.annotation.SuppressLint +import android.webkit.WebView +import androidx.webkit.WebViewCompat +import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptLoader +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface AutofillJavascriptInjector { + suspend fun addDocumentStartJavascript(webView: WebView) +} + +@ContributesBinding(FragmentScope::class) +class AutofillJavascriptInjectorImpl @Inject constructor( + private val javascriptLoader: AutofillJavascriptLoader, +) : AutofillJavascriptInjector { + + @SuppressLint("RequiresFeature") + override suspend fun addDocumentStartJavascript(webView: WebView) { + val js = javascriptLoader.getAutofillJavascript() + .replace("// INJECT userPreferences HERE", staticJavascript) + + WebViewCompat.addDocumentStartJavaScript(webView, js, setOf("*")) + } + + companion object { + private val staticJavascript = """ + userPreferences = { + "debug": false, + "platform": { + "name": "android" + } + } + """.trimIndent() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt deleted file mode 100644 index c5ef7fdb189e..000000000000 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt +++ /dev/null @@ -1,436 +0,0 @@ -/* - * Copyright (c) 2022 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.autofill.impl - -import android.webkit.JavascriptInterface -import android.webkit.WebView -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.autofill.api.AutofillCapabilityChecker -import com.duckduckgo.autofill.api.Callback -import com.duckduckgo.autofill.api.EmailProtectionInContextSignupFlowListener -import com.duckduckgo.autofill.api.EmailProtectionUserPromptListener -import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.domain.app.LoginTriggerType -import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator -import com.duckduckgo.autofill.impl.domain.javascript.JavascriptCredentials -import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker -import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore -import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster -import com.duckduckgo.autofill.impl.jsbridge.request.AutofillDataRequest -import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser -import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataRequest -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputMainType.CREDENTIALS -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.USERNAME -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.AUTOPROMPT -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED -import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter -import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials -import com.duckduckgo.autofill.impl.store.InternalAutofillStore -import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository -import com.duckduckgo.autofill.impl.systemautofill.SystemAutofillServiceSuppressor -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.DeleteAutoLogin -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.DiscardAutoLoginId -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.PromptToSave -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.UpdateSavedAutoLogin -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.AutogeneratedPasswordEventResolver -import com.duckduckgo.common.utils.ConflatedJob -import com.duckduckgo.common.utils.DefaultDispatcherProvider -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import timber.log.Timber - -interface AutofillJavascriptInterface { - - @JavascriptInterface - fun getAutofillData(requestString: String) - - @JavascriptInterface - fun getIncontextSignupDismissedAt(data: String) - - fun injectCredentials(credentials: LoginCredentials) - fun injectNoCredentials() - - fun cancelRetrievingStoredLogins() - - fun acceptGeneratedPassword() - fun rejectGeneratedPassword() - - fun inContextEmailProtectionFlowFinished() - - var callback: Callback? - var emailProtectionInContextCallback: EmailProtectionUserPromptListener? - var emailProtectionInContextSignupFlowCallback: EmailProtectionInContextSignupFlowListener? - var webView: WebView? - var autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor? - var tabId: String? - - companion object { - const val INTERFACE_NAME = "BrowserAutofill" - } - - @JavascriptInterface - fun closeEmailProtectionTab(data: String) -} - -@ContributesBinding(AppScope::class) -class AutofillStoredBackJavascriptInterface @Inject constructor( - private val requestParser: AutofillRequestParser, - private val autofillStore: InternalAutofillStore, - private val shareableCredentials: ShareableCredentials, - private val autofillMessagePoster: AutofillMessagePoster, - private val autofillResponseWriter: AutofillResponseWriter, - @AppCoroutineScope private val coroutineScope: CoroutineScope, - private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(), - private val currentUrlProvider: UrlProvider = WebViewUrlProvider(dispatcherProvider), - private val autofillCapabilityChecker: AutofillCapabilityChecker, - private val passwordEventResolver: AutogeneratedPasswordEventResolver, - private val emailManager: EmailManager, - private val inContextDataStore: EmailProtectionInContextDataStore, - private val recentInstallChecker: EmailProtectionInContextRecentInstallChecker, - private val loginDeduplicator: AutofillLoginDeduplicator, - private val systemAutofillServiceSuppressor: SystemAutofillServiceSuppressor, - private val neverSavedSiteRepository: NeverSavedSiteRepository, -) : AutofillJavascriptInterface { - - override var callback: Callback? = null - override var emailProtectionInContextCallback: EmailProtectionUserPromptListener? = null - override var emailProtectionInContextSignupFlowCallback: EmailProtectionInContextSignupFlowListener? = null - override var webView: WebView? = null - override var autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor? = null - override var tabId: String? = null - - // coroutine jobs tracked for supporting cancellation - private val getAutofillDataJob = ConflatedJob() - private val storeFormDataJob = ConflatedJob() - private val injectCredentialsJob = ConflatedJob() - private val emailProtectionInContextSignupJob = ConflatedJob() - - @JavascriptInterface - override fun getAutofillData(requestString: String) { - Timber.v("BrowserAutofill: getAutofillData called:\n%s", requestString) - getAutofillDataJob += coroutineScope.launch(dispatcherProvider.io()) { - val url = currentUrlProvider.currentUrl(webView) - if (url == null) { - Timber.w("Can't autofill as can't retrieve current URL") - return@launch - } - - if (!autofillCapabilityChecker.canInjectCredentialsToWebView(url)) { - Timber.v("BrowserAutofill: getAutofillData called but feature is disabled") - return@launch - } - - val parseResult = requestParser.parseAutofillDataRequest(requestString) - val request = parseResult.getOrElse { - Timber.w(it, "Unable to parse getAutofillData request") - return@launch - } - - val triggerType = convertTriggerType(request.trigger) - - if (request.mainType != CREDENTIALS) { - handleUnknownRequestMainType(request, url) - return@launch - } - - if (request.isGeneratedPasswordAvailable()) { - handleRequestForPasswordGeneration(url, request) - } else if (request.isAutofillCredentialsRequest()) { - handleRequestForAutofillingCredentials(url, request, triggerType) - } else { - Timber.w("Unable to process request; don't know how to handle request %s", requestString) - } - } - } - - @JavascriptInterface - override fun getIncontextSignupDismissedAt(data: String) { - emailProtectionInContextSignupJob += coroutineScope.launch(dispatcherProvider.io()) { - val permanentDismissalTime = inContextDataStore.timestampUserChoseNeverAskAgain() - val installedRecently = recentInstallChecker.isRecentInstall() - val jsonResponse = autofillResponseWriter.generateResponseForEmailProtectionInContextSignup(installedRecently, permanentDismissalTime) - autofillMessagePoster.postMessage(webView, jsonResponse) - } - } - - @JavascriptInterface - override fun closeEmailProtectionTab(data: String) { - emailProtectionInContextSignupFlowCallback?.closeInContextSignup() - } - - @JavascriptInterface - fun showInContextEmailProtectionSignupPrompt(data: String) { - coroutineScope.launch(dispatcherProvider.io()) { - currentUrlProvider.currentUrl(webView)?.let { - val isSignedIn = emailManager.isSignedIn() - - withContext(dispatcherProvider.main()) { - if (isSignedIn) { - emailProtectionInContextCallback?.showNativeChooseEmailAddressPrompt() - } else { - emailProtectionInContextCallback?.showNativeInContextEmailProtectionSignupPrompt() - } - } - } - } - } - - private suspend fun handleRequestForPasswordGeneration( - url: String, - request: AutofillDataRequest, - ) { - callback?.onGeneratedPasswordAvailableToUse(url, request.generatedPassword?.username, request.generatedPassword?.value!!) - } - - private suspend fun handleRequestForAutofillingCredentials( - url: String, - request: AutofillDataRequest, - triggerType: LoginTriggerType, - ) { - val matches = mutableListOf() - val directMatches = autofillStore.getCredentials(url) - val shareableMatches = shareableCredentials.shareableCredentials(url) - Timber.v("Direct matches: %d, shareable matches: %d for %s", directMatches.size, shareableMatches.size, url) - matches.addAll(directMatches) - matches.addAll(shareableMatches) - - val credentials = filterRequestedSubtypes(request, matches) - - val dedupedCredentials = loginDeduplicator.deduplicate(url, credentials) - Timber.v("Original autofill credentials list size: %d, after de-duping: %d", credentials.size, dedupedCredentials.size) - - val finalCredentialList = ensureUsernamesNotNull(dedupedCredentials) - - if (finalCredentialList.isEmpty()) { - callback?.noCredentialsAvailable(url) - } else { - callback?.onCredentialsAvailableToInject(url, finalCredentialList, triggerType) - } - } - - private fun ensureUsernamesNotNull(credentials: List) = - credentials.map { - if (it.username == null) { - it.copy(username = "") - } else { - it - } - } - - private fun convertTriggerType(trigger: SupportedAutofillTriggerType): LoginTriggerType { - return when (trigger) { - USER_INITIATED -> LoginTriggerType.USER_INITIATED - AUTOPROMPT -> LoginTriggerType.AUTOPROMPT - } - } - - private fun filterRequestedSubtypes( - request: AutofillDataRequest, - credentials: List, - ): List { - return when (request.subType) { - USERNAME -> credentials.filterNot { it.username.isNullOrBlank() } - PASSWORD -> credentials.filterNot { it.password.isNullOrBlank() } - } - } - - private fun handleUnknownRequestMainType( - request: AutofillDataRequest, - url: String, - ) { - Timber.w("Autofill type %s unsupported", request.mainType) - callback?.noCredentialsAvailable(url) - } - - @JavascriptInterface - fun storeFormData(data: String) { - // important to call suppressor as soon as possible - systemAutofillServiceSuppressor.suppressAutofill(webView) - - Timber.i("storeFormData called, credentials provided to be persisted") - - storeFormDataJob += coroutineScope.launch(dispatcherProvider.io()) { - val currentUrl = currentUrlProvider.currentUrl(webView) ?: return@launch - - if (!autofillCapabilityChecker.canSaveCredentialsFromWebView(currentUrl)) { - Timber.v("BrowserAutofill: storeFormData called but feature is disabled") - return@launch - } - - if (neverSavedSiteRepository.isInNeverSaveList(currentUrl)) { - Timber.v("BrowserAutofill: storeFormData called but site is in never save list") - return@launch - } - - val parseResult = requestParser.parseStoreFormDataRequest(data) - val request = parseResult.getOrElse { - Timber.w(it, "Unable to parse storeFormData request") - return@launch - } - - if (!request.isValid()) { - Timber.w("Invalid data from storeFormData") - return@launch - } - - val jsCredentials = JavascriptCredentials(request.credentials!!.username, request.credentials.password) - val credentials = jsCredentials.asLoginCredentials(currentUrl) - - val autologinId = autoSavedLoginsMonitor?.getAutoSavedLoginId(tabId) - Timber.i("Autogenerated? %s, Previous autostored login ID: %s", request.credentials.autogenerated, autologinId) - val autosavedLogin = autologinId?.let { autofillStore.getCredentialsWithId(it) } - - val autogenerated = request.credentials.autogenerated - val actions = passwordEventResolver.decideActions(autosavedLogin, autogenerated) - processStoreFormDataActions(actions, currentUrl, credentials) - } - } - - private suspend fun processStoreFormDataActions( - actions: List, - currentUrl: String, - credentials: LoginCredentials, - ) { - Timber.d("%d actions to take: %s", actions.size, actions.joinToString()) - actions.forEach { - when (it) { - is DeleteAutoLogin -> { - autofillStore.deleteCredentials(it.autologinId) - } - - is DiscardAutoLoginId -> { - autoSavedLoginsMonitor?.clearAutoSavedLoginId(tabId) - } - - is PromptToSave -> { - callback?.onCredentialsAvailableToSave(currentUrl, credentials) - } - - is UpdateSavedAutoLogin -> { - autofillStore.getCredentialsWithId(it.autologinId)?.let { existingCredentials -> - if (isUpdateRequired(existingCredentials, credentials)) { - Timber.v("Update required as not identical to what is already stored. id=%s", it.autologinId) - val toSave = existingCredentials.copy(username = credentials.username, password = credentials.password) - autofillStore.updateCredentials(toSave)?.let { savedCredentials -> - callback?.onCredentialsSaved(savedCredentials) - } - } else { - Timber.v("Update not required as identical to what is already stored. id=%s", it.autologinId) - callback?.onCredentialsSaved(existingCredentials) - } - } - } - } - } - } - - private fun isUpdateRequired( - existingCredentials: LoginCredentials, - credentials: LoginCredentials, - ): Boolean { - return existingCredentials.username != credentials.username || existingCredentials.password != credentials.password - } - - private fun AutofillStoreFormDataRequest?.isValid(): Boolean { - if (this == null || credentials == null) return false - return !(credentials.username.isNullOrBlank() && credentials.password.isNullOrBlank()) - } - - override fun injectCredentials(credentials: LoginCredentials) { - Timber.v("Informing JS layer with credentials selected") - injectCredentialsJob += coroutineScope.launch(dispatcherProvider.io()) { - val jsCredentials = credentials.asJsCredentials() - val jsonResponse = autofillResponseWriter.generateResponseGetAutofillData(jsCredentials) - Timber.i("Injecting credentials: %s", jsonResponse) - autofillMessagePoster.postMessage(webView, jsonResponse) - } - } - - override fun injectNoCredentials() { - Timber.v("No credentials selected; informing JS layer") - injectCredentialsJob += coroutineScope.launch(dispatcherProvider.io()) { - autofillMessagePoster.postMessage(webView, autofillResponseWriter.generateEmptyResponseGetAutofillData()) - } - } - - private fun LoginCredentials.asJsCredentials(): JavascriptCredentials { - return JavascriptCredentials( - username = username, - password = password, - ) - } - - override fun cancelRetrievingStoredLogins() { - getAutofillDataJob.cancel() - } - - override fun acceptGeneratedPassword() { - Timber.v("Accepting generated password") - injectCredentialsJob += coroutineScope.launch(dispatcherProvider.io()) { - autofillMessagePoster.postMessage(webView, autofillResponseWriter.generateResponseForAcceptingGeneratedPassword()) - } - } - - override fun rejectGeneratedPassword() { - Timber.v("Rejecting generated password") - injectCredentialsJob += coroutineScope.launch(dispatcherProvider.io()) { - autofillMessagePoster.postMessage(webView, autofillResponseWriter.generateResponseForRejectingGeneratedPassword()) - } - } - - override fun inContextEmailProtectionFlowFinished() { - emailProtectionInContextSignupJob += coroutineScope.launch(dispatcherProvider.io()) { - val json = autofillResponseWriter.generateResponseForEmailProtectionEndOfFlow(emailManager.isSignedIn()) - autofillMessagePoster.postMessage(webView, json) - } - } - - private fun JavascriptCredentials.asLoginCredentials( - url: String, - ): LoginCredentials { - return LoginCredentials( - id = null, - domain = url, - username = username, - password = password, - domainTitle = null, - ) - } - - interface UrlProvider { - suspend fun currentUrl(webView: WebView?): String? - } - - @ContributesBinding(AppScope::class) - class WebViewUrlProvider @Inject constructor(val dispatcherProvider: DispatcherProvider) : UrlProvider { - override suspend fun currentUrl(webView: WebView?): String? { - return withContext(dispatcherProvider.main()) { - webView?.url - } - } - } -} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillSupportWarningUI.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillSupportWarningUI.kt new file mode 100644 index 000000000000..44e7ea373467 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillSupportWarningUI.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import androidx.core.view.isVisible +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin +import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin.Companion.PRIORITY_KEY_AUTOFILL_SUPPORT_WARNING +import com.duckduckgo.autofill.impl.databinding.ViewAutofillWarningSupportBinding +import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.di.scopes.ViewScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.android.support.AndroidSupportInjection +import javax.inject.Inject + +@ContributesMultibinding(scope = AppScope::class) +@PriorityKey(PRIORITY_KEY_AUTOFILL_SUPPORT_WARNING) +class AutofillSupportWarningUI @Inject constructor( + private val internalAutofillCapabilityChecker: InternalAutofillCapabilityChecker, + private val autofillSurvey: AutofillSurvey, +) : PasswordsScreenPromotionPlugin { + + override suspend fun getView(context: Context, numberSavedPasswords: Int): View? { + val autofillSupported = internalAutofillCapabilityChecker.webViewSupportsAutofill() + if (autofillSupported) return null + + return AutofillSupportWarningView(context) + } +} + +@InjectWith(ViewScope::class) +class AutofillSupportWarningView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : FrameLayout(context, attrs, defStyle) { + + private val binding: ViewAutofillWarningSupportBinding by viewBinding() + + override fun onAttachedToWindow() { + AndroidSupportInjection.inject(this) + super.onAttachedToWindow() + binding.webViewUnsupportedWarningPanel.isVisible = true + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InlineBrowserAutofill.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InlineBrowserAutofill.kt index b29580d8bb3b..58a5b65ab501 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InlineBrowserAutofill.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InlineBrowserAutofill.kt @@ -16,66 +16,112 @@ package com.duckduckgo.autofill.impl +import android.annotation.SuppressLint import android.webkit.WebView +import androidx.webkit.WebViewCompat +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.Callback -import com.duckduckgo.autofill.api.EmailProtectionInContextSignupFlowListener -import com.duckduckgo.autofill.api.EmailProtectionUserPromptListener -import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import timber.log.Timber @ContributesBinding(FragmentScope::class) class InlineBrowserAutofill @Inject constructor( - private val autofillInterface: AutofillJavascriptInterface, - private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor, + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker, + private val dispatchers: DispatcherProvider, + private val autofillJavascriptInjector: AutofillJavascriptInjector, + private val webMessageListeners: PluginPoint, + private val autofillFeature: AutofillFeature, + private val webMessageAttacher: AutofillWebMessageAttacher, ) : BrowserAutofill { - override fun addJsInterface( + override suspend fun addJsInterface( webView: WebView, autofillCallback: Callback, - emailProtectionInContextCallback: EmailProtectionUserPromptListener?, - emailProtectionInContextSignupFlowCallback: EmailProtectionInContextSignupFlowListener?, tabId: String, ) { - Timber.v("Injecting BrowserAutofill interface") - // Adding the interface regardless if the feature is available or not - webView.addJavascriptInterface(autofillInterface, AutofillJavascriptInterface.INTERFACE_NAME) - autofillInterface.webView = webView - autofillInterface.callback = autofillCallback - autofillInterface.emailProtectionInContextCallback = emailProtectionInContextCallback - autofillInterface.autoSavedLoginsMonitor = autoSavedLoginsMonitor - autofillInterface.tabId = tabId - } + withContext(dispatchers.io()) { + if (!autofillCapabilityChecker.webViewSupportsAutofill()) { + Timber.e("Modern javascript integration is not supported on this WebView version; autofill will not work") + return@withContext + } + + if (!autofillFeature.self().isEnabled()) { + Timber.w("Autofill feature is not enabled in remote config; autofill will not work") + return@withContext + } + + if (!autofillCapabilityChecker.canInjectCredentialsToWebView("")) { + Timber.w("Autofill injection on WebView is not supported; autofill will not work") + return@withContext + } - override fun removeJsInterface() { - autofillInterface.webView = null + configureModernIntegration(webView, autofillCallback, tabId) + } } - override fun injectCredentials(credentials: LoginCredentials?) { - if (credentials == null) { - autofillInterface.injectNoCredentials() - } else { - autofillInterface.injectCredentials(credentials) + private suspend fun configureModernIntegration( + webView: WebView, + autofillCallback: Callback, + tabId: String, + ) { + Timber.d("Autofill: Configuring modern integration with %d message listeners", webMessageListeners.getPlugins().size) + + withContext(dispatchers.main()) { + webMessageListeners.getPlugins().forEach { + webView.addWebMessageListener(it, autofillCallback, tabId) + yield() + } + + autofillJavascriptInjector.addDocumentStartJavascript(webView) } } override fun cancelPendingAutofillRequestToChooseCredentials() { - autofillInterface.cancelRetrievingStoredLogins() + webMessageListeners.getPlugins().forEach { + it.cancelOutstandingRequests() + } } - override fun acceptGeneratedPassword() { - autofillInterface.acceptGeneratedPassword() + private fun WebView.addWebMessageListener( + messageListener: AutofillWebMessageListener, + autofillCallback: Callback, + tabId: String, + ) { + webMessageAttacher.addListener(this, messageListener) + messageListener.callback = autofillCallback + messageListener.tabId = tabId } - override fun rejectGeneratedPassword() { - autofillInterface.rejectGeneratedPassword() + override fun notifyPageChanged() { + webMessageListeners.getPlugins().forEach { it.cancelOutstandingRequests() } } +} - override fun inContextEmailProtectionFlowFinished() { - autofillInterface.inContextEmailProtectionFlowFinished() +interface AutofillWebMessageAttacher { + fun addListener( + webView: WebView, + listener: AutofillWebMessageListener, + ) +} + +@SuppressLint("RequiresFeature") +@ContributesBinding(FragmentScope::class) +class AutofillWebMessageAttacherImpl @Inject constructor() : AutofillWebMessageAttacher { + + @SuppressLint("AddWebMessageListenerUsage") + // suppress AddWebMessageListenerUsage, we don't have access to DuckDuckGoWebView here. + override fun addListener( + webView: WebView, + listener: AutofillWebMessageListener, + ) { + WebViewCompat.addWebMessageListener(webView, listener.key, listener.origins, listener) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImpl.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InternalAutofillCapabilityChecker.kt similarity index 69% rename from autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImpl.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InternalAutofillCapabilityChecker.kt index 6b067e1224c1..6ac440fa2639 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImpl.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InternalAutofillCapabilityChecker.kt @@ -19,19 +19,57 @@ package com.duckduckgo.autofill.impl import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.InternalTestUserChecker +import com.duckduckgo.autofill.impl.configuration.integration.JavascriptCommunicationSupport import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import kotlinx.coroutines.withContext +/** + * Used to check the status of various Autofill features. + * + * Whether autofill features are enabled depends on a variety of inputs. This class provides a single way to query the status of all of them. + */ +interface InternalAutofillCapabilityChecker : AutofillCapabilityChecker { + + /** + * Whether autofill is supported in the current environment. + */ + suspend fun webViewSupportsAutofill(): Boolean + + /** + * Whether autofill can inject credentials into a WebView for the given page. + * @param url The URL of the webpage to check. + */ + suspend fun canInjectCredentialsToWebView(url: String): Boolean + + /** + * Whether autofill can save credentials from a WebView for the given page. + * @param url The URL of the webpage to check. + */ + suspend fun canSaveCredentialsFromWebView(url: String): Boolean + + /** + * Whether autofill can generate a password into a WebView for the given page. + * @param url The URL of the webpage to check. + */ + suspend fun canGeneratePasswordFromWebView(url: String): Boolean + + /** + * Whether autofill is configured to be enabled. This is a configuration value, not a user preference. + */ + suspend fun isAutofillEnabledByConfiguration(url: String): Boolean +} + @ContributesBinding(AppScope::class) class AutofillCapabilityCheckerImpl @Inject constructor( private val autofillFeature: AutofillFeature, private val internalTestUserChecker: InternalTestUserChecker, private val autofillGlobalCapabilityChecker: AutofillGlobalCapabilityChecker, + private val javascriptCommunicationSupport: JavascriptCommunicationSupport, private val dispatcherProvider: DispatcherProvider, -) : AutofillCapabilityChecker { +) : InternalAutofillCapabilityChecker { override suspend fun canInjectCredentialsToWebView(url: String): Boolean = withContext(dispatcherProvider.io()) { if (!isSecureAutofillAvailable()) return@withContext false @@ -75,6 +113,10 @@ class AutofillCapabilityCheckerImpl @Inject constructor( return@withContext autofillFeature.canAccessCredentialManagement().isEnabled() } + override suspend fun webViewSupportsAutofill(): Boolean { + return javascriptCommunicationSupport.supportsModernIntegration() + } + private suspend fun isInternalTester(): Boolean { return withContext(dispatcherProvider.io()) { internalTestUserChecker.isInternalTestUser @@ -93,3 +135,8 @@ class AutofillCapabilityCheckerImpl @Inject constructor( private suspend fun isAutofillEnabledByUser() = autofillGlobalCapabilityChecker.isAutofillEnabledByUser() } + +@ContributesBinding(AppScope::class) +class DefaultCapabilityChecker @Inject constructor( + private val capabilityChecker: InternalAutofillCapabilityChecker, +) : AutofillCapabilityChecker by capabilityChecker diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreator.kt index ef5ea4187e8f..d89645591737 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreator.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreator.kt @@ -17,7 +17,6 @@ package com.duckduckgo.autofill.impl import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.credential.saving.DuckAddressLoginCreator import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor @@ -35,7 +34,7 @@ import timber.log.Timber class RealDuckAddressLoginCreator @Inject constructor( private val autofillStore: InternalAutofillStore, private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor, - private val autofillCapabilityChecker: AutofillCapabilityChecker, + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatchers: DispatcherProvider, private val neverSavedSiteRepository: NeverSavedSiteRepository, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt index 75fa4a587438..72d7d00ac5cc 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * Copyright (c) 2024 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,10 +16,10 @@ package com.duckduckgo.autofill.impl.configuration -import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextAvailabilityRules import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials @@ -31,10 +31,7 @@ import javax.inject.Inject import timber.log.Timber interface AutofillRuntimeConfigProvider { - suspend fun getRuntimeConfiguration( - rawJs: String, - url: String?, - ): String + suspend fun getRuntimeConfiguration(url: String?): String } @ContributesBinding(AppScope::class) @@ -42,14 +39,14 @@ class RealAutofillRuntimeConfigProvider @Inject constructor( private val emailManager: EmailManager, private val autofillStore: InternalAutofillStore, private val runtimeConfigurationWriter: RuntimeConfigurationWriter, - private val autofillCapabilityChecker: AutofillCapabilityChecker, + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker, private val autofillFeature: AutofillFeature, private val shareableCredentials: ShareableCredentials, private val emailProtectionInContextAvailabilityRules: EmailProtectionInContextAvailabilityRules, private val neverSavedSiteRepository: NeverSavedSiteRepository, ) : AutofillRuntimeConfigProvider { + override suspend fun getRuntimeConfiguration( - rawJs: String, url: String?, ): String { Timber.v("BrowserAutofill: getRuntimeConfiguration called") @@ -66,11 +63,17 @@ class RealAutofillRuntimeConfigProvider @Inject constructor( ) val availableInputTypes = generateAvailableInputTypes(url) - return rawJs - .replace("// INJECT contentScope HERE", contentScope) - .replace("// INJECT userUnprotectedDomains HERE", userUnprotectedDomains) - .replace("// INJECT userPreferences HERE", userPreferences) - .replace("// INJECT availableInputTypes HERE", availableInputTypes) + return """ + { + "type": "getRuntimeConfigurationResponse", + "success": { + $contentScope, + $userPreferences, + $availableInputTypes, + $userUnprotectedDomains + } + } + """.trimIndent() } private suspend fun generateAvailableInputTypes(url: String?): String { @@ -80,7 +83,7 @@ class RealAutofillRuntimeConfigProvider @Inject constructor( val json = runtimeConfigurationWriter.generateResponseGetAvailableInputTypes(credentialsAvailable, emailAvailable).also { Timber.v("availableInputTypes for %s: \n%s", url, it) } - return "availableInputTypes = $json" + return """"availableInputTypes" : $json""" } private suspend fun determineIfCredentialsAvailable(url: String?): AvailableInputTypeCredentials { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfigurator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfigurator.kt deleted file mode 100644 index 7d37957ca38d..000000000000 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfigurator.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2022 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.duckduckgo.autofill.impl.configuration - -import android.webkit.WebView -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.autofill.api.AutofillCapabilityChecker -import com.duckduckgo.autofill.api.BrowserAutofill.Configurator -import com.duckduckgo.common.utils.DefaultDispatcherProvider -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import timber.log.Timber - -@ContributesBinding(AppScope::class) -class InlineBrowserAutofillConfigurator @Inject constructor( - private val autofillRuntimeConfigProvider: AutofillRuntimeConfigProvider, - @AppCoroutineScope private val coroutineScope: CoroutineScope, - private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), - private val autofillCapabilityChecker: AutofillCapabilityChecker, - private val autofillJavascriptLoader: AutofillJavascriptLoader, -) : Configurator { - override fun configureAutofillForCurrentPage( - webView: WebView, - url: String?, - ) { - coroutineScope.launch(dispatchers.io()) { - if (canJsBeInjected(url)) { - Timber.v("Injecting autofill JS into WebView for %s", url) - - val rawJs = autofillJavascriptLoader.getAutofillJavascript() - val formatted = autofillRuntimeConfigProvider.getRuntimeConfiguration(rawJs, url) - - withContext(dispatchers.main()) { - webView.evaluateJavascript("javascript:$formatted", null) - } - } else { - Timber.v("Won't inject autofill JS into WebView for: %s", url) - } - } - } - - private suspend fun canJsBeInjected(url: String?): Boolean { - url?.let { - // note, we don't check for autofillEnabledByUser here, as the user-facing preference doesn't cover email - return autofillCapabilityChecker.isAutofillEnabledByConfiguration(it) - } - return false - } -} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RuntimeConfigurationWriter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RuntimeConfigurationWriter.kt index 14a4d073c304..23277813b7e6 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RuntimeConfigurationWriter.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RuntimeConfigurationWriter.kt @@ -60,7 +60,7 @@ class RealRuntimeConfigurationWriter @Inject constructor(val moshi: Moshi) : Run */ override fun generateContentScope(): String { return """ - contentScope = { + "contentScope" : { "features": { "autofill": { "state": "enabled", @@ -68,7 +68,7 @@ class RealRuntimeConfigurationWriter @Inject constructor(val moshi: Moshi) : Run } }, "unprotectedTemporary": [] - }; + } """.trimIndent() } @@ -77,7 +77,7 @@ class RealRuntimeConfigurationWriter @Inject constructor(val moshi: Moshi) : Run */ override fun generateUserUnprotectedDomains(): String { return """ - userUnprotectedDomains = []; + "userUnprotectedDomains" : [] """.trimIndent() } @@ -90,7 +90,7 @@ class RealRuntimeConfigurationWriter @Inject constructor(val moshi: Moshi) : Run unknownUsernameCategorization: Boolean, ): String { return """ - userPreferences = { + "userPreferences" : { "debug": false, "platform": { "name": "android" @@ -107,12 +107,12 @@ class RealRuntimeConfigurationWriter @Inject constructor(val moshi: Moshi) : Run "credentials_saving": $credentialSaving, "inlineIcon_credentials": $showInlineKeyIcon, "emailProtection_incontext_signup": $showInContextEmailProtectionSignup, - "unknown_username_categorization": $unknownUsernameCategorization, + "unknown_username_categorization": $unknownUsernameCategorization } } } } - }; + } """.trimIndent() } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/JavascriptCommunicationSupportImpl.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/JavascriptCommunicationSupportImpl.kt new file mode 100644 index 000000000000..c1f686243ad2 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/JavascriptCommunicationSupportImpl.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration + +import androidx.webkit.WebViewFeature +import com.duckduckgo.browser.api.WebViewVersionProvider +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.compareSemanticVersion +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface JavascriptCommunicationSupport { + suspend fun supportsModernIntegration(): Boolean +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class JavascriptCommunicationSupportImpl @Inject constructor( + private val webViewVersionProvider: WebViewVersionProvider, + private val dispatcherProvider: DispatcherProvider, +) : JavascriptCommunicationSupport { + + override suspend fun supportsModernIntegration(): Boolean = isWebMessageListenerSupported() && isModernSupportAvailable + + private val isModernSupportAvailable by lazy { + autofillRequiredFeatures.forEach { requiredFeature -> + if (!WebViewFeature.isFeatureSupported(requiredFeature)) { + Timber.i("Modern integration is not supported because feature %s is not supported", requiredFeature) + return@lazy false + } + } + + return@lazy true + } + + private suspend fun isWebMessageListenerSupported(): Boolean { + return withContext(dispatcherProvider.io()) { + webViewVersionProvider.getFullVersion() + .compareSemanticVersion(WEB_MESSAGE_LISTENER_WEBVIEW_VERSION)?.let { it >= 0 } ?: false + } && WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + } + + companion object { + private const val WEB_MESSAGE_LISTENER_WEBVIEW_VERSION = "126.0.6478.40" + + /** + * We need all of these to be supported in order to use autofill + */ + private val autofillRequiredFeatures = listOf( + WebViewFeature.DOCUMENT_START_SCRIPT, + WebViewFeature.WEB_MESSAGE_LISTENER, + WebViewFeature.POST_WEB_MESSAGE, + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/AutofillWebMessageListener.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/AutofillWebMessageListener.kt new file mode 100644 index 000000000000..3d0d49c4ee17 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/AutofillWebMessageListener.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener + +import android.annotation.SuppressLint +import androidx.annotation.CheckResult +import androidx.collection.LruCache +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebViewCompat +import com.duckduckgo.autofill.api.Callback +import com.duckduckgo.common.utils.ConflatedJob +import java.util.* + +/** + * Base class for handling autofill web messages, which is how we communicate between JS and native code for autofill + * + * Each web message will have a unique key which is used in the JS when initiating a web message. e.g., `window.ddgGetAutofillData.postMessage()` + * And so each listener will declare which key they respond to. + * + * Each listener can also declare which origins they permit messages to come from. by default it will respond to all origins unless overridden. + * + * When a web message is received, there will be a `reply` object attached which is how the listener can respond back to the JS. + * If the listener needs to interact with the user first, it should call [storeReply] which will return it a `requestId`. + * This `requestId` can then be provided later to [AutofillMessagePoster] which will route the message to the correct receiver in the JS. + * + * The recommended way to declare a new web message listener is add a class which extends this abstract base class and + * annotate it with `@ContributesMultibinding(FragmentScope::class)`. This will then be automatically registered and unregistered + * when a new WebView in a tab is initialised or destroyed. See [InlineBrowserAutofill] for where this automatic registration happens. + */ +abstract class AutofillWebMessageListener : WebViewCompat.WebMessageListener { + + /** + * The key that the JS will use to send a message to this listener + * + * The key needs to be agreed upon between JS-layer and native layer. + * See https://app.asana.com/0/1206851683898855/1206851683898855/f for documentation + */ + abstract val key: String + + /** + * By default, a web message listener can be sent messages from all origins. This can be overridden to restrict to specific origins. + */ + open val origins: Set get() = setOf("*") + + lateinit var callback: Callback + lateinit var tabId: String + + internal val job = ConflatedJob() + + /** + * Called when a web message response should be sent back to the JS + * + * @param message the message to send back. The contents of this message will depend on the specific listener and what the JS schema expects. + * @param requestId the requestId that was provided when calling [storeReply] + * @return true if the message was handled by this listener or false if not + */ + @SuppressLint("RequiresFeature") + fun onResponse( + message: String, + requestId: String, + ): Boolean { + val replier = replyMap[requestId] ?: return false + replier.postMessage(message) + replyMap.remove(requestId) + return true + } + + /** + * Store the reply object so that it can be used later to send a response back to the JS + * + * If the listener can respond immediately, it should do so using the `reply` object it has access to. + * If the listener cannot response immediately, e.g., need user interaction first, can store the reply and access it later. + * + * @param reply the reply object to store + * @return a unique requestId that can be used later to send a response back to the JS. + * This requestId must be provided when later sending the message. e.g., provided to [AutofillMessagePoster] alongside the message. + */ + @CheckResult + protected fun storeReply(reply: JavaScriptReplyProxy): String { + return UUID.randomUUID().toString().also { + replyMap.put(it, reply) + } + } + + /** + * Cancel any outstanding requests and clean up resources + */ + fun cancelOutstandingRequests() { + replyMap.evictAll() + job.cancel() + } + + /** + * Store a small list of reply objects, where the requestId is the key. + * Replies are typically disposed of immediately upon using, but in some edge cases we might not respond and the stored replies are stale. + * Using a LRU cache to limit the number of stale replies we'd keep around. + */ + private val replyMap = LruCache(10) + + companion object { + val duckDuckGoOriginOnly = setOf("https://duckduckgo.com") + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillConfig.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillConfig.kt new file mode 100644 index 000000000000..123bee39b5de --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillConfig.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.impl.configuration.AutofillRuntimeConfigProvider +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@SuppressLint("RequiresFeature") +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +class WebMessageListenerGetAutofillConfig @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val autofillRuntimeConfigProvider: AutofillRuntimeConfigProvider, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgGetRuntimeConfiguration" + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + job += appCoroutineScope.launch(dispatchers.io()) { + val config = autofillRuntimeConfigProvider.getRuntimeConfiguration(sourceOrigin.toString()) + reply.postMessage(config) + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillData.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillData.kt new file mode 100644 index 000000000000..2fb87998cf04 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillData.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.AutofillWebMessageRequest +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker +import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillDataRequest +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputMainType.CREDENTIALS +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.USERNAME +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.AUTOPROMPT +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED +import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter +import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerGetAutofillData @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker, + private val requestParser: AutofillRequestParser, + private val autofillStore: InternalAutofillStore, + private val shareableCredentials: ShareableCredentials, + private val loginDeduplicator: AutofillLoginDeduplicator, + private val responseWriter: AutofillResponseWriter, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgGetAutofillData" + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + runCatching { + val originalUrl: String? = webView.url + + job += appCoroutineScope.launch(dispatchers.io()) { + val requestId = storeReply(reply) + + getAutofillData( + message.data.toString(), + AutofillWebMessageRequest( + requestOrigin = sourceOrigin.toString(), + originalPageUrl = originalUrl, + requestId = requestId, + ), + ) + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } + + private suspend fun getAutofillData(requestString: String, autofillWebMessageRequest: AutofillWebMessageRequest) { + Timber.v("BrowserAutofill: getAutofillData called:\n%s", requestString) + if (autofillWebMessageRequest.originalPageUrl == null) { + Timber.w("Can't autofill as can't retrieve current URL") + return + } + + if (!autofillCapabilityChecker.canInjectCredentialsToWebView(autofillWebMessageRequest.requestOrigin)) { + Timber.v("BrowserAutofill: getAutofillData called but feature is disabled") + return + } + + val parseResult = requestParser.parseAutofillDataRequest(requestString) + val request = parseResult.getOrElse { + Timber.w(it, "Unable to parse getAutofillData request") + return + } + + val triggerType = convertTriggerType(request.trigger) + + if (request.mainType != CREDENTIALS) { + handleUnknownRequestMainType(request, autofillWebMessageRequest) + return + } + + if (request.isGeneratedPasswordAvailable()) { + handleRequestForPasswordGeneration(autofillWebMessageRequest, request) + } else if (request.isAutofillCredentialsRequest()) { + handleRequestForAutofillingCredentials(autofillWebMessageRequest, request, triggerType) + } else { + Timber.w("Unable to process request; don't know how to handle request %s", requestString) + } + } + + private suspend fun handleRequestForPasswordGeneration( + autofillWebMessageRequest: AutofillWebMessageRequest, + request: AutofillDataRequest, + ) { + callback.onGeneratedPasswordAvailableToUse(autofillWebMessageRequest, request.generatedPassword?.username, request.generatedPassword?.value!!) + } + + private fun handleUnknownRequestMainType( + request: AutofillDataRequest, + autofillWebMessageRequest: AutofillWebMessageRequest, + ) { + Timber.w("Autofill type %s unsupported", request.mainType) + onNoCredentialsAvailable(autofillWebMessageRequest) + } + + private suspend fun handleRequestForAutofillingCredentials( + urlRequest: AutofillWebMessageRequest, + request: AutofillDataRequest, + triggerType: LoginTriggerType, + ) { + val matches = mutableListOf() + val directMatches = autofillStore.getCredentials(urlRequest.requestOrigin) + val shareableMatches = shareableCredentials.shareableCredentials(urlRequest.requestOrigin) + Timber.v("Direct matches: %d, shareable matches: %d for %s", directMatches.size, shareableMatches.size, urlRequest.requestOrigin) + matches.addAll(directMatches) + matches.addAll(shareableMatches) + + val credentials = filterRequestedSubtypes(request, matches) + + val dedupedCredentials = loginDeduplicator.deduplicate(urlRequest.requestOrigin, credentials) + Timber.v("Original autofill credentials list size: %d, after de-duping: %d", credentials.size, dedupedCredentials.size) + + val finalCredentialList = ensureUsernamesNotNull(dedupedCredentials) + + if (finalCredentialList.isEmpty()) { + onNoCredentialsAvailable(urlRequest) + } else { + callback.onCredentialsAvailableToInject(urlRequest, finalCredentialList, triggerType) + } + } + + private fun onNoCredentialsAvailable(urlRequest: AutofillWebMessageRequest) { + val message = responseWriter.generateEmptyResponseGetAutofillData() + onResponse(message, urlRequest.requestId) + } + + private fun convertTriggerType(trigger: SupportedAutofillTriggerType): LoginTriggerType { + return when (trigger) { + USER_INITIATED -> LoginTriggerType.USER_INITIATED + AUTOPROMPT -> LoginTriggerType.AUTOPROMPT + } + } + + private fun ensureUsernamesNotNull(credentials: List) = + credentials.map { + if (it.username == null) { + it.copy(username = "") + } else { + it + } + } + + private fun filterRequestedSubtypes( + request: AutofillDataRequest, + credentials: List, + ): List { + return when (request.subType) { + USERNAME -> credentials.filterNot { it.username.isNullOrBlank() } + PASSWORD -> credentials.filterNot { it.password.isNullOrBlank() } + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/EmailProtectionSettingsUrl.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/EmailProtectionSettingsUrl.kt new file mode 100644 index 000000000000..026bfa470306 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/EmailProtectionSettingsUrl.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email + +class EmailProtectionUrl { + + companion object { + fun isEmailProtectionUrl(url: String?): Boolean { + return url?.startsWith(EMAIL_PROTECTION_SETTINGS_URL) == true + } + + private const val EMAIL_PROTECTION_SETTINGS_URL = "https://duckduckgo.com/email" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetAlias.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetAlias.kt new file mode 100644 index 000000000000..cd2f58759ff4 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetAlias.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.Autofill +import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.autofill.api.AutofillWebMessageRequest +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerEmailGetAlias @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val autofillFeature: AutofillFeature, + private val autofill: Autofill, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgEmailProtectionGetAlias" + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + val originalUrl: String? = webView.url + + job += appCoroutineScope.launch(dispatchers.io()) { + val requestOrigin = sourceOrigin.toString() + if (!enabled(requestOrigin)) { + return@launch + } + val requestId = storeReply(reply) + callback.showNativeChooseEmailAddressPrompt( + AutofillWebMessageRequest( + requestOrigin = requestOrigin, + originalPageUrl = originalUrl, + requestId = requestId, + ), + ) + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } + + private fun enabled(url: String): Boolean { + return autofillFeature.self().isEnabled() && !autofill.isAnException(url) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetCapabilities.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetCapabilities.kt new file mode 100644 index 000000000000..b6a0029c02c4 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetCapabilities.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.invoke +import kotlinx.coroutines.launch +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerEmailGetCapabilities @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgEmailProtectionGetCapabilities" + + override val origins: Set + get() = duckDuckGoOriginOnly + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + if (!EmailProtectionUrl.isEmailProtectionUrl(webView.url)) return + + job += appCoroutineScope.launch(dispatchers.io()) { + reply.postMessage(generateResponse()) + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } + + private fun generateResponse(): String { + return """ + { + "success" : { + "addUserData" : true, + "getUserData" : true, + "removeUserData" : true + } + } + """.trimIndent() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetUserData.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetUserData.kt new file mode 100644 index 000000000000..47017dd2dac9 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailGetUserData.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerEmailGetUserData @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val emailManager: EmailManager, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgEmailProtectionGetUserData" + + override val origins: Set + get() = duckDuckGoOriginOnly + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + if (!EmailProtectionUrl.isEmailProtectionUrl(webView.url)) return + + job += appCoroutineScope.launch(dispatchers.io()) { + reply.postMessage(generateResponse()) + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } + + private fun generateResponse(): String { + val userData = emailManager.getUserData() + return """ + { + "success" : $userData + } + """.trimIndent() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailRemoveCredentials.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailRemoveCredentials.kt new file mode 100644 index 000000000000..e33fe7d5d13b --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailRemoveCredentials.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerEmailRemoveCredentials @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val emailManager: EmailManager, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgEmailProtectionRemoveUserData" + + override val origins: Set + get() = duckDuckGoOriginOnly + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + if (!EmailProtectionUrl.isEmailProtectionUrl(webView.url)) return + + appCoroutineScope.launch(dispatchers.io()) { + emailManager.signOut() + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailStoreCredentials.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailStoreCredentials.kt new file mode 100644 index 000000000000..61feb0c08662 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/WebMessageListenerEmailStoreCredentials.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerEmailStoreCredentials @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val emailManager: EmailManager, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgEmailProtectionStoreUserData" + + override val origins: Set + get() = duckDuckGoOriginOnly + + private val moshi by lazy { Moshi.Builder().add(KotlinJsonAdapterFactory()).build() } + private val requestParser by lazy { moshi.adapter(IncomingMessage::class.java) } + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + if (!EmailProtectionUrl.isEmailProtectionUrl(webView.url)) return + + appCoroutineScope.launch(dispatchers.io()) { + parseIncomingMessage(message.data.toString())?.let { + emailManager.storeCredentials(it.token, it.userName, it.cohort) + Timber.i("Saved email protection credentials for user %s", it.userName) + } + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } + + private fun parseIncomingMessage(message: String): IncomingMessage? { + return kotlin.runCatching { + return requestParser.fromJson(message) + }.onFailure { Timber.w(it, "Failed to parse incoming email protection save message") }.getOrNull() + } + + private data class IncomingMessage( + val token: String, + val userName: String, + val cohort: String, + ) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerCloseEmailProtectionTab.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerCloseEmailProtectionTab.kt new file mode 100644 index 000000000000..6f6adedc7058 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerCloseEmailProtectionTab.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email.incontext + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat.WebMessageListener +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener.Companion.duckDuckGoOriginOnly +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email.EmailProtectionUrl +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerCloseEmailProtectionTab @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, +) : WebMessageListener { + + lateinit var callback: CloseEmailProtectionTabCallback + + val key: String + get() = "ddgCloseEmailProtectionTab" + + val origins: Set + get() = duckDuckGoOriginOnly + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + if (!EmailProtectionUrl.isEmailProtectionUrl(webView.url)) return + + appCoroutineScope.launch(dispatchers.io()) { + callback.closeNativeInContextEmailProtectionSignup() + } + } + + interface CloseEmailProtectionTabCallback { + suspend fun closeNativeInContextEmailProtectionSignup() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerGetIncontextSignupDismissedAt.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerGetIncontextSignupDismissedAt.kt new file mode 100644 index 000000000000..d792698d09f9 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerGetIncontextSignupDismissedAt.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email.incontext + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker +import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore +import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerGetIncontextSignupDismissedAt @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val autofillResponseWriter: AutofillResponseWriter, + private val inContextDataStore: EmailProtectionInContextDataStore, + private val recentInstallChecker: EmailProtectionInContextRecentInstallChecker, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgGetIncontextSignupDismissedAt" + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + job += appCoroutineScope.launch(dispatchers.io()) { + reply.postMessage(generateResponse()) + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } + + private suspend fun generateResponse(): String { + val permanentDismissalTime = inContextDataStore.timestampUserChoseNeverAskAgain() + val installedRecently = recentInstallChecker.isRecentInstall() + return autofillResponseWriter.generateResponseForEmailProtectionInContextSignup(installedRecently, permanentDismissalTime) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerShowInContextEmailProtectionSignupPrompt.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerShowInContextEmailProtectionSignupPrompt.kt new file mode 100644 index 000000000000..e004ff35d021 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/email/incontext/WebMessageListenerShowInContextEmailProtectionSignupPrompt.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email.incontext + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.AutofillWebMessageRequest +import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerShowInContextEmailProtectionSignupPrompt @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val emailManager: EmailManager, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgShowInContextEmailProtectionSignupPrompt" + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + val originalUrl: String? = webView.url + + job += appCoroutineScope.launch(dispatchers.io()) { + val requestOrigin = sourceOrigin.toString() + val requestId = storeReply(reply) + + val autofillWebMessageRequest = AutofillWebMessageRequest( + requestOrigin = requestOrigin, + originalPageUrl = originalUrl, + requestId = requestId, + ) + showInContextEmailProtectionSignupPrompt(autofillWebMessageRequest) + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } + + private fun showInContextEmailProtectionSignupPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { + appCoroutineScope.launch(dispatchers.io()) { + val isSignedIn = emailManager.isSignedIn() + + withContext(dispatchers.main()) { + if (isSignedIn) { + callback.showNativeChooseEmailAddressPrompt(autofillWebMessageRequest) + } else { + callback.showNativeInContextEmailProtectionSignupPrompt(autofillWebMessageRequest) + } + } + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/password/WebMessageListenerStoreFormData.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/password/WebMessageListenerStoreFormData.kt new file mode 100644 index 000000000000..73bd03268060 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/password/WebMessageListenerStoreFormData.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.password + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.AutofillWebMessageRequest +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.autofill.impl.domain.javascript.JavascriptCredentials +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataRequest +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository +import com.duckduckgo.autofill.impl.systemautofill.SystemAutofillServiceSuppressor +import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions +import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.DeleteAutoLogin +import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.DiscardAutoLoginId +import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.PromptToSave +import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.UpdateSavedAutoLogin +import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.AutogeneratedPasswordEventResolver +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@SingleInstanceIn(FragmentScope::class) +@ContributesMultibinding(FragmentScope::class) +@SuppressLint("RequiresFeature") +class WebMessageListenerStoreFormData @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker, + private val neverSavedSiteRepository: NeverSavedSiteRepository, + private val requestParser: AutofillRequestParser, + private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor, + private val autofillStore: InternalAutofillStore, + private val passwordEventResolver: AutogeneratedPasswordEventResolver, + private val systemAutofillServiceSuppressor: SystemAutofillServiceSuppressor, +) : AutofillWebMessageListener() { + + override val key: String + get() = "ddgStoreFormData" + + override fun onPostMessage( + webView: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: JavaScriptReplyProxy, + ) { + kotlin.runCatching { + // important to call suppressor as soon as possible + systemAutofillServiceSuppressor.suppressAutofill(webView) + + val originalUrl: String? = webView.url + + appCoroutineScope.launch(dispatchers.io()) { + val requestOrigin = sourceOrigin.toString() + val requestId = storeReply(reply) + storeFormData( + message.data.toString(), + AutofillWebMessageRequest(requestOrigin = requestOrigin, originalPageUrl = originalUrl, requestId = requestId), + ) + } + }.onFailure { + Timber.e(it, "Error while processing autofill web message for %s", key) + } + } + + private suspend fun storeFormData( + data: String, + autofillWebMessageRequest: AutofillWebMessageRequest, + ) { + Timber.i("storeFormData called, credentials provided to be persisted") + + if (autofillWebMessageRequest.originalPageUrl == null) return + + if (!autofillCapabilityChecker.canSaveCredentialsFromWebView(autofillWebMessageRequest.requestOrigin)) { + Timber.v("BrowserAutofill: storeFormData called but feature is disabled") + return + } + + if (neverSavedSiteRepository.isInNeverSaveList(autofillWebMessageRequest.requestOrigin)) { + Timber.v("BrowserAutofill: storeFormData called but site is in never save list") + return + } + + val parseResult = requestParser.parseStoreFormDataRequest(data) + val request = parseResult.getOrElse { + Timber.w(it, "Unable to parse storeFormData request") + return + } + + if (!request.isValid()) { + Timber.w("Invalid data from storeFormData") + return + } + + val jsCredentials = JavascriptCredentials(request.credentials!!.username, request.credentials.password) + val credentials = jsCredentials.asLoginCredentials(autofillWebMessageRequest.requestOrigin) + + val autologinId = autoSavedLoginsMonitor.getAutoSavedLoginId(tabId) + Timber.i("Autogenerated? %s, Previous autostored login ID: %s", request.credentials.autogenerated, autologinId) + val autosavedLogin = autologinId?.let { autofillStore.getCredentialsWithId(it) } + + val autogenerated = request.credentials.autogenerated + val actions = passwordEventResolver.decideActions(autosavedLogin, autogenerated) + processStoreFormDataActions(actions, autofillWebMessageRequest, credentials) + } + + private fun isUpdateRequired( + existingCredentials: LoginCredentials, + credentials: LoginCredentials, + ): Boolean { + return existingCredentials.username != credentials.username || existingCredentials.password != credentials.password + } + + private fun AutofillStoreFormDataRequest?.isValid(): Boolean { + if (this == null || credentials == null) return false + return !(credentials.username.isNullOrBlank() && credentials.password.isNullOrBlank()) + } + + private suspend fun processStoreFormDataActions( + actions: List, + autofillWebMessageRequest: AutofillWebMessageRequest, + credentials: LoginCredentials, + ) { + Timber.d("%d actions to take: %s", actions.size, actions.joinToString()) + actions.forEach { + when (it) { + is DeleteAutoLogin -> { + autofillStore.deleteCredentials(it.autologinId) + } + + is DiscardAutoLoginId -> { + autoSavedLoginsMonitor.clearAutoSavedLoginId(tabId) + } + + is PromptToSave -> { + callback.onCredentialsAvailableToSave(autofillWebMessageRequest, credentials) + } + + is UpdateSavedAutoLogin -> { + autofillStore.getCredentialsWithId(it.autologinId)?.let { existingCredentials -> + if (isUpdateRequired(existingCredentials, credentials)) { + Timber.v("Update required as not identical to what is already stored. id=%s", it.autologinId) + val toSave = existingCredentials.copy(username = credentials.username, password = credentials.password) + autofillStore.updateCredentials(toSave)?.let { savedCredentials -> + callback.onCredentialsSaved(savedCredentials) + } + } else { + Timber.v("Update not required as identical to what is already stored. id=%s", it.autologinId) + callback.onCredentialsSaved(existingCredentials) + } + } + } + } + } + } + + private fun JavascriptCredentials.asLoginCredentials( + url: String, + ): LoginCredentials { + return LoginCredentials( + id = null, + domain = url, + username = username, + password = password, + domainTitle = null, + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/di/AutofillModule.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/di/AutofillModule.kt index de1f320f8e45..3bedfd1b7bae 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/di/AutofillModule.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/di/AutofillModule.kt @@ -25,6 +25,7 @@ import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin import com.duckduckgo.autofill.api.InternalTestUserChecker import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizer import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher @@ -159,3 +160,6 @@ interface UnusedAutofillResultPlugin @ContributesPluginPoint(scope = ActivityScope::class, boundType = PasswordsScreenPromotionPlugin::class) private interface PasswordsScreenPromotionPluginPoint + +@ContributesPluginPoint(scope = AppScope::class, boundType = AutofillWebMessageListener::class) +interface UnusedAutofillWebMessageListener diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/EmailProtectionChooseEmailFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/EmailProtectionChooseEmailFragment.kt index 4e446f1c0863..528641767312 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/EmailProtectionChooseEmailFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/EmailProtectionChooseEmailFragment.kt @@ -22,9 +22,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.fragment.app.setFragmentResult import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.Companion.KEY_URL import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.DialogEmailProtectionChooseEmailBinding @@ -89,7 +92,7 @@ class EmailProtectionChooseEmailFragment : BottomSheetDialogFragment(), EmailPro Timber.v("User action: %s", resultType::class.java.simpleName) val result = Bundle().also { - it.putString(EmailProtectionChooseEmailDialog.KEY_URL, getOriginalUrl()) + it.putParcelable(KEY_URL, getWebMessageRequest()) it.putParcelable(EmailProtectionChooseEmailDialog.KEY_RESULT, resultType) } @@ -109,19 +112,19 @@ class EmailProtectionChooseEmailFragment : BottomSheetDialogFragment(), EmailPro } private fun getPersonalAddress() = arguments?.getString(KEY_ADDRESS)!! - private fun getOriginalUrl() = arguments?.getString(EmailProtectionChooseEmailDialog.KEY_URL)!! + private fun getWebMessageRequest() = BundleCompat.getParcelable(requireArguments(), KEY_URL, AutofillWebMessageRequest::class.java)!! private fun getTabId() = arguments?.getString(KEY_TAB_ID)!! companion object { fun instance( personalDuckAddress: String, - url: String, + url: AutofillWebMessageRequest, tabId: String, ): EmailProtectionChooseEmailFragment { val fragment = EmailProtectionChooseEmailFragment() fragment.arguments = Bundle().also { it.putString(KEY_ADDRESS, personalDuckAddress) - it.putString(EmailProtectionChooseEmailDialog.KEY_URL, url) + it.putParcelable(KEY_URL, url) it.putString(KEY_TAB_ID, tabId) } return fragment diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmail.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmail.kt index 6b9e1e58cf8b..177ad636e030 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmail.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmail.kt @@ -16,30 +16,35 @@ package com.duckduckgo.autofill.impl.email -import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.os.Bundle -import android.os.Parcelable +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LAST_USED_DAY -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog -import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.* +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.Companion.KEY_RESULT +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.Companion.KEY_URL +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.DoNotUseEmailProtection +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.UsePersonalEmailAddress +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.UsePrivateAliasAddress +import com.duckduckgo.autofill.api.credential.saving.DuckAddressLoginCreator import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.engagement.DataAutofilledListener +import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_TOOLTIP_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ADDRESS import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ALIAS import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint -import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -47,12 +52,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -@ContributesMultibinding(AppScope::class) +@ContributesMultibinding(FragmentScope::class) class ResultHandlerEmailProtectionChooseEmail @Inject constructor( - private val appBuildConfig: AppBuildConfig, private val emailManager: EmailManager, private val dispatchers: DispatcherProvider, private val pixel: Pixel, + private val messagePoster: AutofillMessagePoster, + private val loginCreator: DuckAddressLoginCreator, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val autofilledListeners: PluginPoint, ) : AutofillFragmentResultsPlugin { @@ -66,52 +72,72 @@ class ResultHandlerEmailProtectionChooseEmail @Inject constructor( ) { Timber.d("${this::class.java.simpleName}: processing result") - val userSelection: EmailProtectionChooseEmailDialog.UseEmailResultType = - result.safeGetParcelable(EmailProtectionChooseEmailDialog.KEY_RESULT) ?: return - val originalUrl = result.getString(EmailProtectionChooseEmailDialog.KEY_URL) ?: return + val userSelection = BundleCompat.getParcelable(result, KEY_RESULT, UseEmailResultType::class.java) ?: return + val autofillWebMessageRequest = BundleCompat.getParcelable(result, KEY_URL, AutofillWebMessageRequest::class.java) ?: return when (userSelection) { UsePersonalEmailAddress -> { - onSelectedToUsePersonalAddress(originalUrl, autofillCallback) + onSelectedToUsePersonalAddress(autofillWebMessageRequest) notifyAutofillListenersDuckAddressFilled() } UsePrivateAliasAddress -> { - onSelectedToUsePrivateAlias(originalUrl, autofillCallback) + onSelectedToUsePrivateAlias(autofillWebMessageRequest, tabId) notifyAutofillListenersDuckAddressFilled() } - DoNotUseEmailProtection -> onSelectedNotToUseEmailProtection() + DoNotUseEmailProtection -> onSelectedNotToUseEmailProtection(autofillWebMessageRequest) } } - private fun onSelectedToUsePersonalAddress(originalUrl: String, autofillCallback: AutofillEventListener) { + private fun onSelectedToUsePersonalAddress(autofillWebMessageRequest: AutofillWebMessageRequest) { appCoroutineScope.launch(dispatchers.io()) { val duckAddress = emailManager.getEmailAddress() ?: return@launch enqueueEmailProtectionPixel(EMAIL_USE_ADDRESS, includeLastUsedDay = true) - withContext(dispatchers.main()) { - autofillCallback.onUseEmailProtectionPersonalAddress(originalUrl, duckAddress) + withContext(dispatchers.io()) { + val message = buildResponseMessage(duckAddress) + messagePoster.postMessage(message, autofillWebMessageRequest.requestId) } emailManager.setNewLastUsedDate() } } - private fun onSelectedToUsePrivateAlias(originalUrl: String, autofillCallback: AutofillEventListener) { + private fun onSelectedToUsePrivateAlias( + autofillWebMessageRequest: AutofillWebMessageRequest, + tabId: String, + ) { appCoroutineScope.launch(dispatchers.io()) { val privateAlias = emailManager.getAlias() ?: return@launch enqueueEmailProtectionPixel(EMAIL_USE_ALIAS, includeLastUsedDay = true) - withContext(dispatchers.main()) { - autofillCallback.onUseEmailProtectionPrivateAlias(originalUrl, privateAlias) - } + val message = buildResponseMessage(privateAlias) + messagePoster.postMessage(message, autofillWebMessageRequest.requestId) + + loginCreator.createLoginForPrivateDuckAddress( + duckAddress = privateAlias, + tabId = tabId, + originalUrl = autofillWebMessageRequest.requestOrigin, + ) emailManager.setNewLastUsedDate() } } - private fun onSelectedNotToUseEmailProtection() { + private fun buildResponseMessage(emailAddress: String): String { + return """ + { + "success": { + "alias": "${emailAddress.removeSuffix("@duck.com")}" + } + } + """.trimIndent() + } + + private fun onSelectedNotToUseEmailProtection(autofillWebMessageRequest: AutofillWebMessageRequest) { + val message = buildResponseMessage("") + messagePoster.postMessage(message, autofillWebMessageRequest.requestId) enqueueEmailProtectionPixel(EMAIL_TOOLTIP_DISMISSED, includeLastUsedDay = false) } @@ -130,15 +156,6 @@ class ResultHandlerEmailProtectionChooseEmail @Inject constructor( ) } - @Suppress("DEPRECATION") - @SuppressLint("NewApi") - private inline fun Bundle.safeGetParcelable(key: String) = - if (appBuildConfig.sdkInt >= Build.VERSION_CODES.TIRAMISU) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) - } - override fun resultKey(tabId: String): String { return EmailProtectionChooseEmailDialog.resultKey(tabId) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignUpWebViewClient.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignUpWebViewClient.kt deleted file mode 100644 index fac79f80a763..000000000000 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignUpWebViewClient.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2023 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.autofill.impl.email.incontext - -import android.graphics.Bitmap -import android.webkit.WebView -import android.webkit.WebViewClient -import javax.inject.Inject - -class EmailProtectionInContextSignUpWebViewClient @Inject constructor( - private val callback: NewPageCallback, -) : WebViewClient() { - - interface NewPageCallback { - fun onPageStarted(url: String) - } - - override fun onPageStarted( - view: WebView?, - url: String?, - favicon: Bitmap?, - ) { - url?.let { callback.onPageStarted(it) } - } -} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupActivity.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupActivity.kt index 1fc81290114e..57bae3b15dc1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupActivity.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupActivity.kt @@ -16,262 +16,30 @@ package com.duckduckgo.autofill.impl.email.incontext -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent import android.os.Bundle -import android.webkit.WebSettings -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.widget.Toolbar -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle +import androidx.fragment.app.commit import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpHandleVerificationLink -import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpScreenNoParams -import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpScreenResult -import com.duckduckgo.autofill.api.EmailProtectionInContextSignupFlowListener -import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.emailprotection.EmailInjector -import com.duckduckgo.autofill.impl.AutofillJavascriptInterface +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpStartScreen import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.ActivityEmailProtectionInContextSignupBinding -import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.ExitButtonAction -import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.ViewState import com.duckduckgo.common.ui.DuckDuckGoActivity -import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.navigation.api.getActivityParams -import com.duckduckgo.user.agent.api.UserAgentProvider -import javax.inject.Inject -import kotlinx.coroutines.launch @InjectWith(ActivityScope::class) -@ContributeToActivityStarter(EmailProtectionInContextSignUpScreenNoParams::class) +@ContributeToActivityStarter(EmailProtectionInContextSignUpStartScreen::class) @ContributeToActivityStarter(EmailProtectionInContextSignUpHandleVerificationLink::class) -class EmailProtectionInContextSignupActivity : - DuckDuckGoActivity(), - EmailProtectionInContextSignUpWebChromeClient.ProgressListener, - EmailProtectionInContextSignUpWebViewClient.NewPageCallback { +class EmailProtectionInContextSignupActivity : DuckDuckGoActivity() { val binding: ActivityEmailProtectionInContextSignupBinding by viewBinding() - private val viewModel: EmailProtectionInContextSignupViewModel by bindViewModel() - - @Inject - lateinit var userAgentProvider: UserAgentProvider - - @Inject - lateinit var dispatchers: DispatcherProvider - - @Inject - lateinit var emailInjector: EmailInjector - - @Inject - lateinit var configurator: BrowserAutofill.Configurator - - @Inject - lateinit var autofillInterface: AutofillJavascriptInterface - - @Inject - lateinit var emailManager: EmailManager - - @Inject - lateinit var pixel: Pixel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) - initialiseToolbar() - setTitle(R.string.autofillEmailProtectionInContextSignUpDialogFeatureName) - configureWebView() - configureBackButtonHandler() - observeViewState() - configureEmailManagerObserver() - loadFirstWebpage(intent) - } - - private fun loadFirstWebpage(intent: Intent?) { - val url = intent?.getActivityParams(EmailProtectionInContextSignUpHandleVerificationLink::class.java)?.url ?: STARTING_URL - binding.webView.loadUrl(url) - - if (url == STARTING_URL) { - viewModel.loadedStartingUrl() - } - } - - private fun configureEmailManagerObserver() { - lifecycleScope.launch(dispatchers.main()) { - repeatOnLifecycle(Lifecycle.State.STARTED) { - emailManager.signedInFlow().collect() { signedIn -> - viewModel.signedInStateUpdated(signedIn, binding.webView.url) - } - } - } - } - - private fun observeViewState() { - lifecycleScope.launch(dispatchers.main()) { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.viewState.collect { viewState -> - when (viewState) { - is ViewState.CancellingInContextSignUp -> cancelInContextSignUp() - is ViewState.ConfirmingCancellationOfInContextSignUp -> confirmCancellationOfInContextSignUp() - is ViewState.NavigatingBack -> navigateWebViewBack() - is ViewState.ShowingWebContent -> showWebContent(viewState) - is ViewState.ExitingAsSuccess -> closeActivityAsSuccessfulSignup() - } - } - } - } - } - - private fun showWebContent(viewState: ViewState.ShowingWebContent) { - when (viewState.urlActions.exitButton) { - ExitButtonAction.Disabled -> getToolbar().navigationIcon = null - ExitButtonAction.ExitWithConfirmation -> { - getToolbar().run { - setNavigationIconAsCross() - setNavigationOnClickListener { confirmCancellationOfInContextSignUp() } - } - } - - ExitButtonAction.ExitWithoutConfirmation -> { - getToolbar().run { - setNavigationIconAsCross() - setNavigationOnClickListener { - viewModel.userCancelledSignupWithoutConfirmation() - } - } - } - - ExitButtonAction.ExitTreatAsSuccess -> { - getToolbar().run { - setNavigationIconAsCross() - setNavigationOnClickListener { closeActivityAsSuccessfulSignup() } - } - } - } - } - - private fun cancelInContextSignUp() { - setResult(EmailProtectionInContextSignUpScreenResult.CANCELLED) - finish() - } - - private fun closeActivityAsSuccessfulSignup() { - setResult(EmailProtectionInContextSignUpScreenResult.SUCCESS) - finish() - } - - private fun navigateWebViewBack() { - val previousUrl = getPreviousWebPageUrl() - binding.webView.goBack() - viewModel.consumedBackNavigation(previousUrl) - } - - private fun confirmCancellationOfInContextSignUp() { - TextAlertDialogBuilder(this) - .setTitle(R.string.autofillEmailProtectionInContextSignUpConfirmExitDialogTitle) - .setPositiveButton(R.string.autofillEmailProtectionInContextSignUpConfirmExitDialogPositiveButton) - .setNegativeButton(R.string.autofillEmailProtectionInContextSignUpConfirmExitDialogNegativeButton) - .addEventListener( - object : TextAlertDialogBuilder.EventListener() { - override fun onPositiveButtonClicked() { - viewModel.onUserDecidedNotToCancelInContextSignUp() - } - - override fun onNegativeButtonClicked() { - viewModel.onUserConfirmedCancellationOfInContextSignUp() - } - }, - ) - .show() - } - - private fun configureBackButtonHandler() { - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - viewModel.onBackButtonPressed(url = binding.webView.url, canGoBack = binding.webView.canGoBack()) - } - }, - ) - } - - private fun initialiseToolbar() { - with(getToolbar()) { - title = getString(R.string.autofillEmailProtectionInContextSignUpDialogFeatureName) - setNavigationIconAsCross() - setNavigationOnClickListener { onBackPressed() } + supportFragmentManager.commit { + replace(R.id.fragment_container, EmailProtectionInContextSignupFragment()) } } - - private fun Toolbar.setNavigationIconAsCross() { - setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) - } - - @SuppressLint("SetJavaScriptEnabled") - private fun configureWebView() { - binding.webView.let { - it.webViewClient = EmailProtectionInContextSignUpWebViewClient(this) - it.webChromeClient = EmailProtectionInContextSignUpWebChromeClient(this) - - it.settings.apply { - userAgentString = userAgentProvider.userAgent() - javaScriptEnabled = true - domStorageEnabled = true - loadWithOverviewMode = true - useWideViewPort = true - builtInZoomControls = true - displayZoomControls = false - mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - setSupportMultipleWindows(true) - databaseEnabled = false - setSupportZoom(true) - } - - it.addJavascriptInterface(autofillInterface, AutofillJavascriptInterface.INTERFACE_NAME) - autofillInterface.webView = it - autofillInterface.emailProtectionInContextSignupFlowCallback = object : EmailProtectionInContextSignupFlowListener { - override fun closeInContextSignup() { - closeActivityAsSuccessfulSignup() - } - } - - emailInjector.addJsInterface(it, {}, {}) - } - } - - companion object { - private const val STARTING_URL = "https://duckduckgo.com/email/start-incontext" - - fun intent(context: Context): Intent { - return Intent(context, EmailProtectionInContextSignupActivity::class.java) - } - } - - override fun onPageStarted(url: String) { - configurator.configureAutofillForCurrentPage(binding.webView, url) - } - - override fun onPageFinished(url: String) { - viewModel.onPageFinished(url) - } - - private fun getPreviousWebPageUrl(): String? { - val webHistory = binding.webView.copyBackForwardList() - val currentIndex = webHistory.currentIndex - if (currentIndex < 0) return null - val previousIndex = currentIndex - 1 - if (previousIndex < 0) return null - return webHistory.getItemAtIndex(previousIndex)?.url - } - - private fun getToolbar() = binding.includeToolbar.toolbar as Toolbar } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupFragment.kt new file mode 100644 index 000000000000..5076f88c9cf6 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupFragment.kt @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.email.incontext + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.webkit.WebSettings +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.Toolbar +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.webkit.WebViewCompat +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillWebMessageRequest +import com.duckduckgo.autofill.api.BrowserAutofill +import com.duckduckgo.autofill.api.Callback +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpHandleVerificationLink +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpScreenResult +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpStartScreen +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType +import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.email.incontext.WebMessageListenerCloseEmailProtectionTab +import com.duckduckgo.autofill.impl.databinding.FragmentEmailProtectionInContextSignupBinding +import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.ExitButtonAction +import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.ViewState +import com.duckduckgo.common.ui.DuckDuckGoFragment +import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.ConflatedJob +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.FragmentViewModelFactory +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.navigation.api.getActivityParams +import com.duckduckgo.user.agent.api.UserAgentProvider +import javax.inject.Inject +import kotlinx.coroutines.launch + +@InjectWith(FragmentScope::class) +class EmailProtectionInContextSignupFragment : + DuckDuckGoFragment(R.layout.fragment_email_protection_in_context_signup), + EmailProtectionInContextSignUpWebChromeClient.ProgressListener, + WebMessageListenerCloseEmailProtectionTab.CloseEmailProtectionTabCallback { + + @Inject + lateinit var userAgentProvider: UserAgentProvider + + @Inject + lateinit var dispatchers: DispatcherProvider + + @Inject + lateinit var browserAutofill: BrowserAutofill + + @Inject + lateinit var emailManager: EmailManager + + @Inject + lateinit var pixel: Pixel + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + @Inject + lateinit var autofillCapabilityChecker: InternalAutofillCapabilityChecker + + @Inject + lateinit var webMessageListener: WebMessageListenerCloseEmailProtectionTab + + val viewModel by lazy { + ViewModelProvider(requireActivity(), viewModelFactory)[EmailProtectionInContextSignupViewModel::class.java] + } + + private val autofillConfigurationJob = ConflatedJob() + + private val binding: FragmentEmailProtectionInContextSignupBinding by viewBinding() + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + initialiseToolbar() + activity?.setTitle(R.string.autofillEmailProtectionInContextSignUpDialogFeatureName) + configureWebView() + configureBackButtonHandler() + observeViewState() + configureEmailManagerObserver() + loadFirstWebpage(activity?.intent) + } + + private fun loadFirstWebpage(intent: Intent?) { + lifecycleScope.launch(dispatchers.main()) { + autofillConfigurationJob.join() + + val url = intent?.getActivityParams(EmailProtectionInContextSignUpHandleVerificationLink::class.java)?.url ?: STARTING_URL + binding.webView.loadUrl(url) + + if (url == STARTING_URL) { + viewModel.loadedStartingUrl() + } + } + } + + private fun configureEmailManagerObserver() { + lifecycleScope.launch(dispatchers.main()) { + repeatOnLifecycle(Lifecycle.State.STARTED) { + emailManager.signedInFlow().collect { signedIn -> + viewModel.signedInStateUpdated(signedIn, binding.webView.url) + } + } + } + } + + private fun observeViewState() { + lifecycleScope.launch(dispatchers.main()) { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collect { viewState -> + when (viewState) { + is ViewState.CancellingInContextSignUp -> cancelInContextSignUp() + is ViewState.ConfirmingCancellationOfInContextSignUp -> confirmCancellationOfInContextSignUp() + is ViewState.NavigatingBack -> navigateWebViewBack() + is ViewState.ShowingWebContent -> showWebContent(viewState) + is ViewState.ExitingAsSuccess -> closeActivityAsSuccessfulSignup() + } + } + } + } + } + + private fun showWebContent(viewState: ViewState.ShowingWebContent) { + when (viewState.urlActions.exitButton) { + ExitButtonAction.Disabled -> getToolbar().navigationIcon = null + ExitButtonAction.ExitWithConfirmation -> { + getToolbar().run { + setNavigationIconAsCross() + setNavigationOnClickListener { confirmCancellationOfInContextSignUp() } + } + } + + ExitButtonAction.ExitWithoutConfirmation -> { + getToolbar().run { + setNavigationIconAsCross() + setNavigationOnClickListener { + viewModel.userCancelledSignupWithoutConfirmation() + } + } + } + + ExitButtonAction.ExitTreatAsSuccess -> { + getToolbar().run { + setNavigationIconAsCross() + setNavigationOnClickListener { + lifecycleScope.launch(dispatchers.io()) { + closeActivityAsSuccessfulSignup() + } + } + } + } + } + } + + private suspend fun cancelInContextSignUp() { + activity?.let { + val intent = viewModel.buildResponseIntent(getMessageRequestId()) + it.setResult(EmailProtectionInContextSignUpScreenResult.CANCELLED, intent) + it.finish() + } + } + + private suspend fun closeActivityAsSuccessfulSignup() { + activity?.let { + val intent = viewModel.buildResponseIntent(getMessageRequestId()) + it.setResult(EmailProtectionInContextSignUpScreenResult.SUCCESS, intent) + it.finish() + } + } + + private fun navigateWebViewBack() { + val previousUrl = getPreviousWebPageUrl() + binding.webView.goBack() + viewModel.consumedBackNavigation(previousUrl) + } + + private fun confirmCancellationOfInContextSignUp() { + context?.let { + TextAlertDialogBuilder(it) + .setTitle(R.string.autofillEmailProtectionInContextSignUpConfirmExitDialogTitle) + .setPositiveButton(R.string.autofillEmailProtectionInContextSignUpConfirmExitDialogPositiveButton) + .setNegativeButton(R.string.autofillEmailProtectionInContextSignUpConfirmExitDialogNegativeButton) + .addEventListener( + object : TextAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked() { + viewModel.onUserDecidedNotToCancelInContextSignUp() + } + + override fun onNegativeButtonClicked() { + viewModel.onUserConfirmedCancellationOfInContextSignUp() + } + }, + ) + .show() + } + } + + private fun configureBackButtonHandler() { + activity?.let { + it.onBackPressedDispatcher.addCallback( + it, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + viewModel.onBackButtonPressed(url = binding.webView.url, canGoBack = binding.webView.canGoBack()) + } + }, + ) + } + } + + private fun initialiseToolbar() { + with(getToolbar()) { + title = getString(R.string.autofillEmailProtectionInContextSignUpDialogFeatureName) + setNavigationIconAsCross() + setNavigationOnClickListener { activity?.onBackPressed() } + } + } + + private fun Toolbar.setNavigationIconAsCross() { + setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) + } + + private fun getMessageRequestId(): String { + val intent = activity?.intent + return intent?.getActivityParams(EmailProtectionInContextSignUpStartScreen::class.java)?.messageRequestId ?: intent?.getActivityParams( + EmailProtectionInContextSignUpHandleVerificationLink::class.java, + )?.messageRequestId!! + } + + @SuppressLint("SetJavaScriptEnabled", "RequiresFeature", "AddWebMessageListenerUsage") + // suppress AddWebMessageListenerUsage, we are not using DuckDuckGo WebView here. + private fun configureWebView() { + binding.webView.let { + it.webChromeClient = EmailProtectionInContextSignUpWebChromeClient(this) + + it.settings.apply { + userAgentString = userAgentProvider.userAgent() + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + setSupportMultipleWindows(true) + databaseEnabled = false + setSupportZoom(true) + } + + autofillConfigurationJob += lifecycleScope.launch(dispatchers.main()) { + if (!autofillCapabilityChecker.webViewSupportsAutofill()) { + activity?.finish() + return@launch + } + + webMessageListener.callback = this@EmailProtectionInContextSignupFragment + WebViewCompat.addWebMessageListener(it, webMessageListener.key, webMessageListener.origins, webMessageListener) + + browserAutofill.addJsInterface( + webView = it, + tabId = "", + autofillCallback = noOpCallback, + ) + } + } + } + + companion object { + private const val STARTING_URL = "https://duckduckgo.com/email/start-incontext" + } + + override fun onPageFinished(url: String) { + viewModel.onPageFinished(url) + } + + private fun getPreviousWebPageUrl(): String? { + val webHistory = binding.webView.copyBackForwardList() + val currentIndex = webHistory.currentIndex + if (currentIndex < 0) return null + val previousIndex = currentIndex - 1 + if (previousIndex < 0) return null + return webHistory.getItemAtIndex(previousIndex)?.url + } + + private fun getToolbar() = (activity as EmailProtectionInContextSignupActivity).binding.includeToolbar.toolbar + + override suspend fun closeNativeInContextEmailProtectionSignup() { + closeActivityAsSuccessfulSignup() + } + + private val noOpCallback = object : Callback { + override suspend fun onCredentialsAvailableToInject( + autofillWebMessageRequest: AutofillWebMessageRequest, + credentials: List, + triggerType: LoginTriggerType, + ) { + } + + override suspend fun onCredentialsAvailableToSave( + autofillWebMessageRequest: AutofillWebMessageRequest, + credentials: LoginCredentials, + ) { + } + + override suspend fun onGeneratedPasswordAvailableToUse( + autofillWebMessageRequest: AutofillWebMessageRequest, + username: String?, + generatedPassword: String, + ) { + } + + override fun showNativeChooseEmailAddressPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { + } + + override fun showNativeInContextEmailProtectionSignupPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { + } + + override fun onCredentialsSaved(savedCredentials: LoginCredentials) { + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupViewModel.kt index 41fc608d3b66..501725fc10d2 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/EmailProtectionInContextSignupViewModel.kt @@ -16,13 +16,17 @@ package com.duckduckgo.autofill.impl.email.incontext +import android.content.Intent import androidx.core.net.toUri import androidx.lifecycle.ViewModel import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpScreenResult +import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.BackButtonAction.NavigateBack import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.Companion.Urls.CHOOSE_ADDRESS import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.Companion.Urls.DEFAULT_URL_ACTIONS +import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.Companion.Urls.EMAIL_SETTINGS_URL import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.Companion.Urls.EMAIL_VERIFICATION_LINK_URL import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.Companion.Urls.IN_CONTEXT_SUCCESS import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.Companion.Urls.REVIEW_INPUT @@ -33,15 +37,18 @@ import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSign import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.ViewState.ExitingAsSuccess import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.ViewState.NavigatingBack import com.duckduckgo.autofill.impl.email.incontext.EmailProtectionInContextSignupViewModel.ViewState.ShowingWebContent +import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_PROTECTION_IN_CONTEXT_MODAL_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_PROTECTION_IN_CONTEXT_MODAL_DISPLAYED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_PROTECTION_IN_CONTEXT_MODAL_EXIT_EARLY_CANCEL import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_PROTECTION_IN_CONTEXT_MODAL_EXIT_EARLY_CONFIRM +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.absoluteString import com.duckduckgo.di.scopes.ActivityScope import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext import timber.log.Timber @ContributesViewModel(ActivityScope::class) @@ -49,6 +56,15 @@ class EmailProtectionInContextSignupViewModel @Inject constructor( private val pixel: Pixel, ) : ViewModel() { + @Inject + lateinit var responseWriter: AutofillResponseWriter + + @Inject + lateinit var emailManager: EmailManager + + @Inject + lateinit var dispatchers: DispatcherProvider + private val _viewState = MutableStateFlow(ShowingWebContent(urlActions = DEFAULT_URL_ACTIONS)) val viewState: StateFlow = _viewState @@ -109,16 +125,27 @@ class EmailProtectionInContextSignupViewModel @Inject constructor( _viewState.value = CancellingInContextSignUp } + suspend fun buildResponseIntent(messageRequestId: String): Intent { + return withContext(dispatchers.io()) { + val isSignedIn = emailManager.isSignedIn() + val message = responseWriter.generateResponseForEmailProtectionEndOfFlow(isSignedIn) + Intent().also { + it.putExtra(EmailProtectionInContextSignUpScreenResult.RESULT_KEY_MESSAGE, message) + it.putExtra(EmailProtectionInContextSignUpScreenResult.RESULT_KEY_REQUEST_ID, messageRequestId) + } + } + } + fun signedInStateUpdated( signedIn: Boolean, url: String?, ) { Timber.i("Now signed in: %s. Current URL is %s", signedIn, url) - if (!signedIn) return + if (!signedIn || url == null) return - if (url?.contains(EMAIL_VERIFICATION_LINK_URL) == true) { - Timber.d("Detected email verification link") + if (url.contains(EMAIL_VERIFICATION_LINK_URL) || url.contains(EMAIL_SETTINGS_URL)) { + Timber.d("Detected email verification link or signed in state") _viewState.value = ExitingAsSuccess } } @@ -168,6 +195,7 @@ class EmailProtectionInContextSignupViewModel @Inject constructor( const val IN_CONTEXT_SUCCESS = "https://duckduckgo.com/email/welcome-incontext" const val EMAIL_VERIFICATION_LINK_URL = "https://duckduckgo.com/email/login?" + const val EMAIL_SETTINGS_URL = "https://duckduckgo.com/email/settings" val DEFAULT_URL_ACTIONS = UrlActions(backButton = NavigateBack, exitButton = ExitWithoutConfirmation) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/ResultHandlerInContextEmailProtectionPrompt.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/ResultHandlerInContextEmailProtectionPrompt.kt index cf078914ed8c..4a0b07f2ff57 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/ResultHandlerInContextEmailProtectionPrompt.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/ResultHandlerInContextEmailProtectionPrompt.kt @@ -16,22 +16,25 @@ package com.duckduckgo.autofill.impl.email.incontext -import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.os.Bundle -import android.os.Parcelable +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog.Companion.KEY_RESULT +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog.Companion.KEY_URL import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog.EmailProtectionInContextSignUpResult -import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog.EmailProtectionInContextSignUpResult.* +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog.EmailProtectionInContextSignUpResult.Cancel +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog.EmailProtectionInContextSignUpResult.DoNotShowAgain +import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog.EmailProtectionInContextSignUpResult.SignUp import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore +import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -39,60 +42,66 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -@ContributesMultibinding(AppScope::class) +@ContributesMultibinding(FragmentScope::class) class ResultHandlerInContextEmailProtectionPrompt @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatchers: DispatcherProvider, private val dataStore: EmailProtectionInContextDataStore, - private val appBuildConfig: AppBuildConfig, + private val messagePoster: AutofillMessagePoster, ) : AutofillFragmentResultsPlugin { - override fun processResult(result: Bundle, context: Context, tabId: String, fragment: Fragment, autofillCallback: AutofillEventListener) { + override fun processResult( + result: Bundle, + context: Context, + tabId: String, + fragment: Fragment, + autofillCallback: AutofillEventListener, + ) { Timber.d("${this::class.java.simpleName}: processing result") - val userSelection = result.safeGetParcelable(EmailProtectionInContextSignUpDialog.KEY_RESULT) ?: return + val userSelection = BundleCompat.getParcelable(result, KEY_RESULT, EmailProtectionInContextSignUpResult::class.java) ?: return + val autofillWebMessageRequest = BundleCompat.getParcelable(result, KEY_URL, AutofillWebMessageRequest::class.java) ?: return appCoroutineScope.launch(dispatchers.io()) { when (userSelection) { - SignUp -> signUpSelected(autofillCallback) - Cancel -> cancelled(autofillCallback) - DoNotShowAgain -> doNotAskAgain(autofillCallback) + SignUp -> signUpSelected(autofillCallback, autofillWebMessageRequest) + Cancel -> cancelled(autofillWebMessageRequest) + DoNotShowAgain -> doNotAskAgain(autofillWebMessageRequest) } } } - private suspend fun signUpSelected(autofillCallback: AutofillEventListener) { + private suspend fun signUpSelected( + autofillCallback: AutofillEventListener, + autofillWebMessageRequest: AutofillWebMessageRequest, + ) { withContext(dispatchers.main()) { - autofillCallback.onSelectedToSignUpForInContextEmailProtection() + autofillCallback.onSelectedToSignUpForInContextEmailProtection(autofillWebMessageRequest) } } - private suspend fun doNotAskAgain(autofillCallback: AutofillEventListener) { + private suspend fun doNotAskAgain(autofillWebMessageRequest: AutofillWebMessageRequest) { Timber.i("User selected to not show sign up for email protection again") dataStore.onUserChoseNeverAskAgain() - notifyEndOfFlow(autofillCallback) + notifyEndOfFlow(autofillWebMessageRequest) } - private suspend fun cancelled(autofillCallback: AutofillEventListener) { + private suspend fun cancelled(autofillWebMessageRequest: AutofillWebMessageRequest) { Timber.i("User cancelled sign up for email protection") - notifyEndOfFlow(autofillCallback) + notifyEndOfFlow(autofillWebMessageRequest) } - private suspend fun notifyEndOfFlow(autofillCallback: AutofillEventListener) { - withContext(dispatchers.main()) { - autofillCallback.onEndOfEmailProtectionInContextSignupFlow() - } + private fun notifyEndOfFlow(autofillWebMessageRequest: AutofillWebMessageRequest) { + val message = """ + { + "success": { + "isSignedIn": false + } + } + """.trimIndent() + messagePoster.postMessage(message, autofillWebMessageRequest.requestId) } override fun resultKey(tabId: String): String { return EmailProtectionInContextSignUpDialog.resultKey(tabId) } - - @Suppress("DEPRECATION") - @SuppressLint("NewApi") - private inline fun Bundle.safeGetParcelable(key: String) = - if (appBuildConfig.sdkInt >= Build.VERSION_CODES.TIRAMISU) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) - } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/prompt/EmailProtectionInContextSignUpPromptFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/prompt/EmailProtectionInContextSignUpPromptFragment.kt index d44b635b2fd2..17cb8d708cc9 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/prompt/EmailProtectionInContextSignUpPromptFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/email/incontext/prompt/EmailProtectionInContextSignUpPromptFragment.kt @@ -22,6 +22,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.fragment.app.setFragmentResult import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider @@ -29,6 +30,7 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpDialog import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.DialogEmailProtectionInContextSignUpBinding @@ -121,6 +123,7 @@ class EmailProtectionInContextSignUpPromptFragment : BottomSheetDialogFragment() val result = Bundle().also { it.putParcelable(EmailProtectionInContextSignUpDialog.KEY_RESULT, resultType) + it.putParcelable(EmailProtectionInContextSignUpDialog.KEY_URL, getAutofillWebMessageRequest()) } parentFragment?.setFragmentResult(EmailProtectionInContextSignUpDialog.resultKey(getTabId()), result) @@ -133,18 +136,22 @@ class EmailProtectionInContextSignUpPromptFragment : BottomSheetDialogFragment() } private fun getTabId() = arguments?.getString(KEY_TAB_ID)!! + private fun getAutofillWebMessageRequest() = BundleCompat.getParcelable(requireArguments(), KEY_URL, AutofillWebMessageRequest::class.java)!! companion object { fun instance( tabId: String, + autofillWebMessageRequest: AutofillWebMessageRequest, ): EmailProtectionInContextSignUpPromptFragment { val fragment = EmailProtectionInContextSignUpPromptFragment() fragment.arguments = Bundle().also { it.putString(KEY_TAB_ID, tabId) + it.putParcelable(KEY_URL, autofillWebMessageRequest) } return fragment } private const val KEY_TAB_ID = "tabId" + private const val KEY_URL = "url" } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListener.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListener.kt index 364504989645..6c07b8d62074 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListener.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListener.kt @@ -18,7 +18,7 @@ package com.duckduckgo.autofill.impl.engagement import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.autofill.impl.PasswordStoreEventListener import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames import com.duckduckgo.browser.api.UserBrowserProperties @@ -51,7 +51,7 @@ class EngagementPasswordAddedListener @Inject constructor( val daysInstalled = userBrowserProperties.daysSinceInstalled() Timber.v("onCredentialAdded. daysInstalled: $daysInstalled") if (daysInstalled < 7) { - pixel.fire(AutofillPixelNames.AUTOFILL_ENGAGEMENT_ONBOARDED_USER, type = UNIQUE) + pixel.fire(AutofillPixelNames.AUTOFILL_ENGAGEMENT_ONBOARDED_USER, type = Unique()) } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt index da76d3a6f18d..8e44d7a04295 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt @@ -17,7 +17,7 @@ package com.duckduckgo.autofill.impl.engagement.store import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_ACTIVE_USER import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_ENABLED_USER import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_STACKED_LOGINS @@ -89,7 +89,7 @@ class DefaultAutofillEngagementRepository @Inject constructor( val numberStoredPasswords = getNumberStoredPasswords() val togglePixel = if (autofillStore.autofillEnabled) AUTOFILL_TOGGLED_ON_SEARCH else AUTOFILL_TOGGLED_OFF_SEARCH val bucket = engagementBucketing.bucketNumberOfSavedPasswords(numberStoredPasswords) - pixel.fire(togglePixel, mapOf("count_bucket" to bucket), type = DAILY) + pixel.fire(togglePixel, mapOf("count_bucket" to bucket), type = Daily()) } private suspend fun DefaultAutofillEngagementRepository.processEvent(engagement: AutofillEngagementEntity) { @@ -102,14 +102,14 @@ class DefaultAutofillEngagementRepository @Inject constructor( val numberStoredPasswords = getNumberStoredPasswords() if (autofilled && searched) { - pixel.fire(AUTOFILL_ENGAGEMENT_ACTIVE_USER, type = DAILY) + pixel.fire(AUTOFILL_ENGAGEMENT_ACTIVE_USER, type = Daily()) val bucket = engagementBucketing.bucketNumberOfSavedPasswords(numberStoredPasswords) - pixel.fire(AUTOFILL_ENGAGEMENT_STACKED_LOGINS, mapOf("count_bucket" to bucket), type = DAILY) + pixel.fire(AUTOFILL_ENGAGEMENT_STACKED_LOGINS, mapOf("count_bucket" to bucket), type = Daily()) } if (searched && numberStoredPasswords >= 10 && autofillStore.autofillEnabled) { - pixel.fire(AUTOFILL_ENGAGEMENT_ENABLED_USER, type = DAILY) + pixel.fire(AUTOFILL_ENGAGEMENT_ENABLED_USER, type = Daily()) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/AutofillMessagePoster.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/AutofillMessagePoster.kt index 092781fc2475..94544d2a0d1f 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/AutofillMessagePoster.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/AutofillMessagePoster.kt @@ -17,48 +17,28 @@ package com.duckduckgo.autofill.impl.jsbridge import android.annotation.SuppressLint -import android.webkit.WebView -import androidx.core.net.toUri -import androidx.webkit.WebMessageCompat -import androidx.webkit.WebViewCompat -import androidx.webkit.WebViewFeature -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn import javax.inject.Inject -import kotlinx.coroutines.withContext import timber.log.Timber interface AutofillMessagePoster { - suspend fun postMessage( - webView: WebView?, - message: String, - ) + fun postMessage(message: String, requestId: String) } -@ContributesBinding(AppScope::class) +@SuppressLint("RequiresFeature") +@SingleInstanceIn(FragmentScope::class) +@ContributesBinding(FragmentScope::class) class AutofillWebViewMessagePoster @Inject constructor( - private val dispatchers: DispatcherProvider, + private val webMessageListeners: PluginPoint, ) : AutofillMessagePoster { - @SuppressLint("RequiresFeature") - override suspend fun postMessage( - webView: WebView?, - message: String, - ) { - webView?.let { wv -> - withContext(dispatchers.main()) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.POST_WEB_MESSAGE)) { - Timber.e("Unable to post web message") - return@withContext - } - - WebViewCompat.postWebMessage(wv, WebMessageCompat(message), WILDCARD_ORIGIN_URL) - } + override fun postMessage(message: String, requestId: String) { + webMessageListeners.getPlugins().firstOrNull { it.onResponse(message, requestId) } ?: { + Timber.w("No listener found for requestId: %s", requestId) } } - - companion object { - private val WILDCARD_ORIGIN_URL = "*".toUri() - } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/response/AutofillDataResponses.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/response/AutofillDataResponses.kt index 4d3e20152b03..50d47d0e9569 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/response/AutofillDataResponses.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/response/AutofillDataResponses.kt @@ -44,6 +44,10 @@ data class RejectGeneratedPasswordResponse( data class RejectGeneratedPassword(val action: String = "rejectGeneratedPassword") } +data class EmailProtectionSignedInResponse( + val success: Boolean, +) + data class EmptyResponse( val type: String = "getAutofillDataResponse", val success: EmptyCredentialResponse, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/response/AutofillResponseWriter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/response/AutofillResponseWriter.kt index 6f9fd01ee623..fa742b14aa14 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/response/AutofillResponseWriter.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/response/AutofillResponseWriter.kt @@ -28,6 +28,7 @@ interface AutofillResponseWriter { fun generateEmptyResponseGetAutofillData(): String fun generateResponseForAcceptingGeneratedPassword(): String fun generateResponseForRejectingGeneratedPassword(): String + fun generateResponseForEmailProtectionIsSignedIn(signedIn: Boolean): String fun generateResponseForEmailProtectionInContextSignup(installedRecently: Boolean, permanentlyDismissedAtTimestamp: Long?): String fun generateResponseForEmailProtectionEndOfFlow(isSignedIn: Boolean): String } @@ -39,6 +40,7 @@ class AutofillJsonResponseWriter @Inject constructor(val moshi: Moshi) : Autofil private val autofillDataAdapterCredentialsUnavailable = moshi.adapter(EmptyResponse::class.java).indent(" ") private val autofillDataAdapterAcceptGeneratedPassword = moshi.adapter(AcceptGeneratedPasswordResponse::class.java).indent(" ") private val autofillDataAdapterRejectGeneratedPassword = moshi.adapter(RejectGeneratedPasswordResponse::class.java).indent(" ") + private val emailProtectionSignedIn = moshi.adapter(EmailProtectionSignedInResponse::class.java).indent(" ") private val emailProtectionDataAdapterInContextSignup = moshi.adapter(EmailProtectionInContextSignupDismissedAtResponse::class.java).indent(" ") private val emailDataAdapterInContextEndOfFlow = moshi.adapter(ShowInContextEmailProtectionSignupPromptResponse::class.java).indent(" ") @@ -66,6 +68,11 @@ class AutofillJsonResponseWriter @Inject constructor(val moshi: Moshi) : Autofil return autofillDataAdapterRejectGeneratedPassword.toJson(topLevelResponse) } + override fun generateResponseForEmailProtectionIsSignedIn(signedIn: Boolean): String { + val response = EmailProtectionSignedInResponse(signedIn) + return emailProtectionSignedIn.toJson(response) + } + override fun generateResponseForEmailProtectionInContextSignup(installedRecently: Boolean, permanentlyDismissedAtTimestamp: Long?): String { val response = DismissedAt(isInstalledRecently = installedRecently, permanentlyDismissedAt = permanentlyDismissedAtTimestamp) val topLevelResponse = EmailProtectionInContextSignupDismissedAtResponse(success = response) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/newtab/AutofillNewTabShortcut.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/newtab/AutofillNewTabShortcut.kt index 0ab89cb0b3f6..240bef0298f4 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/newtab/AutofillNewTabShortcut.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/newtab/AutofillNewTabShortcut.kt @@ -59,9 +59,9 @@ class AutofillNewTabShortcutPlugin @Inject constructor( override suspend fun setUserEnabled(enabled: Boolean) { if (enabled) { - setting.self().setEnabled(Toggle.State(true)) + setting.self().setRawStoredState(Toggle.State(true)) } else { - setting.self().setEnabled(Toggle.State(false)) + setting.self().setRawStoredState(Toggle.State(false)) } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/CredentialAutofillDialogAndroidFactory.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/CredentialAutofillDialogAndroidFactory.kt index badc6a00573c..3873de74840d 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/CredentialAutofillDialogAndroidFactory.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/CredentialAutofillDialogAndroidFactory.kt @@ -17,6 +17,7 @@ package com.duckduckgo.autofill.impl.ui import androidx.fragment.app.DialogFragment +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType import com.duckduckgo.autofill.api.domain.app.LoginCredentials @@ -35,29 +36,29 @@ import javax.inject.Inject class CredentialAutofillDialogAndroidFactory @Inject constructor() : CredentialAutofillDialogFactory { override fun autofillSelectCredentialsDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: List, triggerType: LoginTriggerType, tabId: String, ): DialogFragment { - return AutofillSelectCredentialsDialogFragment.instance(url, credentials, triggerType, tabId) + return AutofillSelectCredentialsDialogFragment.instance(autofillWebMessageRequest, credentials, triggerType, tabId) } override fun autofillSavingCredentialsDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, tabId: String, ): DialogFragment { - return AutofillSavingCredentialsDialogFragment.instance(url, credentials, tabId) + return AutofillSavingCredentialsDialogFragment.instance(autofillWebMessageRequest, credentials, tabId) } override fun autofillSavingUpdatePasswordDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, tabId: String, ): DialogFragment { return AutofillUpdatingExistingCredentialsDialogFragment.instance( - url, + autofillWebMessageRequest, credentials, tabId, CredentialUpdateType.Password, @@ -65,12 +66,12 @@ class CredentialAutofillDialogAndroidFactory @Inject constructor() : CredentialA } override fun autofillSavingUpdateUsernameDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, tabId: String, ): DialogFragment { return AutofillUpdatingExistingCredentialsDialogFragment.instance( - url, + autofillWebMessageRequest, credentials, tabId, CredentialUpdateType.Username, @@ -78,27 +79,27 @@ class CredentialAutofillDialogAndroidFactory @Inject constructor() : CredentialA } override fun autofillGeneratePasswordDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, username: String?, generatedPassword: String, tabId: String, ): DialogFragment { - return AutofillUseGeneratedPasswordDialogFragment.instance(url, username, generatedPassword, tabId) + return AutofillUseGeneratedPasswordDialogFragment.instance(autofillWebMessageRequest, username, generatedPassword, tabId) } override fun autofillEmailProtectionEmailChooserDialog( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, personalDuckAddress: String, tabId: String, ): DialogFragment { return EmailProtectionChooseEmailFragment.instance( personalDuckAddress = personalDuckAddress, - url = url, + url = autofillWebMessageRequest, tabId = tabId, ) } - override fun emailProtectionInContextSignUpDialog(tabId: String): DialogFragment { - return EmailProtectionInContextSignUpPromptFragment.instance(tabId) + override fun emailProtectionInContextSignUpDialog(tabId: String, autofillWebMessageRequest: AutofillWebMessageRequest): DialogFragment { + return EmailProtectionInContextSignUpPromptFragment.instance(tabId, autofillWebMessageRequest) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt index 151aed68cee7..f7b3c3869786 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt @@ -33,6 +33,7 @@ import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.Sync import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.Unknown import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthConfiguration @@ -133,6 +134,7 @@ class AutofillSettingsViewModel @Inject constructor( private val autofillBreakageReportSender: AutofillBreakageReportSender, private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore, private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules, + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker, ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState()) @@ -410,7 +412,11 @@ class AutofillSettingsViewModel @Inject constructor( fun onViewCreated() { if (combineJob != null) return combineJob = viewModelScope.launch(dispatchers.io()) { - _viewState.value = _viewState.value.copy(autofillEnabled = autofillStore.autofillEnabled) + _viewState.value = _viewState.value.copy( + autofillEnabled = autofillStore.autofillEnabled, + isAutofillSupported = autofillCapabilityChecker.webViewSupportsAutofill() && + autofillCapabilityChecker.isAutofillEnabledByConfiguration(""), + ) val allCredentials = autofillStore.getAllCredentials().distinctUntilChanged() val combined = allCredentials.combine(searchQueryFilter) { credentials, filter -> @@ -779,6 +785,7 @@ class AutofillSettingsViewModel @Inject constructor( val credentialSearchQuery: String = "", val reportBreakageState: ReportBreakageState = ReportBreakageState(), val canShowPromo: Boolean = false, + val isAutofillSupported: Boolean = true, ) data class ReportBreakageState( diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementCredentialsMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementCredentialsMode.kt index 1fd755f63afc..5f9e05006555 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementCredentialsMode.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementCredentialsMode.kt @@ -23,11 +23,11 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View +import android.view.autofill.AutofillManager import android.widget.CompoundButton import androidx.appcompat.app.ActionBar import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar -import androidx.core.content.ContextCompat.startActivity import androidx.core.graphics.drawable.toBitmap import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle @@ -237,6 +237,7 @@ class AutofillManagementCredentialsMode : DuckDuckGoFragment(R.layout.fragment_a override fun onDestroyView() { super.onDestroyView() + disableSystemAutofillServiceOnPasswordField() resetToolbarOnExit() binding.removeSaveStateWatcher(saveStateWatcher) } @@ -502,6 +503,9 @@ class AutofillManagementCredentialsMode : DuckDuckGoFragment(R.layout.fragment_a private fun disableSystemAutofillServiceOnPasswordField() { binding.passwordEditText.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + context?.let { + it.getSystemService(AutofillManager::class.java)?.cancel() + } } private fun String.convertBlankToNull(): String? = this.ifBlank { null } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt index e907a6cc3e39..a811f5b0b076 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt @@ -25,6 +25,7 @@ import android.view.ViewGroup import android.widget.CompoundButton import androidx.activity.result.contract.ActivityResultContracts import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.text.toSpanned import androidx.core.view.MenuProvider import androidx.core.view.children import androidx.core.view.updateLayoutParams @@ -67,10 +68,13 @@ import com.duckduckgo.autofill.impl.ui.credential.management.sorting.CredentialG import com.duckduckgo.autofill.impl.ui.credential.management.sorting.InitialExtractor import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionListBuilder import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionMatcher +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.view.SearchBar +import com.duckduckgo.common.ui.view.addClickableLink import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.prependIconToText import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.DispatcherProvider @@ -148,6 +152,31 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill binding.enabledToggle.setOnCheckedChangeListener(globalAutofillToggleListener) } + private fun configureInfoText() { + binding.infoText.addClickableLink( + annotation = "learn_more_link", + textSequence = binding.root.context.prependIconToText( + R.string.credentialManagementAutofillSubtitle, + R.drawable.ic_lock_solid_12, + ).toSpanned(), + onClick = { + launchHelpPage() + }, + ) + } + + private fun launchHelpPage() { + activity?.let { + globalActivityStarter.start( + it, + WebViewActivityWithParams( + url = LEARN_MORE_LINK, + screenTitle = getString(R.string.credentialManagementAutofillHelpPageTitle), + ), + ) + } + } + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -159,6 +188,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill configureCurrentSiteState() observeViewModel() configureToolbar() + configureInfoText() } private fun configurePromotionsContainer() { @@ -302,7 +332,11 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.viewState.collect { state -> - binding.enabledToggle.quietlySetIsChecked(state.autofillEnabled, globalAutofillToggleListener) + if (state.isAutofillSupported) { + binding.enabledToggle.quietlySetIsChecked(state.autofillEnabled, globalAutofillToggleListener) + } else { + binding.enabledToggle.isEnabled = false + } state.logins?.let { credentialsListUpdated(it, state.credentialSearchQuery, state.reportBreakageState.allowBreakageReporting) parentActivity()?.invalidateOptionsMenu() @@ -591,6 +625,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill private const val ARG_CURRENT_URL = "ARG_CURRENT_URL" private const val ARG_PRIVACY_PROTECTION_STATUS = "ARG_PRIVACY_PROTECTION_STATUS" private const val ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE = "ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE" + private const val LEARN_MORE_LINK = "https://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/" } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/AutofillUseGeneratedPasswordDialogFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/AutofillUseGeneratedPasswordDialogFragment.kt index 3476af5319b8..4522dbe5733f 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/AutofillUseGeneratedPasswordDialogFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/AutofillUseGeneratedPasswordDialogFragment.kt @@ -26,11 +26,17 @@ import android.view.TouchDelegate import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.core.os.BundleCompat import androidx.fragment.app.setFragmentResult import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog +import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_PASSWORD +import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_TAB_ID +import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_URL +import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_USERNAME import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.ContentAutofillGeneratePasswordDialogBinding import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames @@ -42,6 +48,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillClipboardIn import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.AutofillUseGeneratedPasswordDialogFragment.DialogEvent.Dismissed import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.AutofillUseGeneratedPasswordDialogFragment.DialogEvent.GeneratedPasswordAccepted import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.AutofillUseGeneratedPasswordDialogFragment.DialogEvent.Shown +import com.duckduckgo.common.ui.view.prependIconToText import com.duckduckgo.common.ui.view.toPx import com.duckduckgo.di.scopes.FragmentScope import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -99,10 +106,14 @@ class AutofillUseGeneratedPasswordDialogFragment : BottomSheetDialogFragment(), private fun configureViews(binding: ContentAutofillGeneratePasswordDialogBinding) { (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED - val originalUrl = getOriginalUrl() configureCloseButton(binding) - configureGeneratePasswordButton(binding, originalUrl) + configureGeneratePasswordButton(binding) configurePasswordField(binding) + configureSubtitleText(binding) + } + + private fun configureSubtitleText(binding: ContentAutofillGeneratePasswordDialogBinding) { + binding.dialogSubtitle.text = binding.root.context.prependIconToText(R.string.saveLoginDialogSubtitle, R.drawable.ic_lock_solid_12) } private fun configurePasswordField(binding: ContentAutofillGeneratePasswordDialogBinding) { @@ -140,18 +151,15 @@ class AutofillUseGeneratedPasswordDialogFragment : BottomSheetDialogFragment(), return appBuildConfig.sdkInt <= VERSION_CODES.S_V2 } - private fun configureGeneratePasswordButton( - binding: ContentAutofillGeneratePasswordDialogBinding, - originalUrl: String, - ) { + private fun configureGeneratePasswordButton(binding: ContentAutofillGeneratePasswordDialogBinding) { binding.useSecurePasswordButton.setOnClickListener { pixelNameDialogEvent(GeneratedPasswordAccepted)?.let { pixel.fire(it) } val result = Bundle().also { - it.putString(UseGeneratedPasswordDialog.KEY_URL, originalUrl) + it.putParcelable(KEY_URL, getAutofillWebMessageRequest()) it.putBoolean(UseGeneratedPasswordDialog.KEY_ACCEPTED, true) - it.putString(UseGeneratedPasswordDialog.KEY_USERNAME, getUsername()) - it.putString(UseGeneratedPasswordDialog.KEY_PASSWORD, getGeneratedPassword()) + it.putString(KEY_USERNAME, getUsername()) + it.putString(KEY_PASSWORD, getGeneratedPassword()) } parentFragment?.setFragmentResult(UseGeneratedPasswordDialog.resultKey(getTabId()), result) @@ -177,7 +185,7 @@ class AutofillUseGeneratedPasswordDialogFragment : BottomSheetDialogFragment(), val result = Bundle().also { it.putBoolean(UseGeneratedPasswordDialog.KEY_ACCEPTED, false) - it.putString(UseGeneratedPasswordDialog.KEY_URL, getOriginalUrl()) + it.putParcelable(KEY_URL, getAutofillWebMessageRequest()) } parentFragment?.setFragmentResult(UseGeneratedPasswordDialog.resultKey(getTabId()), result) @@ -202,15 +210,15 @@ class AutofillUseGeneratedPasswordDialogFragment : BottomSheetDialogFragment(), object GeneratedPasswordAccepted : DialogEvent } - private fun getOriginalUrl() = arguments?.getString(UseGeneratedPasswordDialog.KEY_URL)!! - private fun getUsername() = arguments?.getString(UseGeneratedPasswordDialog.KEY_USERNAME) - private fun getGeneratedPassword() = arguments?.getString(UseGeneratedPasswordDialog.KEY_PASSWORD)!! - private fun getTabId() = arguments?.getString(UseGeneratedPasswordDialog.KEY_TAB_ID)!! + private fun getAutofillWebMessageRequest() = BundleCompat.getParcelable(requireArguments(), KEY_URL, AutofillWebMessageRequest::class.java)!! + private fun getUsername() = arguments?.getString(KEY_USERNAME) + private fun getGeneratedPassword() = arguments?.getString(KEY_PASSWORD)!! + private fun getTabId() = arguments?.getString(KEY_TAB_ID)!! companion object { fun instance( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, username: String?, generatedPassword: String, tabId: String, @@ -218,10 +226,10 @@ class AutofillUseGeneratedPasswordDialogFragment : BottomSheetDialogFragment(), val fragment = AutofillUseGeneratedPasswordDialogFragment() fragment.arguments = Bundle().also { - it.putString(UseGeneratedPasswordDialog.KEY_URL, url) - it.putString(UseGeneratedPasswordDialog.KEY_USERNAME, username) - it.putString(UseGeneratedPasswordDialog.KEY_PASSWORD, generatedPassword) - it.putString(UseGeneratedPasswordDialog.KEY_TAB_ID, tabId) + it.putParcelable(KEY_URL, autofillWebMessageRequest) + it.putString(KEY_USERNAME, username) + it.putString(KEY_PASSWORD, generatedPassword) + it.putString(KEY_TAB_ID, tabId) } return fragment } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPassword.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPassword.kt index 353e4bf75244..230ecbe2d40c 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPassword.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPassword.kt @@ -18,20 +18,25 @@ package com.duckduckgo.autofill.impl.ui.credential.passwordgeneration import android.content.Context import android.os.Bundle +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog +import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_URL import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor import com.duckduckgo.autofill.impl.engagement.DataAutofilledListener +import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster +import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint -import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -39,12 +44,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -@ContributesMultibinding(AppScope::class) +@ContributesMultibinding(FragmentScope::class) class ResultHandlerUseGeneratedPassword @Inject constructor( private val dispatchers: DispatcherProvider, private val autofillStore: InternalAutofillStore, private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor, private val existingCredentialMatchDetector: ExistingCredentialMatchDetector, + private val messagePoster: AutofillMessagePoster, + private val responseWriter: AutofillResponseWriter, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val autofilledListeners: PluginPoint, ) : AutofillFragmentResultsPlugin { @@ -58,28 +65,43 @@ class ResultHandlerUseGeneratedPassword @Inject constructor( ) { Timber.d("${this::class.java.simpleName}: processing result") - val originalUrl = result.getString(UseGeneratedPasswordDialog.KEY_URL) ?: return + val autofillWebMessageRequest = BundleCompat.getParcelable(result, KEY_URL, AutofillWebMessageRequest::class.java) ?: return if (result.getBoolean(UseGeneratedPasswordDialog.KEY_ACCEPTED)) { appCoroutineScope.launch(dispatchers.io()) { - onUserAcceptedToUseGeneratedPassword(result, tabId, originalUrl, autofillCallback) + onUserAcceptedToUseGeneratedPassword(result, tabId, autofillWebMessageRequest) } } else { appCoroutineScope.launch(dispatchers.main()) { - autofillCallback.onRejectGeneratedPassword(originalUrl) + rejectGeneratedPassword(autofillWebMessageRequest) } } } + fun acceptGeneratedPassword(autofillWebMessageRequest: AutofillWebMessageRequest) { + Timber.v("Accepting generated password") + appCoroutineScope.launch(dispatchers.io()) { + val message = responseWriter.generateResponseForAcceptingGeneratedPassword() + messagePoster.postMessage(message, autofillWebMessageRequest.requestId) + } + } + + private fun rejectGeneratedPassword(autofillWebMessageRequest: AutofillWebMessageRequest) { + Timber.v("Rejecting generated password") + appCoroutineScope.launch(dispatchers.io()) { + val message = responseWriter.generateResponseForRejectingGeneratedPassword() + messagePoster.postMessage(message, autofillWebMessageRequest.requestId) + } + } + private suspend fun onUserAcceptedToUseGeneratedPassword( result: Bundle, tabId: String, - originalUrl: String, - callback: AutofillEventListener, + autofillWebMessageRequest: AutofillWebMessageRequest, ) { val username = result.getString(UseGeneratedPasswordDialog.KEY_USERNAME) val password = result.getString(UseGeneratedPasswordDialog.KEY_PASSWORD) ?: return val autologinId = autoSavedLoginsMonitor.getAutoSavedLoginId(tabId) - val matchType = existingCredentialMatchDetector.determine(originalUrl, username, password) + val matchType = existingCredentialMatchDetector.determine(autofillWebMessageRequest.requestOrigin, username, password) Timber.v( "autoSavedLoginId: %s. Match type against existing entries: %s", autologinId, @@ -87,18 +109,18 @@ class ResultHandlerUseGeneratedPassword @Inject constructor( ) if (autologinId == null) { - saveLoginIfNotAlreadySaved(matchType, originalUrl, username, password, tabId) + saveLoginIfNotAlreadySaved(matchType, autofillWebMessageRequest.requestOrigin, username, password, tabId) } else { val existingAutoSavedLogin = autofillStore.getCredentialsWithId(autologinId) if (existingAutoSavedLogin == null) { Timber.w("Can't find saved login with autosavedLoginId: $autologinId") - saveLoginIfNotAlreadySaved(matchType, originalUrl, username, password, tabId) + saveLoginIfNotAlreadySaved(matchType, autofillWebMessageRequest.requestOrigin, username, password, tabId) } else { updateLoginIfDifferent(existingAutoSavedLogin, username, password) } } withContext(dispatchers.main()) { - callback.onAcceptGeneratedPassword(originalUrl) + acceptGeneratedPassword(autofillWebMessageRequest) } autofilledListeners.getPlugins().forEach { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsDialogFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsDialogFragment.kt index 5d12804c3bcf..a0de787a15a5 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsDialogFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsDialogFragment.kt @@ -22,6 +22,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResult import androidx.lifecycle.Lifecycle @@ -32,7 +33,11 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialSavePickerDialog +import com.duckduckgo.autofill.api.CredentialSavePickerDialog.Companion.KEY_CREDENTIALS +import com.duckduckgo.autofill.api.CredentialSavePickerDialog.Companion.KEY_TAB_ID +import com.duckduckgo.autofill.api.CredentialSavePickerDialog.Companion.KEY_URL import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor import com.duckduckgo.autofill.impl.R @@ -64,6 +69,7 @@ import com.duckduckgo.autofill.impl.ui.credential.saving.AutofillSavingCredentia import com.duckduckgo.autofill.impl.ui.credential.saving.AutofillSavingCredentialsDialogFragment.DialogEvent.Shown import com.duckduckgo.autofill.impl.ui.credential.saving.AutofillSavingCredentialsViewModel.ViewState import com.duckduckgo.autofill.impl.ui.credential.saving.declines.AutofillDeclineCounter +import com.duckduckgo.common.ui.view.prependIconToText import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.FragmentViewModelFactory import com.duckduckgo.common.utils.extractDomain @@ -165,6 +171,11 @@ class AutofillSavingCredentialsDialogFragment : BottomSheetDialogFragment(), Cre (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED configureCloseButtons(binding) configureSaveButton(binding) + configureSubtitleText(binding) + } + + private fun configureSubtitleText(binding: ContentAutofillSaveNewCredentialsBinding) { + binding.onboardingSubtitle.text = binding.root.context.prependIconToText(R.string.saveLoginDialogSubtitle, R.drawable.ic_lock_solid_12) } private fun configureSaveButton(binding: ContentAutofillSaveNewCredentialsBinding) { @@ -174,12 +185,12 @@ class AutofillSavingCredentialsDialogFragment : BottomSheetDialogFragment(), Cre pixelNameDialogEvent(Accepted, binding.keyFeaturesContainer.isVisible)?.let { pixel.fire(it) } lifecycleScope.launch(dispatcherProvider.io()) { - faviconManager.persistCachedFavicon(getTabId(), getOriginalUrl()) + faviconManager.persistCachedFavicon(getTabId(), getWebMessageRequest().requestOrigin) } val result = Bundle().also { - it.putString(CredentialSavePickerDialog.KEY_URL, getOriginalUrl()) - it.putParcelable(CredentialSavePickerDialog.KEY_CREDENTIALS, getCredentialsToSave()) + it.putParcelable(KEY_URL, getWebMessageRequest()) + it.putParcelable(KEY_CREDENTIALS, getCredentialsToSave()) } parentFragment?.setFragmentResult(CredentialSavePickerDialog.resultKeyUserChoseToSaveCredentials(getTabId()), result) @@ -206,7 +217,7 @@ class AutofillSavingCredentialsDialogFragment : BottomSheetDialogFragment(), Cre val parentFragmentForResult = parentFragment appCoroutineScope.launch(dispatcherProvider.io()) { - autofillDeclineCounter.userDeclinedToSaveCredentials(getOriginalUrl().extractDomain()) + autofillDeclineCounter.userDeclinedToSaveCredentials(getWebMessageRequest().requestOrigin.extractDomain()) if (autofillDeclineCounter.shouldPromptToDisableAutofill()) { parentFragmentForResult?.setFragmentResult(CredentialSavePickerDialog.resultKeyShouldPromptToDisableAutofill(getTabId()), Bundle()) @@ -218,7 +229,7 @@ class AutofillSavingCredentialsDialogFragment : BottomSheetDialogFragment(), Cre private fun onUserChoseNeverSaveThisSite() { pixelNameDialogEvent(Exclude, isOnboardingMode())?.let { pixel.fire(it) } - viewModel.addSiteToNeverSaveList(getOriginalUrl()) + viewModel.addSiteToNeverSaveList(getWebMessageRequest().requestOrigin) // this is another way to refuse saving credentials, so ensure that normal logic still runs onUserRejectedToSaveCredentials() @@ -273,23 +284,23 @@ class AutofillSavingCredentialsDialogFragment : BottomSheetDialogFragment(), Cre object Exclude : DialogEvent } - private fun getCredentialsToSave() = arguments?.getParcelable(CredentialSavePickerDialog.KEY_CREDENTIALS)!! - private fun getTabId() = arguments?.getString(CredentialSavePickerDialog.KEY_TAB_ID)!! - private fun getOriginalUrl() = arguments?.getString(CredentialSavePickerDialog.KEY_URL)!! + private fun getCredentialsToSave() = BundleCompat.getParcelable(requireArguments(), KEY_CREDENTIALS, LoginCredentials::class.java)!! + private fun getTabId() = requireArguments().getString(KEY_TAB_ID)!! + private fun getWebMessageRequest() = BundleCompat.getParcelable(requireArguments(), KEY_URL, AutofillWebMessageRequest::class.java)!! companion object { fun instance( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, tabId: String, ): AutofillSavingCredentialsDialogFragment { val fragment = AutofillSavingCredentialsDialogFragment() fragment.arguments = Bundle().also { - it.putString(CredentialSavePickerDialog.KEY_URL, url) - it.putParcelable(CredentialSavePickerDialog.KEY_CREDENTIALS, credentials) - it.putString(CredentialSavePickerDialog.KEY_TAB_ID, tabId) + it.putParcelable(KEY_URL, autofillWebMessageRequest) + it.putParcelable(KEY_CREDENTIALS, credentials) + it.putString(KEY_TAB_ID, tabId) } return fragment } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentials.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentials.kt index 25441bf51617..0bdb84d9b312 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentials.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentials.kt @@ -16,17 +16,17 @@ package com.duckduckgo.autofill.impl.ui.credential.saving -import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.os.Bundle -import android.os.Parcelable +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialSavePickerDialog +import com.duckduckgo.autofill.api.CredentialSavePickerDialog.Companion.KEY_CREDENTIALS +import com.duckduckgo.autofill.api.CredentialSavePickerDialog.Companion.KEY_URL import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor import com.duckduckgo.autofill.impl.store.InternalAutofillStore @@ -46,7 +46,6 @@ class ResultHandlerSaveLoginCredentials @Inject constructor( private val dispatchers: DispatcherProvider, private val declineCounter: AutofillDeclineCounter, private val autofillStore: InternalAutofillStore, - private val appBuildConfig: AppBuildConfig, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : AutofillFragmentResultsPlugin { @@ -61,12 +60,11 @@ class ResultHandlerSaveLoginCredentials @Inject constructor( autofillFireproofDialogSuppressor.autofillSaveOrUpdateDialogVisibilityChanged(visible = false) - val originalUrl = result.getString(CredentialSavePickerDialog.KEY_URL) ?: return - val selectedCredentials = - result.safeGetParcelable(CredentialSavePickerDialog.KEY_CREDENTIALS) ?: return + val autofillWebMessageRequest = BundleCompat.getParcelable(result, KEY_URL, AutofillWebMessageRequest::class.java) ?: return + val selectedCredentials = BundleCompat.getParcelable(result, KEY_CREDENTIALS, LoginCredentials::class.java) ?: return appCoroutineScope.launch(dispatchers.io()) { - val savedCredentials = autofillStore.saveCredentials(originalUrl, selectedCredentials) + val savedCredentials = autofillStore.saveCredentials(autofillWebMessageRequest.requestOrigin, selectedCredentials) if (savedCredentials != null) { declineCounter.disableDeclineCounter() @@ -77,15 +75,6 @@ class ResultHandlerSaveLoginCredentials @Inject constructor( } } - @Suppress("DEPRECATION") - @SuppressLint("NewApi") - private inline fun Bundle.safeGetParcelable(key: String) = - if (appBuildConfig.sdkInt >= Build.VERSION_CODES.TIRAMISU) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) - } - override fun resultKey(tabId: String): String { return CredentialSavePickerDialog.resultKeyUserChoseToSaveCredentials(tabId) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/selecting/AutofillSelectCredentialsDialogFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/selecting/AutofillSelectCredentialsDialogFragment.kt index 02eda26dab3a..049320fe7256 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/selecting/AutofillSelectCredentialsDialogFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/selecting/AutofillSelectCredentialsDialogFragment.kt @@ -22,11 +22,17 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.fragment.app.setFragmentResult import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog +import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog.Companion.KEY_CREDENTIALS +import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog.Companion.KEY_TAB_ID +import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog.Companion.KEY_TRIGGER_TYPE +import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog.Companion.KEY_URL_REQUEST import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.api.domain.app.LoginTriggerType.AUTOPROMPT @@ -103,8 +109,7 @@ class AutofillSelectCredentialsDialogFragment : BottomSheetDialogFragment(), Cre private fun configureViews(binding: ContentAutofillSelectCredentialsTooltipBinding) { (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED - val originalUrl = getOriginalUrl() - configureRecyclerView(originalUrl, binding) + configureRecyclerView(getUrlRequest(), binding) configureCloseButton(binding) } @@ -113,10 +118,10 @@ class AutofillSelectCredentialsDialogFragment : BottomSheetDialogFragment(), Cre } private fun configureRecyclerView( - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, binding: ContentAutofillSelectCredentialsTooltipBinding, ) { - binding.availableCredentialsRecycler.adapter = configureAdapter(getAvailableCredentials(originalUrl)) + binding.availableCredentialsRecycler.adapter = configureAdapter(getAvailableCredentials(autofillWebMessageRequest)) } private fun configureAdapter(credentials: List): CredentialsPickerRecyclerAdapter { @@ -131,8 +136,8 @@ class AutofillSelectCredentialsDialogFragment : BottomSheetDialogFragment(), Cre val result = Bundle().also { it.putBoolean(CredentialAutofillPickerDialog.KEY_CANCELLED, false) - it.putString(CredentialAutofillPickerDialog.KEY_URL, getOriginalUrl()) - it.putParcelable(CredentialAutofillPickerDialog.KEY_CREDENTIALS, selectedCredentials) + it.putParcelable(KEY_URL_REQUEST, getUrlRequest()) + it.putParcelable(KEY_CREDENTIALS, selectedCredentials) } parentFragment?.setFragmentResult(CredentialAutofillPickerDialog.resultKey(getTabId()), result) @@ -153,7 +158,7 @@ class AutofillSelectCredentialsDialogFragment : BottomSheetDialogFragment(), Cre val result = Bundle().also { it.putBoolean(CredentialAutofillPickerDialog.KEY_CANCELLED, true) - it.putString(CredentialAutofillPickerDialog.KEY_URL, getOriginalUrl()) + it.putParcelable(KEY_URL_REQUEST, getUrlRequest()) } parentFragment?.setFragmentResult(CredentialAutofillPickerDialog.resultKey(getTabId()), result) @@ -176,20 +181,20 @@ class AutofillSelectCredentialsDialogFragment : BottomSheetDialogFragment(), Cre object Selected : DialogEvent } - private fun getAvailableCredentials(originalUrl: String): List { - val unsortedCredentials = arguments?.getParcelableArrayList(CredentialAutofillPickerDialog.KEY_CREDENTIALS)!! - val grouped = autofillSelectCredentialsGrouper.group(originalUrl, unsortedCredentials) + private fun getAvailableCredentials(autofillWebMessageRequest: AutofillWebMessageRequest): List { + val unsortedCredentials = BundleCompat.getParcelableArrayList(requireArguments(), KEY_CREDENTIALS, LoginCredentials::class.java)!! + val grouped = autofillSelectCredentialsGrouper.group(autofillWebMessageRequest.requestOrigin, unsortedCredentials) return autofillSelectCredentialsListBuilder.buildFlatList(grouped) } - private fun getOriginalUrl() = arguments?.getString(CredentialAutofillPickerDialog.KEY_URL)!! - private fun getTriggerType() = arguments?.getSerializable(CredentialAutofillPickerDialog.KEY_TRIGGER_TYPE) as LoginTriggerType - private fun getTabId() = arguments?.getString(CredentialAutofillPickerDialog.KEY_TAB_ID)!! + private fun getUrlRequest() = BundleCompat.getParcelable(requireArguments(), KEY_URL_REQUEST, AutofillWebMessageRequest::class.java)!! + private fun getTriggerType() = arguments?.getSerializable(KEY_TRIGGER_TYPE) as LoginTriggerType + private fun getTabId() = arguments?.getString(KEY_TAB_ID)!! companion object { fun instance( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: List, triggerType: LoginTriggerType, tabId: String, @@ -199,10 +204,10 @@ class AutofillSelectCredentialsDialogFragment : BottomSheetDialogFragment(), Cre val fragment = AutofillSelectCredentialsDialogFragment() fragment.arguments = Bundle().also { - it.putString(CredentialAutofillPickerDialog.KEY_URL, url) - it.putParcelableArrayList(CredentialAutofillPickerDialog.KEY_CREDENTIALS, cr) - it.putSerializable(CredentialAutofillPickerDialog.KEY_TRIGGER_TYPE, triggerType) - it.putString(CredentialAutofillPickerDialog.KEY_TAB_ID, tabId) + it.putParcelable(KEY_URL_REQUEST, autofillWebMessageRequest) + it.putParcelableArrayList(KEY_CREDENTIALS, cr) + it.putSerializable(KEY_TRIGGER_TYPE, triggerType) + it.putString(KEY_TAB_ID, tabId) } return fragment } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelection.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelection.kt index 1ef8f2569f5e..20ab22b45d93 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelection.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelection.kt @@ -16,26 +16,29 @@ package com.duckduckgo.autofill.impl.ui.credential.selecting -import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.os.Bundle -import android.os.Parcelable +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog +import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog.Companion.KEY_CREDENTIALS +import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog.Companion.KEY_URL_REQUEST import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator +import com.duckduckgo.autofill.impl.domain.javascript.JavascriptCredentials import com.duckduckgo.autofill.impl.engagement.DataAutofilledListener +import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster +import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint -import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -43,17 +46,22 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -@ContributesMultibinding(AppScope::class) +@ContributesMultibinding(FragmentScope::class) class ResultHandlerCredentialSelection @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatchers: DispatcherProvider, private val pixel: Pixel, private val deviceAuthenticator: DeviceAuthenticator, - private val appBuildConfig: AppBuildConfig, private val autofillStore: InternalAutofillStore, + private val messagePoster: AutofillMessagePoster, + private val autofillResponseWriter: AutofillResponseWriter, private val autofilledListeners: PluginPoint, ) : AutofillFragmentResultsPlugin { + override fun resultKey(tabId: String): String { + return CredentialAutofillPickerDialog.resultKey(tabId) + } + override fun processResult( result: Bundle, context: Context, @@ -63,11 +71,11 @@ class ResultHandlerCredentialSelection @Inject constructor( ) { Timber.d("${this::class.java.simpleName}: processing result") - val originalUrl = result.getString(CredentialAutofillPickerDialog.KEY_URL) ?: return + val autofillWebMessageRequest = BundleCompat.getParcelable(result, KEY_URL_REQUEST, AutofillWebMessageRequest::class.java) ?: return if (result.getBoolean(CredentialAutofillPickerDialog.KEY_CANCELLED)) { Timber.v("Autofill: User cancelled credential selection") - autofillCallback.onNoCredentialsChosenForAutofill(originalUrl) + injectNoCredentials(autofillWebMessageRequest) return } @@ -75,20 +83,37 @@ class ResultHandlerCredentialSelection @Inject constructor( processAutofillCredentialSelectionResult( result = result, browserTabFragment = fragment, - autofillCallback = autofillCallback, - originalUrl = originalUrl, + autofillWebMessageRequest = autofillWebMessageRequest, ) } } + private fun injectCredentials( + credentials: LoginCredentials, + autofillWebMessageRequest: AutofillWebMessageRequest, + ) { + Timber.v("Informing JS layer with credentials selected") + appCoroutineScope.launch(dispatchers.io()) { + val jsCredentials = credentials.asJsCredentials() + val jsonResponse = autofillResponseWriter.generateResponseGetAutofillData(jsCredentials) + messagePoster.postMessage(jsonResponse, autofillWebMessageRequest.requestId) + } + } + + private fun injectNoCredentials(autofillWebMessageRequest: AutofillWebMessageRequest) { + Timber.v("No credentials selected; informing JS layer") + appCoroutineScope.launch(dispatchers.io()) { + val jsonResponse = autofillResponseWriter.generateEmptyResponseGetAutofillData() + messagePoster.postMessage(jsonResponse, autofillWebMessageRequest.requestId) + } + } + private suspend fun processAutofillCredentialSelectionResult( result: Bundle, browserTabFragment: Fragment, - autofillCallback: AutofillEventListener, - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, ) { - val selectedCredentials: LoginCredentials = - result.safeGetParcelable(CredentialAutofillPickerDialog.KEY_CREDENTIALS) ?: return + val selectedCredentials = BundleCompat.getParcelable(result, KEY_CREDENTIALS, LoginCredentials::class.java) ?: return selectedCredentials.updateLastUsedTimestamp() @@ -103,19 +128,19 @@ class ResultHandlerCredentialSelection @Inject constructor( Timber.v("Autofill: user selected credential to use, and successfully authenticated") pixel.fire(AutofillPixelNames.AUTOFILL_AUTHENTICATION_TO_AUTOFILL_AUTH_SUCCESSFUL) notifyAutofilledListeners() - autofillCallback.onShareCredentialsForAutofill(originalUrl, selectedCredentials) + injectCredentials(selectedCredentials, autofillWebMessageRequest) } DeviceAuthenticator.AuthResult.UserCancelled -> { Timber.d("Autofill: user selected credential to use, but cancelled without authenticating") pixel.fire(AutofillPixelNames.AUTOFILL_AUTHENTICATION_TO_AUTOFILL_AUTH_CANCELLED) - autofillCallback.onNoCredentialsChosenForAutofill(originalUrl) + injectNoCredentials(autofillWebMessageRequest) } is DeviceAuthenticator.AuthResult.Error -> { Timber.w("Autofill: user selected credential to use, but there was an error when authenticating: ${it.reason}") pixel.fire(AutofillPixelNames.AUTOFILL_AUTHENTICATION_TO_AUTOFILL_AUTH_FAILURE) - autofillCallback.onNoCredentialsChosenForAutofill(originalUrl) + injectNoCredentials(autofillWebMessageRequest) } } } @@ -135,16 +160,10 @@ class ResultHandlerCredentialSelection @Inject constructor( } } - @Suppress("DEPRECATION") - @SuppressLint("NewApi") - private inline fun Bundle.safeGetParcelable(key: String) = - if (appBuildConfig.sdkInt >= Build.VERSION_CODES.TIRAMISU) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) - } - - override fun resultKey(tabId: String): String { - return CredentialAutofillPickerDialog.resultKey(tabId) + private fun LoginCredentials.asJsCredentials(): JavascriptCredentials { + return JavascriptCredentials( + username = username, + password = password, + ) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/AutofillUpdatingExistingCredentialsDialogFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/AutofillUpdatingExistingCredentialsDialogFragment.kt index ee6fd6d155df..f12c0f8700d8 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/AutofillUpdatingExistingCredentialsDialogFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/AutofillUpdatingExistingCredentialsDialogFragment.kt @@ -22,11 +22,17 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.fragment.app.setFragmentResult import androidx.lifecycle.ViewModelProvider import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog +import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Companion.KEY_CREDENTIALS +import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Companion.KEY_CREDENTIAL_UPDATE_TYPE +import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Companion.KEY_TAB_ID +import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Companion.KEY_URL import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor @@ -104,14 +110,14 @@ class AutofillUpdatingExistingCredentialsDialogFragment : BottomSheetDialogFragm private fun configureViews(binding: ContentAutofillUpdateExistingCredentialsBinding) { (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED val credentials = getCredentialsToSave() - val originalUrl = getOriginalUrl() + val webMessageRequest = getWebMessageRequest() val updateType = getUpdateType() Timber.v("Update type is $updateType") configureDialogTitle(binding, updateType) configureCloseButtons(binding) configureUpdatedFieldPreview(binding, credentials, updateType) - configureUpdateButton(binding, originalUrl, credentials, updateType) + configureUpdateButton(binding, webMessageRequest, credentials, updateType) } private fun configureDialogTitle( @@ -130,7 +136,7 @@ class AutofillUpdatingExistingCredentialsDialogFragment : BottomSheetDialogFragm private fun configureUpdateButton( binding: ContentAutofillUpdateExistingCredentialsBinding, - originalUrl: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, updateType: CredentialUpdateType, ) { @@ -143,9 +149,9 @@ class AutofillUpdatingExistingCredentialsDialogFragment : BottomSheetDialogFragm pixelNameDialogEvent(Updated)?.let { pixel.fire(it) } val result = Bundle().also { - it.putString(CredentialUpdateExistingCredentialsDialog.KEY_URL, originalUrl) - it.putParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIALS, credentials) - it.putParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIAL_UPDATE_TYPE, getUpdateType()) + it.putParcelable(KEY_URL, autofillWebMessageRequest) + it.putParcelable(KEY_CREDENTIALS, credentials) + it.putParcelable(KEY_CREDENTIAL_UPDATE_TYPE, getUpdateType()) } parentFragment?.setFragmentResult(CredentialUpdateExistingCredentialsDialog.resultKeyCredentialUpdated(getTabId()), result) @@ -200,16 +206,15 @@ class AutofillUpdatingExistingCredentialsDialogFragment : BottomSheetDialogFragm object Updated : DialogEvent } - private fun getCredentialsToSave() = arguments?.getParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIALS)!! - private fun getTabId() = arguments?.getString(CredentialUpdateExistingCredentialsDialog.KEY_TAB_ID)!! - private fun getOriginalUrl() = arguments?.getString(CredentialUpdateExistingCredentialsDialog.KEY_URL)!! - private fun getUpdateType() = - arguments?.getParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIAL_UPDATE_TYPE)!! + private fun getCredentialsToSave() = BundleCompat.getParcelable(requireArguments(), KEY_CREDENTIALS, LoginCredentials::class.java)!! + private fun getTabId() = arguments?.getString(KEY_TAB_ID)!! + private fun getWebMessageRequest() = BundleCompat.getParcelable(requireArguments(), KEY_URL, AutofillWebMessageRequest::class.java)!! + private fun getUpdateType() = BundleCompat.getParcelable(requireArguments(), KEY_CREDENTIAL_UPDATE_TYPE, CredentialUpdateType::class.java)!! companion object { fun instance( - url: String, + autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials, tabId: String, credentialUpdateType: CredentialUpdateType, @@ -217,10 +222,10 @@ class AutofillUpdatingExistingCredentialsDialogFragment : BottomSheetDialogFragm val fragment = AutofillUpdatingExistingCredentialsDialogFragment() fragment.arguments = Bundle().also { - it.putString(CredentialUpdateExistingCredentialsDialog.KEY_URL, url) - it.putParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIALS, credentials) - it.putString(CredentialUpdateExistingCredentialsDialog.KEY_TAB_ID, tabId) - it.putParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIAL_UPDATE_TYPE, credentialUpdateType) + it.putParcelable(KEY_URL, autofillWebMessageRequest) + it.putParcelable(KEY_CREDENTIALS, credentials) + it.putString(KEY_TAB_ID, tabId) + it.putParcelable(KEY_CREDENTIAL_UPDATE_TYPE, credentialUpdateType) } return fragment } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentials.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentials.kt index a1b7019fb820..5bf017bf188c 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentials.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentials.kt @@ -16,19 +16,18 @@ package com.duckduckgo.autofill.impl.ui.credential.updating -import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.os.Bundle -import android.os.Parcelable +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Companion.KEY_CREDENTIALS import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Companion.KEY_CREDENTIAL_UPDATE_TYPE +import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Companion.KEY_URL import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor @@ -47,7 +46,6 @@ class ResultHandlerUpdateLoginCredentials @Inject constructor( private val autofillFireproofDialogSuppressor: AutofillFireproofDialogSuppressor, private val dispatchers: DispatcherProvider, private val autofillStore: InternalAutofillStore, - private val appBuildConfig: AppBuildConfig, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : AutofillFragmentResultsPlugin { @@ -62,12 +60,12 @@ class ResultHandlerUpdateLoginCredentials @Inject constructor( autofillFireproofDialogSuppressor.autofillSaveOrUpdateDialogVisibilityChanged(visible = false) - val selectedCredentials = result.safeGetParcelable(KEY_CREDENTIALS) ?: return - val originalUrl = result.getString(CredentialUpdateExistingCredentialsDialog.KEY_URL) ?: return - val updateType = result.safeGetParcelable(KEY_CREDENTIAL_UPDATE_TYPE) ?: return + val selectedCredentials = BundleCompat.getParcelable(result, KEY_CREDENTIALS, LoginCredentials::class.java) ?: return + val autofillWebMessageRequest = BundleCompat.getParcelable(result, KEY_URL, AutofillWebMessageRequest::class.java) ?: return + val updateType = BundleCompat.getParcelable(result, KEY_CREDENTIAL_UPDATE_TYPE, CredentialUpdateType::class.java) ?: return appCoroutineScope.launch(dispatchers.io()) { - autofillStore.updateCredentials(originalUrl, selectedCredentials, updateType)?.let { + autofillStore.updateCredentials(autofillWebMessageRequest.requestOrigin, selectedCredentials, updateType)?.let { withContext(dispatchers.main()) { autofillCallback.onUpdatedCredentials(it) } @@ -75,15 +73,6 @@ class ResultHandlerUpdateLoginCredentials @Inject constructor( } } - @Suppress("DEPRECATION") - @SuppressLint("NewApi") - private inline fun Bundle.safeGetParcelable(key: String) = - if (appBuildConfig.sdkInt >= Build.VERSION_CODES.TIRAMISU) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) - } - override fun resultKey(tabId: String): String { return CredentialUpdateExistingCredentialsDialog.resultKeyCredentialUpdated(tabId) } diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_lock_solid_12.xml b/autofill/autofill-impl/src/main/res/drawable/ic_lock_solid_12.xml new file mode 100644 index 000000000000..ce86ecbbc83b --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_lock_solid_12.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/layout/activity_email_protection_in_context_signup.xml b/autofill/autofill-impl/src/main/res/layout/activity_email_protection_in_context_signup.xml index 0494b569d3db..7fe7ceb413b9 100644 --- a/autofill/autofill-impl/src/main/res/layout/activity_email_protection_in_context_signup.xml +++ b/autofill/autofill-impl/src/main/res/layout/activity_email_protection_in_context_signup.xml @@ -28,13 +28,13 @@ app:layout_constraintEnd_toEndOf="parent" layout="@layout/include_default_toolbar" /> - \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/content_autofill_generate_password_dialog.xml b/autofill/autofill-impl/src/main/res/layout/content_autofill_generate_password_dialog.xml index a81a432a8798..41634bc58e27 100644 --- a/autofill/autofill-impl/src/main/res/layout/content_autofill_generate_password_dialog.xml +++ b/autofill/autofill-impl/src/main/res/layout/content_autofill_generate_password_dialog.xml @@ -109,12 +109,11 @@ android:layout_marginTop="24dp" android:enabled="false" android:gravity="center_horizontal" - app:layout_constraintWidth_percent="0.75" + app:textType="secondary" app:typography="body2" app:layout_constraintEnd_toEndOf="@id/useSecurePasswordButton" app:layout_constraintStart_toStartOf="@id/useSecurePasswordButton" - app:layout_constraintTop_toBottomOf="@id/generatedPassword" - android:text="@string/autofill_password_generation_offer_message" /> + app:layout_constraintTop_toBottomOf="@id/generatedPassword" /> - + app:layout_constraintTop_toBottomOf="@id/dialogTitle" /> - + + - \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_bottom.xml b/autofill/autofill-impl/src/main/res/layout/fragment_email_protection_in_context_signup.xml similarity index 56% rename from app/src/main/res/anim/slide_from_bottom.xml rename to autofill/autofill-impl/src/main/res/layout/fragment_email_protection_in_context_signup.xml index 4cc83bb34604..59bf313e7578 100644 --- a/app/src/main/res/anim/slide_from_bottom.xml +++ b/autofill/autofill-impl/src/main/res/layout/fragment_email_protection_in_context_signup.xml @@ -1,5 +1,5 @@ - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/view_autofill_config_disabled_warning.xml b/autofill/autofill-impl/src/main/res/layout/view_autofill_config_disabled_warning.xml new file mode 100644 index 000000000000..fffc2217ec91 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/view_autofill_config_disabled_warning.xml @@ -0,0 +1,28 @@ + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/view_autofill_warning_support.xml b/autofill/autofill-impl/src/main/res/layout/view_autofill_warning_support.xml new file mode 100644 index 000000000000..b0822b6372b3 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/view_autofill_warning_support.xml @@ -0,0 +1,28 @@ + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml index 6ff109c9b038..634a034f9c78 100644 --- a/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Запазване на паролата Запазване на паролата Не записвай - Запазване на тази парола? + Запазване на паролата? Повече опции Използване на запазена парола? @@ -122,8 +122,9 @@ Пароли Все още няма запазени пароли Запазване и автоматично попълване на паролите - Паролите се съхраняват сигурно на Вашето устройство - DuckDuckGo Passwords & Autofill съхранява паролите по сигурен начин на Вашето устройство. + Паролите са криптирани. Никой освен Вас не може да ги види, дори ние. Научете повече + Сигурност на инструмента за управление на пароли + С DuckDuckGo Passwords & Autofill можете да съхранявате паролата по сигурен начин на устройството. Добавяне на парола Заглавие Паролата е изтрита @@ -191,7 +192,7 @@ Уверете се, че все още имате друг начин за достъп до Вашите акаунти. - Паролите от други браузъри или приложения могат да бъдат импортирани чрез версията за работен плот на браузъра DuckDuckGo. + Можете да импортирате запазени пароли от друг браузър в DuckDuckGo. Импортиране на пароли Как се импортират пароли diff --git a/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml index aa92d8f89b75..4ac254bc5f35 100644 --- a/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Uložit heslo Uložit heslo Neukládat - Uložit tohle heslo? + Uložit heslo? Další možnosti Použít uložené heslo? @@ -122,8 +122,9 @@ Hesla Zatím nemáš uložená žádná hesla Ukládání a automatické vyplňování hesel - Hesla se bezpečně ukládají do tvého zařízení - Funkce ukládání a automatického vyplňování hesel DuckDuckGo bezpečně ukládá hesla ve tvém zařízení. + Hesla jsou šifrovaná. Nikdo kromě tebe je nevidí, dokonce ani my. Další informace + Zabezpečení správce hesel + Bezpečně si ulož heslo do zařízení pomocí funkce pro ukládání a automatické vyplňování hesel DuckDuckGo. Přidat heslo Název Heslo smazáno @@ -203,7 +204,7 @@ Zkontroluj si předtím, že se k účtům i tak dostaneš. - Hesla z jiných prohlížečů nebo aplikací se dají importovat pomocí verze prohlížeče DuckDuckGo pro počítače. + Uložená hesla můžeš importovat z jiného prohlížeče do DuckDuckGo. Importovat hesla Jak importovat hesla diff --git a/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml index d39e3bb8ab0b..849a456c7ab9 100644 --- a/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Gem adgangskode Gem adgangskode Gem ikke - Gem denne adgangskode? + Gem din adgangskode? Flere muligheder Brug en gemt adgangskode? @@ -122,8 +122,9 @@ Adgangskoder Ingen adgangskoder gemt endnu Gem og udfyld adgangskoder automatisk - Adgangskoder gemmes sikkert på din enhed - DuckDuckGo adgangskoder og automatisk udfyldning gemmer adgangskoder sikkert på din enhed. + Adgangskoderne er krypterede. Ingen andre end dig kan se dem, ikke engang os. Få mere at vide + Password Manager-sikkerhed + Gem din adgangskode sikkert på enheden med DuckDuckGo Passwords & Autofill. Tilføj adgangskode Titel Adgangskode slettet @@ -191,7 +192,7 @@ Husk at sikre, at du stadig har adgang til dine konti. - Adgangskoder fra andre browsere eller apps kan importeres ved hjælp af computerversionen af DuckDuckGo-browseren. + Du kan importere gemte adgangskoder fra en anden browser til DuckDuckGo. Importer adgangskoder Sådan importerer du adgangskoder diff --git a/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml index c0527d0b9cb8..973066e2219e 100644 --- a/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Passwort speichern Passwort speichern Nicht speichern - Dieses Passwort speichern? + Passwort speichern? Weitere Optionen Gespeichertes Passwort verwenden? @@ -122,8 +122,9 @@ Passwörter Noch keine Passwörter gespeichert Passwörter speichern und automatisch ausfüllen - Passwörter werden sicher auf deinem Gerät gespeichert - DuckDuckGo Passwörter & Autovervollständigen speichert Passwörter sicher auf deinem Gerät. + Passwörter sind verschlüsselt. Niemand außer dir kann sie sehen, nicht einmal wir. Mehr erfahren + Passwort-Manager-Sicherheit + Speichere dein Passwort mit DuckDuckGo Passwörter & Autovervollständigen sicher auf dem Gerät. Passwort hinzufügen Titel Passwort gelöscht @@ -191,7 +192,7 @@ Vergewissere dich, dass du weiterhin eine Möglichkeit hast, auf deine Konten zuzugreifen. - Passwörter aus anderen Browsern oder Apps können mit der Desktop-Version des DuckDuckGo-Browsers importiert werden. + Du kannst gespeicherte Passwörter aus einem anderen Browser in DuckDuckGo importieren. Passwörter importieren Wie man Passwörter importiert diff --git a/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml index 710f394e8095..6b3bd8c78d2d 100644 --- a/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Αποθήκευση κωδικού πρόσβασης Αποθήκευση κωδικού πρόσβασης Να μην αποθηκευτεί - Αποθήκευση αυτού του κωδικού πρόσβασης; + Αποθήκευση του κωδικού πρόσβασής σας; Περισσότερες επιλογές Χρήση αποθηκευμένου κωδικού πρόσβασης; @@ -122,8 +122,9 @@ Κωδικοί πρόσβασης Δεν έχουν αποθηκευτεί ακόμα κωδικοί πρόσβασης Αποθήκευση και αυτόματη συμπλήρωση κωδικών πρόσβασης - Οι κωδικοί πρόσβασης αποθηκεύονται με ασφάλεια στη συσκευή σας - Η λειτουργία DuckDuckGo κωδικοί πρόσβασης και αυτόματη συμπλήρωση αποθηκεύει τους κωδικούς πρόσβασης με ασφάλεια στη συσκευή σας. + Οι κωδικοί πρόσβασης είναι κρυπτογραφημένοι. Κανείς άλλος εκτός από εσάς δεν μπορεί να τους βλέπει, ούτε καν εμείς. Μάθετε περισσότερα + Ασφάλεια Διαχειριστή κωδικών πρόσβασης + Αποθηκεύστε με ασφάλεια τον κωδικό πρόσβασής σας στη συσκευή με τη λειτουργία DuckDuckGo κωδικοί πρόσβασης και αυτόματη συμπλήρωση. Προσθήκη κωδικού πρόσβασης Τίτλος Διαγραφή κωδικού πρόσβασης @@ -191,7 +192,7 @@ Βεβαιωθείτε ότι έχετε ακόμα τρόπο πρόσβασης στους λογαριασμούς σας. - Οι κωδικοί πρόσβασης από άλλα προγράμματα περιήγησης ή εφαρμογές μπορούν να εισαχθούν με χρήση της έκδοσης του προγράμματος περιήγησης DuckDuckGo για υπολογιστές. + Μπορείτε να εισαγάγετε αποθηκευμένους κωδικούς πρόσβασης από άλλο πρόγραμμα περιήγησης στο DuckDuckGo. Εισαγωγή κωδικών πρόσβασης Τρόπος εισαγωγής κωδικών πρόσβασης diff --git a/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml index 4d55747059b7..a9fc5a4a7baf 100644 --- a/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Guardar contraseña Guardar contraseña No guardar - ¿Guardar esta contraseña? + ¿Guardar contraseña? Más opciones ¿Usar una contraseña guardada? @@ -122,8 +122,9 @@ Contraseñas Aún no hay contraseñas guardadas Guardar y autocompletar contraseñas - Las contraseñas se almacenan de forma segura en tu dispositivo - Contraseñas y Autocompletar de DuckDuckGo almacena las contraseñas de forma segura en tu dispositivo. + Las contraseñas están cifradas. Nadie más que tú puede verlas, ni siquiera nosotros. Más información + Seguridad del administrador de contraseñas + Almacena de forma segura tu contraseña en el dispositivo con DuckDuckGo Contraseñas y Autocompletar. Añadir contraseña Título Contraseña borrada @@ -191,7 +192,7 @@ Asegúrate de seguir teniendo una forma de acceder a tus cuentas. - Las contraseñas de otros navegadores o aplicaciones se pueden importar utilizando la versión de escritorio del navegador DuckDuckGo. + Puedes importar contraseñas guardadas de otro navegador a DuckDuckGo. Importar contraseñas Cómo importar contraseñas diff --git a/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml index 6ad626a7f61c..d794704e6fe3 100644 --- a/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Salvesta parool Salvesta parool Ära salvesta - Kas salvestada see parool? + Kas salvestada parool? Veel valikuid Kas kasutada salvestatud parooli? @@ -122,8 +122,9 @@ Paroolid Paroole ei ole veel salvestatud Paroolide salvestamine ja automaatne sisestamine - Paroole hoitakse turvaliselt sinu seadmes - DuckDuckGo paroolid ja automaatne täitmine salvestab paroole turvaliselt sinu seadmes. + Paroolid on krüpteeritud. Keegi peale sinu ei näe neid, isegi mitte meie. Lisateave + Paroolihalduri turvalisus + Salvesta oma parool turvaliselt seadmesse DuckDuckGo paroolide ja automaatse täitmisega. Parooli lisamine Pealkiri Parool on kustutatud @@ -191,7 +192,7 @@ Veendu, et sulle jääks endiselt mõni viis oma kontodele pääsemiseks. - Teiste brauserite või rakenduste paroole saab importida DuckDuckGo brauseri töölauaversiooni abil. + Salvestatud paroole saad importida teisest brauserist DuckDuckGo-sse. Impordi paroolid Kuidas importida paroole diff --git a/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml index 70fe6a260074..bce7ddf55d74 100644 --- a/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Tallenna salasana Tallenna salasana Älä tallenna - Tallennetaanko tämä salasana? + Tallennetaanko salasanasi? Lisää vaihtoehtoja Käytetäänkö tallennettua salasanaa? @@ -122,8 +122,9 @@ Salasanat Salasanoja ei ole vielä tallennettu Tallenna ja täytä salasanat automaattisesti - Salasanat tallennetaan laitteellesi turvallisesti - DuckDuckGon salasanat ja automaattinen täyttö tallentaa salasanat turvallisesti laitteellesi. + Salasanat salataan. Kukaan muu kuin sinä ei näe niitä, emme edes me. Lue lisää + Salasanahallinnan suojaus + Tallenna salasanasi turvallisesti laitteeseen DuckDuckGon salasanojen ja automaattisen täytön avulla. Lisää salasana Otsikko Salasana poistettu @@ -191,7 +192,7 @@ Varmista, että pääset yhä käyttämään tilejäsi. - Muiden selainten tai sovellusten salasanat voidaan tuoda DuckDuckGo-selaimen tietokoneversiolla. + Voit tuoda tallennetut salasanat toisesta selaimesta DuckDuckGohon. Tuo salasanat Näin tuot salasanat diff --git a/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml index fe38fb94bc80..6d22a1b2f445 100644 --- a/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Enregistrer le mot de passe Enregistrer le mot de passe Ne pas enregistrer - Enregistrer ce mot de passe ? + Enregistrer votre mot de passe ? Plus d\'options Utiliser un mot de passe enregistré ? @@ -122,8 +122,9 @@ Mots de passe Aucun mot de passe n\'a été enregistré Enregistrer et saisir automatiquement les mots de passe - Les mots de passe sont stockés en toute sécurité sur votre appareil - Dans DuckDuckGo, « Mots de passe et saisie automatique » stocke les mots de passe en toute sécurité sur votre appareil. + Les mots de passe sont cryptés. Personne d\'autre que vous ne peut les voir, pas même nous. En savoir plus + Sécurité du gestionnaire de mots de passe + Stockez votre mot de passe en toute sécurité sur votre appareil avec DuckDuckGo, Mots de passe et saisie automatique. Ajouter un mot de passe Titre Le mot de passe a été supprimé @@ -191,7 +192,7 @@ Assurez-vous d\'avoir toujours un moyen d\'accéder à vos comptes. - Les mots de passe d\'autres navigateurs ou applications peuvent être importés à l\'aide de la version de bureau du navigateur DuckDuckGo. + Vous pouvez importer les mots de passe enregistrés d\'un autre navigateur dans DuckDuckGo. Importer les mots de passe Comment importer des mots de passe diff --git a/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml index 6074ddb739a2..ff3785f0066f 100644 --- a/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Spremi lozinku Spremi lozinku Nemoj spremiti - Želiš li spremiti ovu lozinku? + Želiš li spremiti svoju lozinku? Dodatne mogućnosti Koristiti spremljenu lozinku? @@ -122,8 +122,9 @@ Lozinke Još nema spremljenih lozinki Spremi i automatski popuni lozinke - Lozinke su sigurno pohranjene na tvom uređaju - DuckDuckGo funkcija Passwords & Autofill sigurno pohranjuje lozinke na tvom uređaju. + Lozinke su šifrirane. Nitko osim tebe ne može ih vidjeti, čak ni mi. Saznaj više + Zaštita upravitelja lozinkama + Sigurno pohrani svoju lozinku na uređaj pomoću usluge automatskog popunjavanja DuckDuckGo Passwords & Autofill. Dodaj lozinku Naslov Lozinka je izbrisana @@ -203,7 +204,7 @@ Uvjeri se da i dalje imaš način pristupa svojim računima. - Lozinke iz drugih preglednika ili aplikacija mogu se uvesti pomoću desktop verzije preglednika DuckDuckGo. + Možeš uvesti spremljene lozinke iz drugog preglednika u DuckDuckGo. Uvezi lozinke Kako uvesti lozinke diff --git a/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml index 26d2180c158f..8f850a49e327 100644 --- a/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Jelszó mentése Jelszó mentése Mentés mellőzése - Mented a jelszót? + Mented a jelszavad? További lehetőségek Mentett jelszót használsz? @@ -122,8 +122,9 @@ Jelszavak Még nincsenek mentett jelszavak Jelszavak mentése és automatikus kitöltése - A jelszavakat az eszközöd biztonságosan tárolja - A DuckDuckGóban elérhető Jelszavak és automatikus kitöltés funkció biztonságosan tárolja a jelszavakat az eszközön. + A jelszavak titkosítva vannak. Rajtad kívül senki sem láthatja őket, még mi sem. További tudnivalók + Jelszókezelő biztonsága + Tárold biztonságosan a jelszavaidat az eszközödön a DuckDuckGo Jelszavak és automatikus kitöltés funkciójával. Jelszó hozzáadása Cím Jelszó törölve @@ -191,7 +192,7 @@ Győződj meg róla, hogy továbbra is hozzáférsz a fiókjaidhoz. - Más böngészőkből vagy alkalmazásokból származó jelszavak a DuckDuckGo böngésző asztali verziójával importálhatók. + Importálhatod a mentett jelszavaidat egy másik böngészőből a DuckDuckGo böngészőjébe. Jelszavak importálása Jelszavak importálásának módja diff --git a/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml index 01b5f5b76ced..6c688a78cfbb 100644 --- a/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Salva password Salva password Non salvare - Salvare questa password? + Vuoi salvare la password? Altre opzioni Utilizzare una password salvata? @@ -122,8 +122,9 @@ Password Nessuna password ancora salvata Salva e compila automaticamente le password - Le password sono archiviate in modo sicuro sul tuo dispositivo - DuckDuckGo Password e compilazione automatica archivia le password in modo sicuro sul tuo dispositivo. + Le password sono crittografate. Nessuno tranne te può vederle, nemmeno noi. Ulteriori informazioni + Sicurezza del gestore di password + Memorizza in modo sicuro la tua password sul dispositivo con DuckDuckGo Passwords & Autofill. Aggiungi password Titolo Password eliminata @@ -191,7 +192,7 @@ Assicurati di avere ancora la possibilità di accedere ai tuoi account. - Le password di altri browser o app possono essere importate utilizzando la versione desktop del browser DuckDuckGo. + Puoi importare le password salvate da un altro browser in DuckDuckGo. Importa password Come importare le password diff --git a/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml index e0f2effac080..7e2d54501d87 100644 --- a/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Išsaugoti slaptažodį Išsaugoti slaptažodį Neišsaugokite - Išsaugoti šį slaptažodį? + Išsaugoti slaptažodį? Daugiau parinkčių Naudoti išsaugotą slaptažodį? @@ -122,8 +122,9 @@ Slaptažodžiai Dar nėra išsaugotų slaptažodžių Išsaugokite ir automatiškai užpildykite slaptažodžius - Slaptažodžiai saugiai saugomi jūsų įrenginyje - „DuckDuckGo“ priemonė „Slaptažodžiai ir automatinis užpildymas“ saugiai saugo slaptažodžius jūsų įrenginyje. + Slaptažodžiai yra užšifruoti. Niekas, išskyrus jus, negali jų matyti – net mes. Sužinokite daugiau + Slaptažodžių tvarkytuvės saugumas + Saugiai išsaugokite slaptažodį įrenginyje naudodami „DuckDuckGo“ slaptažodžių ir automatinio pildymo parinktį. Pridėti slaptažodį Pavadinimas Slaptažodis ištrintas @@ -203,7 +204,7 @@ Įsitikinkite, kad vis dar turite būdą, kaip pasiekti paskyras. - Slaptažodžius iš kitų naršyklių ar programų galima importuoti naudojant „DuckDuckGo“ naršyklės versiją kompiuteriui. + Galite importuoti išsaugotus slaptažodžius iš kitos naršyklės į „DuckDuckGo“. Importuoti slaptažodžius Kaip importuoti slaptažodžius diff --git a/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml index 5e2d7cbb51d1..d5d11321d6f6 100644 --- a/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Saglabāt paroli Saglabāt paroli Nesaglabāt - Vai saglabāt šo paroli? + Vai saglabāt tavu paroli? Citas iespējas Izmantot saglabāto paroli? @@ -122,8 +122,9 @@ Paroles Vēl nav saglabāta neviena parole Saglabāt un automātiski aizpildīt paroles - Paroles tiek droši glabātas tavā ierīcē - DuckDuckGo funkcija Paroles un automātiskā aizpildīšana droši glabā paroles tavā ierīcē. + Paroles ir šifrētas. Neviens, izņemot tevi, tās nevar redzēt – pat mēs ne. Uzzināt vairāk + Paroļu pārvaldnieka drošība + Droši saglabā paroli ierīcē, izmantojot DuckDuckGo paroles un automātisko aizpildīšanu. Pievienot paroli Nosaukums Parole dzēsta @@ -197,7 +198,7 @@ Pārliecinies, vai joprojām varēsi piekļūt savam kontam. - Paroles no citiem pārlūkiem vai lietotnēm var importēt, izmantojot DuckDuckGo pārlūka galddatora versiju. + Tu vari DuckDuckGo importēt saglabātās paroles no citas pārlūkprogrammas. Importēt paroles Kā importēt paroles diff --git a/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml index 54083b32f68e..88c1c7c1f38a 100644 --- a/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Lagre passordet Lagre passordet Ikke lagre - Vil du lagre dette passordet? + Vil du lagre passordet ditt? Flere alternativer Vil du bruke et lagret passord? @@ -122,8 +122,9 @@ Passord Ingen passord er lagret ennå Lagre og fyll ut passord automatisk - Passord lagres på enheten din på en sikker måte - DuckDuckGo-passord og -autofyll lagrer passord trygt på enheten din. + Passord krypteres. Ingen andre enn du kan se dem, ikke engang vi. Les mer + Passordbehandlingssikkerhet + Lagre passordet ditt trygt på enheten med DuckDuckGos passord og autofyll. Legg til passord Tittel Passordet er slettet @@ -191,7 +192,7 @@ Sørg for at du fortsatt har tilgang til kontoene dine. - Passord fra andre nettlesere eller apper kan importeres med skrivebordsversjonen av DuckDuckGo-nettleseren. + Du kan importere lagrede passord fra en annen nettleser til DuckDuckGo. Importer passord Slik importerer du passord diff --git a/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml index 92184251c6d0..f1d17a0cc1ab 100644 --- a/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Wachtwoord opslaan Wachtwoord opslaan Niet opslaan - Dit wachtwoord opslaan? + Je wachtwoord opslaan? Meer opties Een opgeslagen wachtwoord gebruiken? @@ -122,8 +122,9 @@ Wachtwoorden Nog geen wachtwoorden opgeslagen Wachtwoorden opslaan en automatisch invullen - Wachtwoorden worden veilig opgeslagen op je apparaat - DuckDuckGo Wachtwoorden en Automatisch aanvullen slaat wachtwoorden veilig op je apparaat op. + Wachtwoorden worden versleuteld. Niemand anders dan jij kunt ze zien, zelfs wij niet. Meer informatie + Beveiliging met wachtwoordbeheerder + Sla je wachtwoord veilig op je apparaat op met DuckDuckGo wachtwoorden en automatisch invullen. Wachtwoord toevoegen Titel Wachtwoord verwijderd @@ -191,7 +192,7 @@ Zorg ervoor dat je nog steeds toegang hebt tot je accounts. - Wachtwoorden van andere browsers of apps kunnen worden geïmporteerd met de desktopversie van de DuckDuckGo-browser. + Je kunt opgeslagen wachtwoorden uit een andere browser importeren in DuckDuckGo. Wachtwoorden importeren Wachtwoorden importeren diff --git a/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml index c65971fb67d0..316d6d627473 100644 --- a/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Zapisz hasło Zapisz hasło Nie zapisuj - Zapisać to hasło? + Zapisać hasło? Więcej opcji Użyć zapisanego hasła? @@ -122,8 +122,9 @@ Hasła Nie zapisano jeszcze żadnych haseł Zapisuj i automatycznie uzupełniaj hasła - Hasła są bezpiecznie przechowywane na Twoim urządzeniu - Funkcja Hasła i autouzupełnianie DuckDuckGo bezpiecznie przechowuje hasła na urządzeniu. + Hasła są szyfrowane. Nikt poza Tobą ich nie widzi, nawet my. Dowiedz się więcej + Bezpieczeństwo menedżera haseł + Bezpiecznie przechowuj swoje hasło na urządzeniu dzięki funkcji Hasła i autouzupełnianie DuckDuckGo. Dodaj hasło Tytuł Hasło usunięte @@ -203,7 +204,7 @@ Upewnij się, że nadal masz możliwość dostępu do swoich kont. - Hasła z innych przeglądarek lub aplikacji można zaimportować za pomocą komputerowej wersji przeglądarki DuckDuckGo. + Możesz importować zapisane hasła z innej przeglądarki do DuckDuckGo. Importuj hasła Jak zaimportować hasła diff --git a/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml index e86333d9d973..107cb0752758 100644 --- a/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Guardar palavra-passe Guardar palavra-passe Não guardar - Guardar esta palavra-passe? + Guardar a palavra-passe? Mais opções Usar uma palavra-passe guardada? @@ -122,8 +122,9 @@ Palavras-passe Ainda não há palavras-passe guardadas Guardar e preencher palavras-passe automaticamente - As palavras-passe são armazenadas com segurança no teu dispositivo - A funcionalidade Palavras-passe e preenchimento automático do DuckDuckGo armazena as palavras-passe com segurança no teu dispositivo. + As palavras-passe estão encriptadas. Ninguém além de ti pode vê-las, nem mesmo nós. Sabe mais + Segurança do gestor de palavras-passe + Guarda a tua palavra-passe com segurança no dispositivo com a funcionalidade Palavras-passe e preenchimento automático do DuckDuckGo. Adicionar palavra-passe Título Palavra-passe eliminada @@ -191,7 +192,7 @@ Confirma que ainda tens uma forma de aceder às contas. - As palavras-passe de outros navegadores ou aplicações podem ser importadas com a versão para computadores do navegador DuckDuckGo. + Podes importar palavras-passe guardadas de outro navegador para o DuckDuckGo. Importar palavras-passe Como importar palavras-passe diff --git a/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml index 35e634472505..e965afc70add 100644 --- a/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Salvează parola Salvează parola Nu salva - Dorești să salvezi această parolă? + Dorești să îți salvezi parola? Mai multe opțiuni Folosești o parolă salvată? @@ -122,8 +122,9 @@ Parole Nici o parolă nu a fost salvată încă Salvează și completează automat parolele - Parolele sunt stocate în siguranță pe dispozitivul tău - Parole și completare automată de la DuckDuckGo stochează parolele în siguranță pe dispozitivul tău. + Parolele sunt criptate. Nimeni în afară de tine nu le poate vedea, nici măcar noi. Află mai multe + Securitatea managerului de parole + Stochează în siguranță parola pe dispozitiv, cu Parole și completare automată DuckDuckGo. Adaugă parola Titlu Parolă ștearsă @@ -197,7 +198,7 @@ Asigură-te că ai în continuare posibilitatea de a-ți accesa conturile. - Parolele din alte browsere sau aplicații pot fi importate folosind versiunea pentru desktop a browserului DuckDuckGo. + Poți importa parolele salvate dintr-un alt browser în DuckDuckGo. Importă parolele Cum să imporți parolele diff --git a/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml index cf0cef5f6a0d..ec9a1cc0b3af 100644 --- a/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml @@ -122,8 +122,9 @@ Пароли Сохраненных паролей пока нет Хранение и автозаполнение паролей - Пароли надежно защищены и хранятся на вашем устройстве - Функция «Пароли и автозаполнение» в составе DuckDuckGo надежно хранит пароли на вашем устройстве. + Пароли подвергаются шифрованию. Никто, кроме вас, их не увидит. Даже мы. Подробнее... + Безопасность менеджера паролей + Вы можете сохранить этот пароль на устройстве под надежной защитой функции «Пароли и автозаполнение» от DuckDuckGo. Добавление пароля Название Пароль удален @@ -203,7 +204,7 @@ Обязательно убедитесь, что вы можете войти в свои учетные записи другим способом. - Пароли из других браузеров и приложений можно импортировать с помощью настольной версии DuckDuckGo. + В DuckDuckGo можно импортировать сохраненные пароли из другого браузера. Импорт паролей Как импортировать пароли diff --git a/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml index 1840d2981393..85d3376a22ae 100644 --- a/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Uložiť heslo Uložiť heslo Neukladať - Uložiť toto heslo? + Chcete uložiť svoje heslo? Ďalšie možnosti Použiť uložené heslo? @@ -122,8 +122,9 @@ Heslá Zatiaľ nie sú uložené žiadne heslá Ukladať a automaticky dopĺňať heslá - Heslá sú zabezpečeným spôsobom uložené vo vašom zariadení - DuckDuckGo Passwords & Autofill bezpečne ukladá heslá vo vašom zariadení. + Heslá sú zašifrované. Nikto okrem vás ich nemôže vidieť, dokonca ani my. Zistiť viac + Zabezpečenie správcu hesiel + Heslo bezpečne uložte do zariadenia pomocou aplikácie DuckDuckGo Passwords & Autofill. Pridať heslo Názov Heslo bolo odstránené @@ -203,7 +204,7 @@ Zabezpečte si prístup k svojim účtom. - Heslá z iných prehliadačov alebo aplikácií možno importovať pomocou verzie prehliadača DuckDuckGo pre počítače. + Uložené heslá môžete importovať z iného prehliadača do DuckDuckGo. Importovať heslá Ako importovať heslá diff --git a/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml index 1cc84158bcfe..9ad5d1f281f4 100644 --- a/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Shrani geslo Shrani geslo Ne shrani - Želite shraniti to geslo? + Želite shraniti svoje geslo? Več možnosti Želite uporabiti shranjeno geslo? @@ -122,8 +122,9 @@ Gesla Nobeno geslo še ni shranjeno Shranite in samodejno izpolnite gesla - Gesla so varno shranjena v vaši napravi - Funkcija DuckDuckGo Passwords & Autofill varno shranjuje gesla v vaši napravi. + Gesla so šifrirana. Nihče razen vas jih ne more videti, niti mi. Več o tem + Varnost upravitelja gesel + S funkcijo DuckDuckGo Passwords & Autofill varno shranite geslo v napravo. Dodajte geslo Naslov Geslo je izbrisano @@ -203,7 +204,7 @@ Prepričajte se, da imate še vedno možnost dostopa do svojih računov. - Gesla iz drugih brskalnikov ali aplikacij lahko uvozite z namizno različico brskalnika DuckDuckGo. + Shranjena gesla lahko uvozite iz drugega brskalnika v DuckDuckGo. Uvozi gesla Kako uvoziti gesla diff --git a/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml index 62a64cee85ea..7fd746bd199b 100644 --- a/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Spara lösenord Spara lösenord Spara inte - Spara det här lösenordet? + Spara ditt lösenord? Fler alternativ Vill du använda ett sparat lösenord? @@ -122,8 +122,9 @@ Lösenord Inga lösenord sparade ännu Spara och fyll i lösenord automatiskt - Lösenord lagras säkert på din enhet - DuckDuckGo Lösenord och autofyll lagrar lösenord på ett säkert sätt på din enhet. + Lösenorden är krypterade. Ingen annan än du kan se dem, inte ens vi. Läs mer + Säkerhet för lösenordshanteraren + Förvara ditt lösenord säkert på enheten med DuckDuckGo Lösenord och autofyll. Lägg till lösenord Rubrik Lösenordet har raderats @@ -191,7 +192,7 @@ Se till att du har kvar ett sätt att komma åt dina konton. - Lösenord från andra webbläsare eller appar kan importeras med hjälp av datorversionen av DuckDuckGo-webbläsaren. + Du kan importera sparade lösenord från andra webbläsare till DuckDuckGo. Importera lösenord Så här importerar du lösenord diff --git a/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml index df90a23fb215..6684cb2c1a9b 100644 --- a/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Şifreyi Kaydet Şifre Kaydet Kaydetme - Bu parola kaydedilsin mi? + Parolanız kaydedilsin mi? Diğer Seçenekler Kayıtlı bir şifre mi kullanıyorsunuz? @@ -122,8 +122,9 @@ Şifreler Henüz şifre kaydedilmedi Şifreleri kaydedin ve otomatik doldurun - Şifreler cihazınızda güvenli bir şekilde saklanır - DuckDuckGo Şifreleri ve Otomatik Doldurma, parolaları cihazınızda güvenli bir şekilde saklar. + Parolalar şifrelenir. Onları sizden başka kimse göremez. Biz bile. Daha Fazla Bilgi + Parola Yöneticisi Güvenliği + DuckDuckGo Parolalar ve Otomatik Doldurma ile parolanızı cihazınızda güvenle saklayın. Şifre Ekle Title Şifre silindi @@ -191,7 +192,7 @@ Hesaplarınıza başka bir şekilde erişebileceğinizden emin olun. - Diğer tarayıcılardan veya uygulamalardan şifreler, DuckDuckGo tarayıcısının masaüstü sürümü kullanılarak içe aktarılabilir. + Kayıtlı parolaları başka bir tarayıcıdan DuckDuckGo\'ya aktarabilirsiniz. Şifreleri İçe Aktar Şifreler Nasıl Aktarılır diff --git a/autofill/autofill-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index 633f147275b8..97df55f79047 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -17,4 +17,6 @@ Passwords + + Autofill for passwords is currently unavailable. We’re working to restore this in a future app update. \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/values/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values/strings-autofill-impl.xml index f74949afd9c5..703928b6e4ab 100644 --- a/autofill/autofill-impl/src/main/res/values/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values/strings-autofill-impl.xml @@ -22,7 +22,7 @@ Save Password Save Password Don\'t Save - Save this password? + Save your password? More Options Use a saved password? @@ -56,7 +56,7 @@ Suggested - Autofill for passwords is unavailable because your version of Android WebView is too old. + Autofill for passwords is unavailable because your version of Android WebView is outdated or incompatible. Clear search input No results for \'%1$s\' @@ -122,8 +122,9 @@ Passwords No passwords saved yet Save and Autofill Passwords - Passwords are stored securely on your device - DuckDuckGo Passwords & Autofill stores passwords securely on your device. + Passwords are encrypted. Nobody but you can see them, not even us. Learn More + Password Manager Security + Securely store your password on device with DuckDuckGo Passwords & Autofill. Add Password Title Password deleted @@ -187,7 +188,7 @@ Make sure you still have a way to access your accounts. - Passwords from other browsers or apps can be imported using the desktop version of the DuckDuckGo browser. + You can import saved passwords from another browser into DuckDuckGo. Import Passwords How To Import Passwords diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt deleted file mode 100644 index 172112766201..000000000000 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt +++ /dev/null @@ -1,440 +0,0 @@ -/* - * Copyright (c) 2022 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.autofill.impl - -import android.webkit.WebView -import androidx.test.core.app.ApplicationProvider.getApplicationContext -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.autofill.api.AutofillCapabilityChecker -import com.duckduckgo.autofill.api.Callback -import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.domain.app.LoginTriggerType -import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.impl.AutofillStoredBackJavascriptInterface.UrlProvider -import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator -import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker -import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore -import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster -import com.duckduckgo.autofill.impl.jsbridge.request.AutofillDataRequest -import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser -import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataCredentialsRequest -import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataRequest -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputMainType.CREDENTIALS -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.USERNAME -import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED -import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter -import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials -import com.duckduckgo.autofill.impl.store.InternalAutofillStore -import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository -import com.duckduckgo.autofill.impl.systemautofill.SystemAutofillServiceSuppressor -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.AutogeneratedPasswordEventResolver -import com.duckduckgo.common.test.CoroutineTestRule -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.* - -@RunWith(AndroidJUnit4::class) -class AutofillStoredBackJavascriptInterfaceTest { - - @get:Rule - var coroutineRule = CoroutineTestRule() - - private val requestParser: AutofillRequestParser = mock() - private val autofillStore: InternalAutofillStore = mock() - private val autofillMessagePoster: AutofillMessagePoster = mock() - private val autofillResponseWriter: AutofillResponseWriter = mock() - private val currentUrlProvider: UrlProvider = mock() - private val autofillCapabilityChecker: AutofillCapabilityChecker = mock() - private val passwordEventResolver: AutogeneratedPasswordEventResolver = mock() - private val testSavedLoginsMonitor: AutomaticSavedLoginsMonitor = mock() - private val coroutineScope: CoroutineScope = TestScope() - private val shareableCredentials: ShareableCredentials = mock() - private val emailManager: EmailManager = mock() - private val inContextDataStore: EmailProtectionInContextDataStore = mock() - private val recentInstallChecker: EmailProtectionInContextRecentInstallChecker = mock() - private val testWebView = WebView(getApplicationContext()) - private val loginDeduplicator: AutofillLoginDeduplicator = NoopDeduplicator() - private val systemAutofillServiceSuppressor: SystemAutofillServiceSuppressor = mock() - private val neverSavedSiteRepository: NeverSavedSiteRepository = mock() - private lateinit var testee: AutofillStoredBackJavascriptInterface - - private val testCallback = TestCallback() - - @Before - fun setUp() = runTest { - whenever(autofillCapabilityChecker.isAutofillEnabledByConfiguration(any())).thenReturn(true) - whenever(autofillCapabilityChecker.canInjectCredentialsToWebView(any())).thenReturn(true) - whenever(autofillCapabilityChecker.canSaveCredentialsFromWebView(any())).thenReturn(true) - whenever(shareableCredentials.shareableCredentials(any())).thenReturn(emptyList()) - whenever(neverSavedSiteRepository.isInNeverSaveList(any())).thenReturn(false) - testee = AutofillStoredBackJavascriptInterface( - requestParser = requestParser, - autofillStore = autofillStore, - autofillMessagePoster = autofillMessagePoster, - autofillResponseWriter = autofillResponseWriter, - coroutineScope = coroutineScope, - currentUrlProvider = currentUrlProvider, - dispatcherProvider = coroutineRule.testDispatcherProvider, - autofillCapabilityChecker = autofillCapabilityChecker, - passwordEventResolver = passwordEventResolver, - shareableCredentials = shareableCredentials, - emailManager = emailManager, - inContextDataStore = inContextDataStore, - recentInstallChecker = recentInstallChecker, - loginDeduplicator = loginDeduplicator, - systemAutofillServiceSuppressor = systemAutofillServiceSuppressor, - neverSavedSiteRepository = neverSavedSiteRepository, - ) - testee.callback = testCallback - testee.webView = testWebView - testee.autoSavedLoginsMonitor = testSavedLoginsMonitor - - whenever(currentUrlProvider.currentUrl(testWebView)).thenReturn("https://example.com") - whenever(requestParser.parseAutofillDataRequest(any())).thenReturn( - Result.success(AutofillDataRequest(CREDENTIALS, USERNAME, USER_INITIATED, null)), - ) - whenever(autofillResponseWriter.generateEmptyResponseGetAutofillData()).thenReturn("") - whenever(autofillResponseWriter.generateResponseGetAutofillData(any())).thenReturn("") - } - - @Test - fun whenInjectingNoCredentialResponseThenCorrectJsonWriterInvoked() = runTest { - testee.injectNoCredentials() - verify(autofillResponseWriter).generateEmptyResponseGetAutofillData() - verifyMessageSent() - } - - @Test - fun whenInjectingCredentialResponseThenCorrectJsonWriterInvoked() = runTest { - val loginCredentials = LoginCredentials(0, "example.com", "username", "password") - testee.injectCredentials(loginCredentials) - verify(autofillResponseWriter).generateResponseGetAutofillData(any()) - verifyMessageSent() - } - - @Test - fun whenGetAutofillDataCalledNoCredentialsAvailableThenNoCredentialsCallbackInvoked() = runTest { - setupRequestForSubTypeUsername() - whenever(autofillStore.getCredentials(any())).thenReturn(emptyList()) - initiateGetAutofillDataRequest() - assertCredentialsUnavailable() - } - - @Test - fun whenGetAutofillDataCalledWithCredentialsAvailableThenCredentialsAvailableCallbackInvoked() = runTest { - whenever(autofillStore.getCredentials(any())).thenReturn(listOf(LoginCredentials(0, "example.com", "username", "password"))) - initiateGetAutofillDataRequest() - assertCredentialsAvailable() - } - - @Test - fun whenGetAutofillDataCalledWithCredentialsAvailableWithNullUsernameUsernameConvertedToEmptyString() = runTest { - setupRequestForSubTypePassword() - whenever(autofillStore.getCredentials(any())).thenReturn( - listOf( - loginCredential(username = null, password = "foo"), - loginCredential(username = null, password = "bar"), - loginCredential(username = "foo", password = "bar"), - ), - ) - initiateGetAutofillDataRequest() - assertCredentialsAvailable() - - // ensure the list of credentials now has two entries with empty string username (one for each null username) - assertCredentialsContains({ it.username }, "", "") - } - - @Test - fun whenRequestSpecifiesSubtypeUsernameAndNoEntriesThenNoCredentialsCallbackInvoked() = runTest { - setupRequestForSubTypeUsername() - whenever(autofillStore.getCredentials(any())).thenReturn(emptyList()) - initiateGetAutofillDataRequest() - assertCredentialsUnavailable() - } - - @Test - fun whenRequestSpecifiesSubtypeUsernameAndNoEntriesWithAUsernameThenNoCredentialsCallbackInvoked() = runTest { - setupRequestForSubTypeUsername() - whenever(autofillStore.getCredentials(any())).thenReturn( - listOf( - loginCredential(username = null, password = "foo"), - loginCredential(username = null, password = "bar"), - ), - ) - initiateGetAutofillDataRequest() - assertCredentialsUnavailable() - } - - @Test - fun whenRequestSpecifiesSubtypeUsernameAndSingleEntryWithAUsernameThenCredentialsAvailableCallbackInvoked() = runTest { - setupRequestForSubTypeUsername() - whenever(autofillStore.getCredentials(any())).thenReturn( - listOf( - loginCredential(username = null, password = "foo"), - loginCredential(username = null, password = "bar"), - loginCredential(username = "foo", password = "bar"), - ), - ) - initiateGetAutofillDataRequest() - assertCredentialsAvailable() - assertCredentialsContains({ it.username }, "foo") - } - - @Test - fun whenRequestSpecifiesSubtypeUsernameAndMultipleEntriesWithAUsernameThenCredentialsAvailableCallbackInvoked() = runTest { - setupRequestForSubTypeUsername() - whenever(autofillStore.getCredentials(any())).thenReturn( - listOf( - loginCredential(username = null, password = "foo"), - loginCredential(username = "username1", password = "bar"), - loginCredential(username = null, password = "bar"), - loginCredential(username = null, password = "bar"), - loginCredential(username = "username2", password = null), - ), - ) - initiateGetAutofillDataRequest() - assertCredentialsAvailable() - assertCredentialsContains({ it.username }, "username1", "username2") - } - - @Test - fun whenRequestSpecifiesSubtypePasswordAndNoEntriesThenNoCredentialsCallbackInvoked() = runTest { - setupRequestForSubTypePassword() - whenever(autofillStore.getCredentials(any())).thenReturn(emptyList()) - initiateGetAutofillDataRequest() - assertCredentialsUnavailable() - } - - @Test - fun whenRequestSpecifiesSubtypePasswordAndNoEntriesWithAPasswordThenNoCredentialsCallbackInvoked() = runTest { - setupRequestForSubTypePassword() - whenever(autofillStore.getCredentials(any())).thenReturn( - listOf( - loginCredential(username = "foo", password = null), - loginCredential(username = "bar", password = null), - ), - ) - initiateGetAutofillDataRequest() - assertCredentialsUnavailable() - } - - @Test - fun whenRequestSpecifiesSubtypePasswordAndSingleEntryWithAPasswordThenCredentialsAvailableCallbackInvoked() = runTest { - setupRequestForSubTypePassword() - whenever(autofillStore.getCredentials(any())).thenReturn( - listOf( - loginCredential(username = null, password = null), - loginCredential(username = "foobar", password = null), - loginCredential(username = "foo", password = "bar"), - ), - ) - initiateGetAutofillDataRequest() - assertCredentialsAvailable() - assertCredentialsContains({ it.password }, "bar") - } - - @Test - fun whenRequestSpecifiesSubtypePasswordAndMultipleEntriesWithAPasswordThenCredentialsAvailableCallbackInvoked() = runTest { - setupRequestForSubTypePassword() - whenever(autofillStore.getCredentials(any())).thenReturn( - listOf( - loginCredential(username = null, password = null), - loginCredential(username = "username2", password = null), - loginCredential(username = "username1", password = "password1"), - loginCredential(username = null, password = "password2"), - loginCredential(username = null, password = "password3"), - - ), - ) - initiateGetAutofillDataRequest() - assertCredentialsAvailable() - assertCredentialsContains({ it.password }, "password1", "password2", "password3") - } - - @Test - fun whenStoreFormDataCalledWithNoUsernameThenCallbackInvoked() = runTest { - configureRequestParserToReturnSaveCredentialRequestType(username = null, password = "password") - testee.storeFormData("") - assertNotNull(testCallback.credentialsToSave) - } - - @Test - fun whenStoreFormDataCalledWithNoPasswordThenCallbackInvoked() = runTest { - configureRequestParserToReturnSaveCredentialRequestType(username = "dax@duck.com", password = null) - testee.storeFormData("") - assertNotNull(testCallback.credentialsToSave) - assertEquals("dax@duck.com", testCallback.credentialsToSave!!.username) - } - - @Test - fun whenStoreFormDataCalledWithNullUsernameAndPasswordThenCallbackNotInvoked() = runTest { - configureRequestParserToReturnSaveCredentialRequestType(username = null, password = null) - testee.storeFormData("") - assertNull(testCallback.credentialsToSave) - } - - @Test - fun whenStoreFormDataCalledWithBlankUsernameThenCallbackInvoked() = runTest { - configureRequestParserToReturnSaveCredentialRequestType(username = " ", password = "password") - testee.storeFormData("") - assertEquals(" ", testCallback.credentialsToSave!!.username) - assertEquals("password", testCallback.credentialsToSave!!.password) - } - - @Test - fun whenStoreFormDataCalledWithBlankPasswordThenCallbackInvoked() = runTest { - configureRequestParserToReturnSaveCredentialRequestType(username = "username", password = " ") - testee.storeFormData("") - assertEquals("username", testCallback.credentialsToSave!!.username) - assertEquals(" ", testCallback.credentialsToSave!!.password) - } - - @Test - fun whenStoreFormDataCalledButSiteInNeverSaveListThenCallbackNotInvoked() = runTest { - configureRequestParserToReturnSaveCredentialRequestType(username = "username", password = "password") - whenever(neverSavedSiteRepository.isInNeverSaveList(any())).thenReturn(true) - testee.storeFormData("") - assertNull(testCallback.credentialsToSave) - } - - @Test - fun whenStoreFormDataCalledWithBlankUsernameAndBlankPasswordThenCallbackNotInvoked() = runTest { - configureRequestParserToReturnSaveCredentialRequestType(username = " ", password = " ") - testee.storeFormData("") - assertNull(testCallback.credentialsToSave) - } - - @Test - fun whenStoreFormDataCalledAndParsingErrorThenExceptionIsContained() = runTest { - configureRequestParserToReturnSaveCredentialRequestType(username = "username", password = "password") - whenever(requestParser.parseStoreFormDataRequest(any())).thenReturn(Result.failure(RuntimeException("Parsing error"))) - testee.storeFormData("") - assertNull(testCallback.credentialsToSave) - } - - private suspend fun configureRequestParserToReturnSaveCredentialRequestType( - username: String?, - password: String?, - ) { - val credentials = AutofillStoreFormDataCredentialsRequest(username = username, password = password) - val topLevelRequest = AutofillStoreFormDataRequest(credentials) - whenever(requestParser.parseStoreFormDataRequest(any())).thenReturn(Result.success(topLevelRequest)) - whenever(passwordEventResolver.decideActions(anyOrNull(), any())).thenReturn(listOf(Actions.PromptToSave)) - } - - private fun assertCredentialsContains( - property: (LoginCredentials) -> String?, - vararg expected: String?, - ) { - val numberExpected = expected.size - val numberMatched = testCallback.credentialsToInject?.filter { expected.contains(property(it)) }?.count() - assertEquals("Wrong number of matched properties. Expected $numberExpected but found $numberMatched", numberExpected, numberMatched) - } - - private fun loginCredential( - username: String?, - password: String?, - ) = LoginCredentials(0, "example.com", username, password) - - private suspend fun setupRequestForSubTypeUsername() { - whenever(requestParser.parseAutofillDataRequest(any())).thenReturn( - Result.success(AutofillDataRequest(CREDENTIALS, USERNAME, USER_INITIATED, null)), - ) - } - - private suspend fun setupRequestForSubTypePassword() { - whenever(requestParser.parseAutofillDataRequest(any())).thenReturn( - Result.success(AutofillDataRequest(CREDENTIALS, PASSWORD, USER_INITIATED, null)), - ) - } - - private fun assertCredentialsUnavailable() { - assertNotNull("Callback has not been called", testCallback.credentialsAvailableToInject) - assertFalse(testCallback.credentialsAvailableToInject!!) - } - - private fun assertCredentialsAvailable() { - assertNotNull("Callback has not been called", testCallback.credentialsAvailableToInject) - assertTrue(testCallback.credentialsAvailableToInject!!) - } - - private fun initiateGetAutofillDataRequest() { - testee.getAutofillData("") - } - - private suspend fun verifyMessageSent() { - verify(autofillMessagePoster).postMessage(any(), anyOrNull()) - } - - class TestCallback : Callback { - - // for injection - var credentialsToInject: List? = null - var credentialsAvailableToInject: Boolean? = null - - // for saving - var credentialsToSave: LoginCredentials? = null - - // for password generation - var offeredToGeneratePassword: Boolean = false - - override suspend fun onCredentialsAvailableToInject( - originalUrl: String, - credentials: List, - triggerType: LoginTriggerType, - ) { - credentialsAvailableToInject = true - this.credentialsToInject = credentials - } - - override suspend fun onCredentialsAvailableToSave( - currentUrl: String, - credentials: LoginCredentials, - ) { - credentialsToSave = credentials - } - - override suspend fun onGeneratedPasswordAvailableToUse( - originalUrl: String, - username: String?, - generatedPassword: String, - ) { - offeredToGeneratePassword = true - } - - override fun noCredentialsAvailable(originalUrl: String) { - credentialsAvailableToInject = false - } - - override fun onCredentialsSaved(savedCredentials: LoginCredentials) { - // no-op - } - } - - private class NoopDeduplicator : AutofillLoginDeduplicator { - override fun deduplicate(originalUrl: String, logins: List): List = logins - } -} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/InlineBrowserAutofillTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/InlineBrowserAutofillTest.kt index 0f28994e95a1..4881053b0588 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/InlineBrowserAutofillTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/InlineBrowserAutofillTest.kt @@ -1,161 +1,121 @@ -/* - * Copyright (c) 2022 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package com.duckduckgo.autofill.impl +import android.annotation.SuppressLint import android.webkit.WebView -import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.Callback -import com.duckduckgo.autofill.api.EmailProtectionInContextSignupFlowListener -import com.duckduckgo.autofill.api.EmailProtectionUserPromptListener -import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.domain.app.LoginTriggerType -import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.impl.InlineBrowserAutofillTest.FakeAutofillJavascriptInterface.Actions.* -import org.junit.Assert.* -import org.junit.Before +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.AutofillWebMessageListener +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class InlineBrowserAutofillTest { - private lateinit var testee: InlineBrowserAutofill - private val automaticSavedLoginsMonitor: AutomaticSavedLoginsMonitor = mock() - private lateinit var autofillJavascriptInterface: FakeAutofillJavascriptInterface - - private lateinit var testWebView: WebView - - private val emailProtectionInContextCallback: EmailProtectionUserPromptListener = mock() - private val emailProtectionInContextSignupFlowCallback: EmailProtectionInContextSignupFlowListener = mock() - - private val testCallback = object : Callback { - override suspend fun onCredentialsAvailableToInject( - originalUrl: String, - credentials: List, - triggerType: LoginTriggerType, - ) { - } - - override suspend fun onCredentialsAvailableToSave( - currentUrl: String, - credentials: LoginCredentials, - ) { - } - - override suspend fun onGeneratedPasswordAvailableToUse( - originalUrl: String, - username: String?, - generatedPassword: String, - ) { - } - - override fun noCredentialsAvailable(originalUrl: String) { - } - - override fun onCredentialsSaved(savedCredentials: LoginCredentials) { - } + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val mockWebView: WebView = mock() + private val autofillCallback: Callback = mock() + private val capabilityChecker: InternalAutofillCapabilityChecker = mock() + private val autofillJavascriptInjector: AutofillJavascriptInjector = mock() + private val webMessageAttacher: AutofillWebMessageAttacher = mock() + private val webMessageListeners = mutableListOf() + private val webMessageListenersPlugin: PluginPoint = object : PluginPoint { + override fun getPlugins(): Collection = webMessageListeners } - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - autofillJavascriptInterface = FakeAutofillJavascriptInterface() - testWebView = WebView(getApplicationContext()) - testee = InlineBrowserAutofill(autofillInterface = autofillJavascriptInterface, autoSavedLoginsMonitor = automaticSavedLoginsMonitor) + @Test + fun whenAutofillFeatureFlagDisabledThenDoNotAddJsInterface() = runTest { + val testee = setupConfig(topLevelFeatureEnabled = false) + testee.addJsInterface() + verifyJavascriptNotAdded() } @Test - fun whenRemoveJsInterfaceThenRemoveReferenceToWebview() { - testee.addJsInterface(testWebView, testCallback, emailProtectionInContextCallback, emailProtectionInContextSignupFlowCallback, "tabId") - - assertNotNull(autofillJavascriptInterface.webView) - - testee.removeJsInterface() - - assertNull(autofillJavascriptInterface.webView) + fun whenWebViewDoesNotSupportIntegrationThenDoNotAddJsInterface() = runTest { + val testee = setupConfig(deviceWebViewSupportsAutofill = false) + testee.addJsInterface() + verifyJavascriptNotAdded() } @Test - fun whenInjectCredentialsNullThenInterfaceInjectNoCredentials() { - testee.injectCredentials(null) - - assertEquals(NoCredentialsInjected, autofillJavascriptInterface.lastAction) + fun whenWebViewSupportsIntegrationAndFeatureEnabledThenJsInterfaceIsAdded() = runTest { + val testee = setupConfig() + testee.addJsInterface() + verifyJavascriptIsAdded() } @Test - fun whenInjectCredentialsThenInterfaceCredentialsInjected() { - val toInject = LoginCredentials( - id = 1, - domain = "hello.com", - username = "test", - password = "test123", - ) - testee.injectCredentials(toInject) - - assertEquals(CredentialsInjected(toInject), autofillJavascriptInterface.lastAction) + fun whenPluginsIsEmptyThenJsInterfaceIsAdded() = runTest { + val testee = setupConfig() + webMessageListeners.clear() + testee.addJsInterface() + verifyJavascriptIsAdded() } - class FakeAutofillJavascriptInterface : AutofillJavascriptInterface { - sealed class Actions { - data class GetAutoFillData(val requestString: String) : Actions() - data class CredentialsInjected(val credentials: LoginCredentials) : Actions() - object NoCredentialsInjected : Actions() - } - - var lastAction: Actions? = null - - override fun getAutofillData(requestString: String) { - lastAction = GetAutoFillData(requestString) - } - - override fun injectCredentials(credentials: LoginCredentials) { - lastAction = CredentialsInjected(credentials) - } - - override fun injectNoCredentials() { - lastAction = NoCredentialsInjected - } - - override fun closeEmailProtectionTab(data: String) { - } - - override fun getIncontextSignupDismissedAt(data: String) { - } - - override fun cancelRetrievingStoredLogins() { - } + @Test + fun whenPluginsIsNotEmptyThenIsRegisteredWithWebView() = runTest { + val testee = setupConfig() + val mockMessageListener: AutofillWebMessageListener = mock() + webMessageListeners.add(mockMessageListener) + testee.addJsInterface() + verify(webMessageAttacher).addListener(any(), eq(mockMessageListener)) + } - override fun acceptGeneratedPassword() { - } + private suspend fun verifyJavascriptNotAdded() { + verify(autofillJavascriptInjector, never()).addDocumentStartJavascript(any()) + } - override fun rejectGeneratedPassword() { - } + private suspend fun verifyJavascriptIsAdded() { + verify(autofillJavascriptInjector).addDocumentStartJavascript(any()) + } - override fun inContextEmailProtectionFlowFinished() { - } + private suspend fun InlineBrowserAutofill.addJsInterface() { + addJsInterface(mockWebView, autofillCallback, "tab-id-123") + } - override var callback: Callback? = null - override var emailProtectionInContextCallback: EmailProtectionUserPromptListener? = null - override var emailProtectionInContextSignupFlowCallback: EmailProtectionInContextSignupFlowListener? = null - override var webView: WebView? = null - override var autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor? = null - override var tabId: String? = null + @SuppressLint("DenyListedApi") + private suspend fun setupConfig( + topLevelFeatureEnabled: Boolean = true, + autofillEnabledByUser: Boolean = true, + canInjectCredentials: Boolean = true, + canSaveCredentials: Boolean = true, + canGeneratePassword: Boolean = true, + canAccessCredentialManagement: Boolean = true, + canIntegrateAutofillInWebView: Boolean = true, + deviceWebViewSupportsAutofill: Boolean = true, + ): InlineBrowserAutofill { + val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) + autofillFeature.self().setRawStoredState(State(enable = topLevelFeatureEnabled)) + autofillFeature.canInjectCredentials().setRawStoredState(State(enable = canInjectCredentials)) + autofillFeature.canSaveCredentials().setRawStoredState(State(enable = canSaveCredentials)) + autofillFeature.canGeneratePasswords().setRawStoredState(State(enable = canGeneratePassword)) + autofillFeature.canAccessCredentialManagement().setRawStoredState(State(enable = canAccessCredentialManagement)) + autofillFeature.canIntegrateAutofillInWebView().setRawStoredState(State(enable = canIntegrateAutofillInWebView)) + + whenever(capabilityChecker.webViewSupportsAutofill()).thenReturn(deviceWebViewSupportsAutofill) + whenever(capabilityChecker.canInjectCredentialsToWebView(any())).thenReturn(canIntegrateAutofillInWebView) + + return InlineBrowserAutofill( + autofillCapabilityChecker = capabilityChecker, + dispatchers = coroutineTestRule.testDispatcherProvider, + autofillJavascriptInjector = autofillJavascriptInjector, + webMessageListeners = webMessageListenersPlugin, + autofillFeature = autofillFeature, + webMessageAttacher = webMessageAttacher, + ) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/InternalAutofillCapabilityCheckerImplTest.kt similarity index 81% rename from autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImplTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/InternalAutofillCapabilityCheckerImplTest.kt index ae275a45ddbc..4fa036b21280 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/InternalAutofillCapabilityCheckerImplTest.kt @@ -16,9 +16,11 @@ package com.duckduckgo.autofill.impl +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.InternalTestUserChecker +import com.duckduckgo.autofill.impl.configuration.integration.JavascriptCommunicationSupport import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State @@ -40,6 +42,7 @@ class AutofillCapabilityCheckerImplTest { private val internalTestUserChecker: InternalTestUserChecker = mock() private val autofillGlobalCapabilityChecker: AutofillGlobalCapabilityChecker = mock() + private val javascriptCommunicationSupport: JavascriptCommunicationSupport = mock() private lateinit var testee: AutofillCapabilityCheckerImpl @@ -123,6 +126,20 @@ class AutofillCapabilityCheckerImplTest { assertFalse(testee.canGeneratePasswordFromWebView(URL)) } + @Test + fun whenModernJavascriptIntegrationIsSupportedThenSupportsAutofillIsTrue() = runTest { + setupConfig(topLevelFeatureEnabled = true, autofillEnabledByUser = true) + whenever(javascriptCommunicationSupport.supportsModernIntegration()).thenReturn(true) + assertTrue(testee.webViewSupportsAutofill()) + } + + @Test + fun whenModernJavascriptIntegrationIsSupportedThenSupportsAutofillIsFalse() = runTest { + setupConfig(topLevelFeatureEnabled = true, autofillEnabledByUser = true) + whenever(javascriptCommunicationSupport.supportsModernIntegration()).thenReturn(false) + assertFalse(testee.webViewSupportsAutofill()) + } + private suspend fun assertAllSubFeaturesDisabled() { assertFalse(testee.canAccessCredentialManagementScreen()) assertFalse(testee.canGeneratePasswordFromWebView(URL)) @@ -130,6 +147,7 @@ class AutofillCapabilityCheckerImplTest { assertFalse(testee.canSaveCredentialsFromWebView(URL)) } + @SuppressLint("DenyListedApi") private suspend fun setupConfig( topLevelFeatureEnabled: Boolean = false, autofillEnabledByUser: Boolean = false, @@ -139,11 +157,11 @@ class AutofillCapabilityCheckerImplTest { canAccessCredentialManagement: Boolean = false, ) { val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) - autofillFeature.self().setEnabled(State(enable = topLevelFeatureEnabled)) - autofillFeature.canInjectCredentials().setEnabled(State(enable = canInjectCredentials)) - autofillFeature.canSaveCredentials().setEnabled(State(enable = canSaveCredentials)) - autofillFeature.canGeneratePasswords().setEnabled(State(enable = canGeneratePassword)) - autofillFeature.canAccessCredentialManagement().setEnabled(State(enable = canAccessCredentialManagement)) + autofillFeature.self().setRawStoredState(State(enable = topLevelFeatureEnabled)) + autofillFeature.canInjectCredentials().setRawStoredState(State(enable = canInjectCredentials)) + autofillFeature.canSaveCredentials().setRawStoredState(State(enable = canSaveCredentials)) + autofillFeature.canGeneratePasswords().setRawStoredState(State(enable = canGeneratePassword)) + autofillFeature.canAccessCredentialManagement().setRawStoredState(State(enable = canAccessCredentialManagement)) whenever(autofillGlobalCapabilityChecker.isSecureAutofillAvailable()).thenReturn(true) whenever(autofillGlobalCapabilityChecker.isAutofillEnabledByConfiguration(any())).thenReturn(true) @@ -155,6 +173,7 @@ class AutofillCapabilityCheckerImplTest { internalTestUserChecker = internalTestUserChecker, autofillGlobalCapabilityChecker = autofillGlobalCapabilityChecker, dispatcherProvider = coroutineTestRule.testDispatcherProvider, + javascriptCommunicationSupport = javascriptCommunicationSupport, ) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreatorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreatorTest.kt index f16fc89e9679..f384a2e4f3ab 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreatorTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreatorTest.kt @@ -16,7 +16,7 @@ package com.duckduckgo.autofill.impl -import com.duckduckgo.autofill.api.AutofillCapabilityChecker +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor import com.duckduckgo.autofill.impl.store.InternalAutofillStore @@ -34,7 +34,7 @@ class RealDuckAddressLoginCreatorTest { private val autofillStore: InternalAutofillStore = mock() private val automaticSavedLoginsMonitor: AutomaticSavedLoginsMonitor = mock() - private val autofillCapabilityChecker: AutofillCapabilityChecker = mock() + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker = mock() private val neverSavedSiteRepository: NeverSavedSiteRepository = mock() private val testee = RealDuckAddressLoginCreator( @@ -126,6 +126,8 @@ class RealDuckAddressLoginCreatorTest { companion object { private const val TAB_ID = "tab-id-123" private const val URL = "example.com" + private const val REQUEST_ID = "request-id-123" + private val AUTOFILL_URL_REQUEST = AutofillWebMessageRequest(URL, URL, REQUEST_ID) private const val DUCK_ADDRESS = "foo@duck.com" } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfiguratorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfiguratorTest.kt deleted file mode 100644 index 9ac08289d8e8..000000000000 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfiguratorTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2023 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.autofill.impl.configuration - -import android.webkit.WebView -import com.duckduckgo.autofill.api.AutofillCapabilityChecker -import com.duckduckgo.common.test.CoroutineTestRule -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -class InlineBrowserAutofillConfiguratorTest { - - @get:Rule var coroutineRule = CoroutineTestRule() - - private lateinit var inlineBrowserAutofillConfigurator: InlineBrowserAutofillConfigurator - - private val autofillRuntimeConfigProvider: AutofillRuntimeConfigProvider = mock() - private val webView: WebView = mock() - private val autofillCapabilityChecker: AutofillCapabilityChecker = mock() - private val autofillJavascriptLoader: AutofillJavascriptLoader = mock() - - @Before - fun before() = runTest { - whenever(autofillJavascriptLoader.getAutofillJavascript()).thenReturn("") - whenever(autofillRuntimeConfigProvider.getRuntimeConfiguration(any(), any())).thenReturn("") - - inlineBrowserAutofillConfigurator = InlineBrowserAutofillConfigurator( - autofillRuntimeConfigProvider, - TestScope(), - coroutineRule.testDispatcherProvider, - autofillCapabilityChecker, - autofillJavascriptLoader, - ) - } - - @Test - fun whenFeatureIsNotEnabledThenDoNotInject() = runTest { - givenFeatureIsDisabled() - inlineBrowserAutofillConfigurator.configureAutofillForCurrentPage(webView, "https://example.com") - - verify(webView, never()).evaluateJavascript("javascript:", null) - } - - @Test - fun whenFeatureIsEnabledThenInject() = runTest { - givenFeatureIsEnabled() - inlineBrowserAutofillConfigurator.configureAutofillForCurrentPage(webView, "https://example.com") - - verify(webView).evaluateJavascript("javascript:", null) - } - - private suspend fun givenFeatureIsEnabled() { - whenever(autofillCapabilityChecker.isAutofillEnabledByConfiguration(any())).thenReturn(true) - } - - private suspend fun givenFeatureIsDisabled() { - whenever(autofillCapabilityChecker.isAutofillEnabledByConfiguration(any())).thenReturn(false) - } -} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt index c6287c4fe137..016784f52800 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * Copyright (c) 2024 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,10 +17,10 @@ package com.duckduckgo.autofill.impl.configuration import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextAvailabilityRules import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials @@ -49,7 +49,7 @@ class RealAutofillRuntimeConfigProviderTest { private val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) private val runtimeConfigurationWriter: RuntimeConfigurationWriter = mock() private val shareableCredentials: ShareableCredentials = mock() - private val autofillCapabilityChecker: AutofillCapabilityChecker = mock() + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker = mock() private val emailProtectionInContextAvailabilityRules: EmailProtectionInContextAvailabilityRules = mock() private val neverSavedSiteRepository: NeverSavedSiteRepository = mock() @@ -72,7 +72,7 @@ class RealAutofillRuntimeConfigProviderTest { whenever(neverSavedSiteRepository.isInNeverSaveList(any())).thenReturn(false) } - autofillFeature.canCategorizeUnknownUsername().setEnabled(State(enable = true)) + autofillFeature.canCategorizeUnknownUsername().setRawStoredState(State(enable = true)) whenever(runtimeConfigurationWriter.generateContentScope()).thenReturn("") whenever(runtimeConfigurationWriter.generateResponseGetAvailableInputTypes(any(), any())).thenReturn("") whenever(runtimeConfigurationWriter.generateUserUnprotectedDomains()).thenReturn("") @@ -91,7 +91,7 @@ class RealAutofillRuntimeConfigProviderTest { @Test fun whenAutofillNotEnabledThenConfigurationUserPrefsCredentialsIsFalse() = runTest { configureAutofillCapabilities(enabled = false) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration(EXAMPLE_URL) verifyAutofillCredentialsReturnedAs(false) } @@ -99,7 +99,7 @@ class RealAutofillRuntimeConfigProviderTest { fun whenAutofillEnabledThenConfigurationUserPrefsCredentialsIsTrue() = runTest { configureAutofillCapabilities(enabled = true) configureNoShareableLogins() - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration(EXAMPLE_URL) verifyAutofillCredentialsReturnedAs(true) } @@ -108,14 +108,14 @@ class RealAutofillRuntimeConfigProviderTest { configureAutofillCapabilities(enabled = true) configureAutofillAvailableForSite(EXAMPLE_URL) configureNoShareableLogins() - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration(EXAMPLE_URL) verifyKeyIconRequestedToShow() } @Test fun whenNoCredentialsForUrlThenConfigurationInputTypeCredentialsIsFalse() = runTest { configureAutofillEnabledWithNoSavedCredentials(EXAMPLE_URL) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration(EXAMPLE_URL) val expectedCredentialResponse = AvailableInputTypeCredentials(username = false, password = false) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -139,7 +139,7 @@ class RealAutofillRuntimeConfigProviderTest { ) configureNoShareableLogins() - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration(EXAMPLE_URL) val expectedCredentialResponse = AvailableInputTypeCredentials(username = true, password = true) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -163,7 +163,7 @@ class RealAutofillRuntimeConfigProviderTest { ), ) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration(EXAMPLE_URL) val expectedCredentialResponse = AvailableInputTypeCredentials(username = true, password = true) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -188,7 +188,7 @@ class RealAutofillRuntimeConfigProviderTest { ) configureNoShareableLogins() - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(EXAMPLE_URL) val expectedCredentialResponse = AvailableInputTypeCredentials(username = true, password = false) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -213,7 +213,7 @@ class RealAutofillRuntimeConfigProviderTest { ) configureNoShareableLogins() - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(EXAMPLE_URL) val expectedCredentialResponse = AvailableInputTypeCredentials(username = false, password = false) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -238,7 +238,7 @@ class RealAutofillRuntimeConfigProviderTest { ) configureNoShareableLogins() - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(EXAMPLE_URL) val expectedCredentialResponse = AvailableInputTypeCredentials(username = false, password = true) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -263,7 +263,7 @@ class RealAutofillRuntimeConfigProviderTest { ) configureNoShareableLogins() - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(url) val expectedCredentialResponse = AvailableInputTypeCredentials(username = false, password = false) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -287,7 +287,7 @@ class RealAutofillRuntimeConfigProviderTest { ), ) - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(url) val expectedCredentialResponse = AvailableInputTypeCredentials(username = false, password = false) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -311,7 +311,7 @@ class RealAutofillRuntimeConfigProviderTest { ), ) - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(url) val expectedCredentialResponse = AvailableInputTypeCredentials(username = false, password = false) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( @@ -326,7 +326,7 @@ class RealAutofillRuntimeConfigProviderTest { configureAutofillEnabledWithNoSavedCredentials(url) whenever(emailManager.isSignedIn()).thenReturn(true) - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(url) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( credentialsAvailable = any(), @@ -340,7 +340,7 @@ class RealAutofillRuntimeConfigProviderTest { configureAutofillEnabledWithNoSavedCredentials(url) whenever(emailManager.isSignedIn()).thenReturn(false) - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(url) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes( credentialsAvailable = any(), @@ -352,7 +352,7 @@ class RealAutofillRuntimeConfigProviderTest { fun whenSiteNotInNeverSaveListThenCanSaveCredentials() = runTest { val url = "example.com" configureAutofillEnabledWithNoSavedCredentials(url) - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(url) verifyCanSaveCredentialsReturnedAs(true) } @@ -362,7 +362,7 @@ class RealAutofillRuntimeConfigProviderTest { configureAutofillEnabledWithNoSavedCredentials(url) whenever(neverSavedSiteRepository.isInNeverSaveList(url)).thenReturn(true) - testee.getRuntimeConfiguration("", url) + testee.getRuntimeConfiguration(url) verifyCanSaveCredentialsReturnedAs(true) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealRuntimeConfigurationWriterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealRuntimeConfigurationWriterTest.kt index 2f7b044dea5f..7b3560a0574c 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealRuntimeConfigurationWriterTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealRuntimeConfigurationWriterTest.kt @@ -48,7 +48,7 @@ class RealRuntimeConfigurationWriterTest { @Test fun whenGenerateContentScopeTheReturnContentScopeString() { val expectedJson = """ - contentScope = { + "contentScope" : { "features": { "autofill": { "state": "enabled", @@ -56,7 +56,7 @@ class RealRuntimeConfigurationWriterTest { } }, "unprotectedTemporary": [] - }; + } """.trimIndent() assertEquals( expectedJson, @@ -66,7 +66,9 @@ class RealRuntimeConfigurationWriterTest { @Test fun whenGenerateUserUnprotectedDomainsThenReturnUserUnprotectedDomainsString() { - val expectedJson = "userUnprotectedDomains = [];" + val expectedJson = """ + "userUnprotectedDomains" : [] + """.trimIndent() assertEquals( expectedJson, testee.generateUserUnprotectedDomains(), @@ -76,7 +78,7 @@ class RealRuntimeConfigurationWriterTest { @Test fun whenGenerateUserPreferencesThenReturnUserPreferencesString() { val expectedJson = """ - userPreferences = { + "userPreferences" : { "debug": false, "platform": { "name": "android" @@ -93,12 +95,12 @@ class RealRuntimeConfigurationWriterTest { "credentials_saving": true, "inlineIcon_credentials": true, "emailProtection_incontext_signup": true, - "unknown_username_categorization": false, + "unknown_username_categorization": false } } } } - }; + } """.trimIndent() assertEquals( expectedJson, diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/AutofillWebMessageListenerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/AutofillWebMessageListenerTest.kt new file mode 100644 index 000000000000..4958df8013d3 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/AutofillWebMessageListenerTest.kt @@ -0,0 +1,74 @@ +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener + +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import org.junit.Assert.* +import org.junit.Test +import org.mockito.kotlin.mock + +class AutofillWebMessageListenerTest { + + private val mockReply: JavaScriptReplyProxy = mock() + + private val testee = object : AutofillWebMessageListener() { + override val key: String + get() = "testkey" + + override fun onPostMessage( + p0: WebView, + p1: WebMessageCompat, + p2: Uri, + p3: Boolean, + p4: JavaScriptReplyProxy, + ) { + } + + fun testStoreReply(reply: JavaScriptReplyProxy): String { + return storeReply(reply) + } + } + + @Test + fun whenStoreReplyThenGetBackNonNullId() { + assertNotNull(testee.testStoreReply(mockReply)) + } + + @Test + fun whenAttemptResponseWithNoAssociatedReplyThenMessageNotHandled() { + assertFalse(testee.onResponse("message", "unknown-request-id")) + } + + @Test + fun whenAttemptResponseWithAnAssociatedReplyThenMessageIsHandled() { + val requestId = testee.testStoreReply(mockReply) + assertTrue(testee.onResponse("message", requestId)) + } + + @Test + fun whenReplyIsUsedThenItIsCleanedUp() { + val requestId = testee.testStoreReply(mockReply) + assertTrue(testee.onResponse("message", requestId)) + assertFalse(testee.onResponse("message", requestId)) + } + + @Test + fun whenMaxConcurrentRepliesInUseThenAllStillUsable() { + val requestIds = mutableListOf() + repeat(10) { requestIds.add(it, testee.testStoreReply(mockReply)) } + requestIds.forEach { + assertTrue(testee.onResponse("message", it)) + } + } + + @Test + fun whenMaxConcurrentRepliesPlusOneInUseThenAllButFirstIsStillUsable() { + val requestIds = mutableListOf() + repeat(11) { requestIds.add(it, testee.testStoreReply(mockReply)) } + assertFalse(testee.onResponse("message", requestIds.first())) + requestIds.drop(1).forEach { + assertTrue(testee.onResponse("message", it)) + } + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/TestWebMessageListenerCallback.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/TestWebMessageListenerCallback.kt new file mode 100644 index 000000000000..5b44cc1adb05 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/TestWebMessageListenerCallback.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener + +import com.duckduckgo.autofill.api.AutofillWebMessageRequest +import com.duckduckgo.autofill.api.Callback +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType + +class TestWebMessageListenerCallback : Callback { + + // for injection + var credentialsToInject: List? = null + var credentialsAvailableToInject: Boolean? = null + + // for saving + var credentialsToSave: LoginCredentials? = null + + // for password generation + var offeredToGeneratePassword: Boolean = false + + // for email protection + var showNativeChooseEmailAddressPrompt: Boolean = false + var showNativeInContextEmailProtectionSignupPrompt: Boolean = false + + override suspend fun onCredentialsAvailableToInject( + autofillWebMessageRequest: AutofillWebMessageRequest, + credentials: List, + triggerType: LoginTriggerType, + ) { + credentialsAvailableToInject = true + this.credentialsToInject = credentials + } + + override suspend fun onCredentialsAvailableToSave( + autofillWebMessageRequest: AutofillWebMessageRequest, + credentials: LoginCredentials, + ) { + credentialsToSave = credentials + } + + override suspend fun onGeneratedPasswordAvailableToUse( + autofillWebMessageRequest: AutofillWebMessageRequest, + username: String?, + generatedPassword: String, + ) { + offeredToGeneratePassword = true + } + + override fun showNativeChooseEmailAddressPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { + showNativeChooseEmailAddressPrompt = true + } + + override fun showNativeInContextEmailProtectionSignupPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { + showNativeInContextEmailProtectionSignupPrompt = true + } + + override fun onCredentialsSaved(savedCredentials: LoginCredentials) { + // no-op + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillDataTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillDataTest.kt new file mode 100644 index 000000000000..af6e1ab17985 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/WebMessageListenerGetAutofillDataTest.kt @@ -0,0 +1,266 @@ +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener + +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker +import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillDataRequest +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputMainType.CREDENTIALS +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.USERNAME +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED +import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter +import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class WebMessageListenerGetAutofillDataTest { + + private val shareableCredentials: ShareableCredentials = mock() + private val autofillStore: InternalAutofillStore = mock() + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker = mock() + private val requestParser: AutofillRequestParser = mock() + private val webMessageReply: JavaScriptReplyProxy = mock() + private val testCallback = TestWebMessageListenerCallback() + private val mockWebView: WebView = mock() + private val responseWriter: AutofillResponseWriter = mock() + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val testee = WebMessageListenerGetAutofillData( + appCoroutineScope = coroutineTestRule.testScope, + dispatchers = coroutineTestRule.testDispatcherProvider, + autofillCapabilityChecker = autofillCapabilityChecker, + requestParser = requestParser, + autofillStore = autofillStore, + shareableCredentials = shareableCredentials, + loginDeduplicator = NoopDeduplicator(), + responseWriter = responseWriter, + ) + + @Before + fun setup() = runTest { + testee.callback = testCallback + + whenever(mockWebView.url).thenReturn(REQUEST_URL) + whenever(autofillCapabilityChecker.isAutofillEnabledByConfiguration(any())).thenReturn(true) + whenever(autofillCapabilityChecker.canInjectCredentialsToWebView(any())).thenReturn(true) + whenever(autofillCapabilityChecker.canSaveCredentialsFromWebView(any())).thenReturn(true) + whenever(shareableCredentials.shareableCredentials(any())).thenReturn(emptyList()) + whenever(responseWriter.generateEmptyResponseGetAutofillData()).thenReturn("") + } + + @Test + fun whenGettingSavedPasswordsNoCredentialsAvailableThenNoCredentialsCallbackInvoked() = runTest { + setupRequestForSubTypeUsername() + whenever(autofillStore.getCredentials(any())).thenReturn(emptyList()) + initiateGetAutofillDataRequest() + assertCredentialsUnavailable() + } + + @Test + fun whenGettingSavedPasswordsWithCredentialsAvailableThenCredentialsAvailableCallbackInvoked() = runTest { + setupRequestForSubTypeUsername() + whenever(autofillStore.getCredentials(any())).thenReturn(listOf(LoginCredentials(0, "example.com", "username", "password"))) + initiateGetAutofillDataRequest() + assertCredentialsAvailable() + } + + @Test + fun whenGettingSavedPasswordsWithCredentialsAvailableWithNullUsernameUsernameConvertedToEmptyString() = runTest { + setupRequestForSubTypePassword() + whenever(autofillStore.getCredentials(any())).thenReturn( + listOf( + loginCredential(username = null, password = "foo"), + loginCredential(username = null, password = "bar"), + loginCredential(username = "foo", password = "bar"), + ), + ) + initiateGetAutofillDataRequest() + assertCredentialsAvailable() + + // ensure the list of credentials now has two entries with empty string username (one for each null username) + assertCredentialsContains({ it.username }, "", "") + } + + @Test + fun whenGettingSavedPasswordsAndRequestSpecifiesSubtypeUsernameAndNoEntriesThenNoCredentialsCallbackInvoked() = runTest { + setupRequestForSubTypeUsername() + whenever(autofillStore.getCredentials(any())).thenReturn(emptyList()) + initiateGetAutofillDataRequest() + assertCredentialsUnavailable() + } + + @Test + fun whenGettingSavedPasswordsAndRequestSpecifiesSubtypeUsernameAndNoEntriesWithAUsernameThenNoCredentialsCallbackInvoked() = runTest { + setupRequestForSubTypeUsername() + whenever(autofillStore.getCredentials(any())).thenReturn( + listOf( + loginCredential(username = null, password = "foo"), + loginCredential(username = null, password = "bar"), + ), + ) + initiateGetAutofillDataRequest() + assertCredentialsUnavailable() + } + + @Test + fun whenGettingSavedPasswordsAndRequestSpecifiesSubtypeUsernameAndSingleEntryWithAUsernameThenCredentialsAvailableCallbackInvoked() = runTest { + setupRequestForSubTypeUsername() + whenever(autofillStore.getCredentials(any())).thenReturn( + listOf( + loginCredential(username = null, password = "foo"), + loginCredential(username = null, password = "bar"), + loginCredential(username = "foo", password = "bar"), + ), + ) + initiateGetAutofillDataRequest() + assertCredentialsAvailable() + assertCredentialsContains({ it.username }, "foo") + } + + @Test + fun whenGettingSavedPasswordsAndRequestSpecifiesSubtypeUsernameAndMultipleEntriesWithAUsernameThenCorrectCallbackInvoked() = runTest { + setupRequestForSubTypeUsername() + whenever(autofillStore.getCredentials(any())).thenReturn( + listOf( + loginCredential(username = null, password = "foo"), + loginCredential(username = "username1", password = "bar"), + loginCredential(username = null, password = "bar"), + loginCredential(username = null, password = "bar"), + loginCredential(username = "username2", password = null), + ), + ) + initiateGetAutofillDataRequest() + assertCredentialsAvailable() + assertCredentialsContains({ it.username }, "username1", "username2") + } + + @Test + fun whenGettingSavedPasswordsAndRequestSpecifiesSubtypePasswordAndNoEntriesThenNoCredentialsCallbackInvoked() = runTest { + setupRequestForSubTypePassword() + whenever(autofillStore.getCredentials(any())).thenReturn(emptyList()) + initiateGetAutofillDataRequest() + assertCredentialsUnavailable() + } + + @Test + fun whenGettingSavedPasswordsAndRequestSpecifiesSubtypePasswordAndNoEntriesWithAPasswordThenNoCredentialsCallbackInvoked() = runTest { + setupRequestForSubTypePassword() + whenever(autofillStore.getCredentials(any())).thenReturn( + listOf( + loginCredential(username = "foo", password = null), + loginCredential(username = "bar", password = null), + ), + ) + initiateGetAutofillDataRequest() + assertCredentialsUnavailable() + } + + @Test + fun whenGettingSavedPasswordsAndRequestSpecifiesSubtypePasswordAndSingleEntryWithAPasswordThenCredentialsAvailableCallbackInvoked() = runTest { + setupRequestForSubTypePassword() + whenever(autofillStore.getCredentials(any())).thenReturn( + listOf( + loginCredential(username = null, password = null), + loginCredential(username = "foobar", password = null), + loginCredential(username = "foo", password = "bar"), + ), + ) + initiateGetAutofillDataRequest() + assertCredentialsAvailable() + assertCredentialsContains({ it.password }, "bar") + } + + @Test + fun whenGettingSavedPasswordsAndRequestSpecifiesSubtypePasswordAndMultipleEntriesWithAPasswordThenCorrectCallbackInvoked() = runTest { + setupRequestForSubTypePassword() + whenever(autofillStore.getCredentials(any())).thenReturn( + listOf( + loginCredential(username = null, password = null), + loginCredential(username = "username2", password = null), + loginCredential(username = "username1", password = "password1"), + loginCredential(username = null, password = "password2"), + loginCredential(username = null, password = "password3"), + + ), + ) + initiateGetAutofillDataRequest() + assertCredentialsAvailable() + assertCredentialsContains({ it.password }, "password1", "password2", "password3") + } + + private fun assertCredentialsUnavailable() { + verify(responseWriter).generateEmptyResponseGetAutofillData() + verify(webMessageReply).postMessage(any()) + } + + private fun assertCredentialsAvailable() { + assertNotNull("Callback has not been called", testCallback.credentialsAvailableToInject) + assertTrue(testCallback.credentialsAvailableToInject!!) + } + + private fun assertCredentialsContains( + property: (LoginCredentials) -> String?, + vararg expected: String?, + ) { + val numberExpected = expected.size + val numberMatched = testCallback.credentialsToInject?.count { expected.contains(property(it)) } + Assert.assertEquals("Wrong number of matched properties. Expected $numberExpected but found $numberMatched", numberExpected, numberMatched) + } + + private fun initiateGetAutofillDataRequest(isMainFrame: Boolean = true) { + testee.onPostMessage( + webView = mockWebView, + message = WebMessageCompat(""), + sourceOrigin = REQUEST_ORIGIN, + isMainFrame = isMainFrame, + reply = webMessageReply, + ) + } + + private fun loginCredential( + username: String?, + password: String?, + ) = LoginCredentials(0, "example.com", username, password) + + private suspend fun setupRequestForSubTypeUsername() { + whenever(requestParser.parseAutofillDataRequest(any())).thenReturn( + Result.success(AutofillDataRequest(CREDENTIALS, USERNAME, USER_INITIATED, null)), + ) + } + + private suspend fun setupRequestForSubTypePassword() { + whenever(requestParser.parseAutofillDataRequest(any())).thenReturn( + Result.success(AutofillDataRequest(CREDENTIALS, PASSWORD, USER_INITIATED, null)), + ) + } + + private class NoopDeduplicator : AutofillLoginDeduplicator { + override fun deduplicate(originalUrl: String, logins: List): List = logins + } + + companion object { + private const val REQUEST_URL = "https://example.com" + private val REQUEST_ORIGIN = REQUEST_URL.toUri() + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/password/WebMessageListenerStoreFormDataTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/password/WebMessageListenerStoreFormDataTest.kt new file mode 100644 index 000000000000..96d44afe8e0a --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/integration/modern/listener/password/WebMessageListenerStoreFormDataTest.kt @@ -0,0 +1,158 @@ +package com.duckduckgo.autofill.impl.configuration.integration.modern.listener.password + +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker +import com.duckduckgo.autofill.impl.configuration.integration.modern.listener.TestWebMessageListenerCallback +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataCredentialsRequest +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataRequest +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository +import com.duckduckgo.autofill.impl.systemautofill.SystemAutofillServiceSuppressor +import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions +import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.AutogeneratedPasswordEventResolver +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class WebMessageListenerStoreFormDataTest { + + private val testCallback = TestWebMessageListenerCallback() + private val mockWebView: WebView = mock() + private val webMessageReply: JavaScriptReplyProxy = mock() + private val systemAutofillServiceSuppressor: SystemAutofillServiceSuppressor = mock() + private val passwordEventResolver: AutogeneratedPasswordEventResolver = mock() + private val autofillStore: InternalAutofillStore = mock() + private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor = mock() + private val requestParser: AutofillRequestParser = mock() + private val neverSavedSiteRepository: NeverSavedSiteRepository = mock() + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker = mock() + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val testee = WebMessageListenerStoreFormData( + appCoroutineScope = coroutineTestRule.testScope, + dispatchers = coroutineTestRule.testDispatcherProvider, + autofillCapabilityChecker = autofillCapabilityChecker, + neverSavedSiteRepository = neverSavedSiteRepository, + requestParser = requestParser, + autoSavedLoginsMonitor = autoSavedLoginsMonitor, + autofillStore = autofillStore, + passwordEventResolver = passwordEventResolver, + systemAutofillServiceSuppressor = systemAutofillServiceSuppressor, + ) + + @Before + fun setup() = runTest { + testee.callback = testCallback + testee.tabId = "abc-123" + whenever(mockWebView.url).thenReturn(REQUEST_URL) + whenever(autofillCapabilityChecker.isAutofillEnabledByConfiguration(any())).thenReturn(true) + whenever(autofillCapabilityChecker.canSaveCredentialsFromWebView(any())).thenReturn(true) + whenever(neverSavedSiteRepository.isInNeverSaveList(any())).thenReturn(false) + whenever(autoSavedLoginsMonitor.getAutoSavedLoginId(any())).thenReturn(null) + } + + @Test + fun whenStoreFormDataCalledWithNoPasswordThenCallbackInvoked() = runTest { + configureRequestParserToReturnSaveCredentialRequestType(username = "dax@duck.com", password = null) + simulateWebMessage() + assertNotNull(testCallback.credentialsToSave) + assertEquals("dax@duck.com", testCallback.credentialsToSave!!.username) + } + + @Test + fun whenStoreFormDataCalledWithNullUsernameAndPasswordThenCallbackNotInvoked() = runTest { + configureRequestParserToReturnSaveCredentialRequestType(username = null, password = null) + simulateWebMessage() + assertNull(testCallback.credentialsToSave) + } + + @Test + fun whenStoreFormDataCalledWithBlankUsernameThenCallbackInvoked() = runTest { + configureRequestParserToReturnSaveCredentialRequestType(username = " ", password = "password") + simulateWebMessage() + assertEquals(" ", testCallback.credentialsToSave!!.username) + assertEquals("password", testCallback.credentialsToSave!!.password) + } + + @Test + fun whenStoreFormDataCalledWithBlankPasswordThenCallbackInvoked() = runTest { + configureRequestParserToReturnSaveCredentialRequestType(username = "username", password = " ") + simulateWebMessage() + assertEquals("username", testCallback.credentialsToSave!!.username) + assertEquals(" ", testCallback.credentialsToSave!!.password) + } + + @Test + fun whenStoreFormDataCalledButSiteInNeverSaveListThenCallbackNotInvoked() = runTest { + configureRequestParserToReturnSaveCredentialRequestType(username = "username", password = "password") + whenever(neverSavedSiteRepository.isInNeverSaveList(any())).thenReturn(true) + simulateWebMessage() + assertNull(testCallback.credentialsToSave) + } + + @Test + fun whenStoreFormDataCalledWithBlankUsernameAndBlankPasswordThenCallbackNotInvoked() = runTest { + configureRequestParserToReturnSaveCredentialRequestType(username = " ", password = " ") + simulateWebMessage() + assertNull(testCallback.credentialsToSave) + } + + @Test + fun whenStoreFormDataCalledAndParsingErrorThenExceptionIsContained() = runTest { + configureRequestParserToReturnSaveCredentialRequestType(username = "username", password = "password") + whenever(requestParser.parseStoreFormDataRequest(any())).thenReturn(Result.failure(RuntimeException("Parsing error"))) + simulateWebMessage() + assertNull(testCallback.credentialsToSave) + } + + @Test + fun whenStoreFormDataCalledWithNoUsernameThenCallbackInvoked() = runTest { + configureRequestParserToReturnSaveCredentialRequestType(username = null, password = "password") + simulateWebMessage() + assertNotNull(testCallback.credentialsToSave) + } + + private suspend fun configureRequestParserToReturnSaveCredentialRequestType( + username: String?, + password: String?, + ) { + val credentials = AutofillStoreFormDataCredentialsRequest(username = username, password = password) + val topLevelRequest = AutofillStoreFormDataRequest(credentials) + whenever(requestParser.parseStoreFormDataRequest(any())).thenReturn(Result.success(topLevelRequest)) + whenever(passwordEventResolver.decideActions(anyOrNull(), any())).thenReturn(listOf(Actions.PromptToSave)) + } + + private fun simulateWebMessage(isMainFrame: Boolean = true) { + testee.onPostMessage( + webView = mockWebView, + message = WebMessageCompat(""), + sourceOrigin = REQUEST_ORIGIN, + isMainFrame = isMainFrame, + reply = webMessageReply, + ) + } + + companion object { + private const val REQUEST_URL = "https://example.com" + private val REQUEST_ORIGIN = REQUEST_URL.toUri() + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmailTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmailTest.kt index 0215dda1f3db..a5d1bce72fda 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmailTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmailTest.kt @@ -23,14 +23,16 @@ import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.COHORT import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LAST_USED_DAY -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.DoNotUseEmailProtection import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.UsePersonalEmailAddress import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.UsePrivateAliasAddress +import com.duckduckgo.autofill.api.credential.saving.DuckAddressLoginCreator import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.engagement.DataAutofilledListener +import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_TOOLTIP_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ADDRESS import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ALIAS @@ -41,7 +43,12 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.* +import org.mockito.kotlin.any +import org.mockito.kotlin.argWhere +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class ResultHandlerEmailProtectionChooseEmailTest { @@ -51,16 +58,18 @@ class ResultHandlerEmailProtectionChooseEmailTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val callback: AutofillEventListener = mock() - private val appBuildConfig: AppBuildConfig = mock() private val emailManager: EmailManager = mock() private val pixel: Pixel = mock() + private val messagePoster: AutofillMessagePoster = mock() + private val loginCreator: DuckAddressLoginCreator = mock() private val testee = ResultHandlerEmailProtectionChooseEmail( - appBuildConfig = appBuildConfig, emailManager = emailManager, dispatchers = coroutineTestRule.testDispatcherProvider, appCoroutineScope = coroutineTestRule.testScope, pixel = pixel, + messagePoster = messagePoster, + loginCreator = loginCreator, autofilledListeners = FakePluginPoint(), ) @@ -73,17 +82,17 @@ class ResultHandlerEmailProtectionChooseEmailTest { } @Test - fun whenUserSelectedToUsePersonalAddressThenCorrectCallbackInvoked() = runTest { + fun whenUserSelectedToUsePersonalAddressThenCorrectResponsePosted() = runTest { val bundle = bundle(result = UsePersonalEmailAddress) testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback).onUseEmailProtectionPersonalAddress(any(), any()) + verify(messagePoster).postMessage(argWhere { it.contains(""""alias": "personal-example""") }, any()) } @Test - fun whenUserSelectedToUsePrivateAliasAddressThenCorrectCallbackInvoked() = runTest { + fun whenUserSelectedToUsePrivateAliasAddressThenCorrectResponsePosted() = runTest { val bundle = bundle(result = UsePrivateAliasAddress) testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback).onUseEmailProtectionPrivateAlias(any(), any()) + verify(messagePoster).postMessage(argWhere { it.contains(""""alias": "private-example""") }, any()) } @Test @@ -146,7 +155,9 @@ class ResultHandlerEmailProtectionChooseEmailTest { result: EmailProtectionChooseEmailDialog.UseEmailResultType?, ): Bundle { return Bundle().also { - it.putString(EmailProtectionChooseEmailDialog.KEY_URL, url) + if (url != null) { + it.putParcelable(EmailProtectionChooseEmailDialog.KEY_URL, AutofillWebMessageRequest(url, url, "")) + } it.putParcelable(EmailProtectionChooseEmailDialog.KEY_RESULT, result) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/incontext/availability/RealEmailProtectionInContextAvailabilityRulesTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/incontext/availability/RealEmailProtectionInContextAvailabilityRulesTest.kt index d2844a4e227a..0d73f6a46802 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/incontext/availability/RealEmailProtectionInContextAvailabilityRulesTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/incontext/availability/RealEmailProtectionInContextAvailabilityRulesTest.kt @@ -49,7 +49,7 @@ class RealEmailProtectionInContextAvailabilityRulesTest { configureEnglishLocale() configureAsRecentInstall() - emailProtectionInContextSignupFeature.self().setEnabled(State(enable = true)) + emailProtectionInContextSignupFeature.self().setRawStoredState(State(enable = true)) whenever(exceptions.isAnException(any())).thenReturn(false) whenever(exceptions.isAnException(DISALLOWED_URL)).thenReturn(true) whenever(internalTestUserChecker.isInternalTestUser).thenReturn(false) @@ -83,7 +83,7 @@ class RealEmailProtectionInContextAvailabilityRulesTest { @Test fun whenFeatureDisabledInRemoteConfigThenNotPermitted() = runTest { - emailProtectionInContextSignupFeature.self().setEnabled(State(enable = false)) + emailProtectionInContextSignupFeature.self().setRawStoredState(State(enable = false)) assertFalse(testee.permittedToShow(ALLOWED_URL)) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListenerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListenerTest.kt index 7c9ccf9e69cc..16d1b5f5bc53 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListenerTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListenerTest.kt @@ -1,7 +1,7 @@ package com.duckduckgo.autofill.impl.engagement import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_ONBOARDED_USER import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.common.test.CoroutineTestRule @@ -58,10 +58,10 @@ class EngagementPasswordAddedListenerTest { } private fun verifyPixelSentOnce() { - verify(pixel).fire(AUTOFILL_ENGAGEMENT_ONBOARDED_USER, type = UNIQUE) + verify(pixel).fire(AUTOFILL_ENGAGEMENT_ONBOARDED_USER, type = Unique()) } private fun verifyPixelNotSent() { - verify(pixel, never()).fire(AUTOFILL_ENGAGEMENT_ONBOARDED_USER, type = UNIQUE) + verify(pixel, never()).fire(AUTOFILL_ENGAGEMENT_ONBOARDED_USER, type = Unique()) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/reporting/AutofillBreakageReportCanShowRulesImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/reporting/AutofillBreakageReportCanShowRulesImplTest.kt index 839992f97621..767d6f36aa1a 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/reporting/AutofillBreakageReportCanShowRulesImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/reporting/AutofillBreakageReportCanShowRulesImplTest.kt @@ -44,7 +44,7 @@ class AutofillBreakageReportCanShowRulesImplTest { @Before fun setup() = runTest { - remoteFeature.self().setEnabled(State(enable = true)) + remoteFeature.self().setRawStoredState(State(enable = true)) whenever(exceptionsRepository.exceptions).thenReturn(emptyList()) whenever(dataStore.getMinimumNumberOfDaysBeforeReportPromptReshown()).thenReturn(10) whenever(timeProvider.currentTimeMillis()).thenReturn(System.currentTimeMillis()) @@ -52,7 +52,7 @@ class AutofillBreakageReportCanShowRulesImplTest { @Test fun whenFeatureIsDisabledThenCannotShowPrompt() = runTest { - remoteFeature.self().setEnabled(State(enable = false)) + remoteFeature.self().setRawStoredState(State(enable = false)) assertFalse(testee.canShowForSite(aSite())) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/reporting/AutofillBreakageReportSenderImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/reporting/AutofillBreakageReportSenderImplTest.kt index fd03a4c2b89a..919fe45ec125 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/reporting/AutofillBreakageReportSenderImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/reporting/AutofillBreakageReportSenderImplTest.kt @@ -2,7 +2,7 @@ package com.duckduckgo.autofill.impl.reporting import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.AutofillGlobalCapabilityChecker @@ -161,7 +161,7 @@ class AutofillBreakageReportSenderImplTest { private fun sendReport(url: String = "https://example.com", protectionStatus: Boolean? = true) { testee.sendBreakageReport(url, protectionStatus) - verify(pixel).fire(eq(AUTOFILL_SITE_BREAKAGE_REPORT), paramsCaptor.capture(), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_SITE_BREAKAGE_REPORT), paramsCaptor.capture(), any(), eq(Count)) } private fun String.assertMatchesEmailProtection() { diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt index ef42c9ae1688..80f3e91f8826 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt @@ -20,7 +20,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserOverflow import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserSnackbar @@ -31,6 +31,7 @@ import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.SettingsActivity import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.Sync import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.InternalAutofillCapabilityChecker import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizerImpl import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames @@ -121,6 +122,7 @@ class AutofillSettingsViewModelTest { private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules = mock() private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore = mock() private val urlMatcher = AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl()) + private val autofillCapabilityChecker: InternalAutofillCapabilityChecker = mock() private val testee = AutofillSettingsViewModel( autofillStore = mockStore, @@ -140,18 +142,18 @@ class AutofillSettingsViewModelTest { autofillBreakageReportSender = autofillBreakageReportSender, autofillBreakageReportDataStore = autofillBreakageReportDataStore, autofillBreakageReportCanShowRules = autofillBreakageReportCanShowRules, + autofillCapabilityChecker = autofillCapabilityChecker, ) @Before - fun setup() { + fun setup() = runTest { whenever(webUrlIdentifier.isLikelyAUrl(anyOrNull())).thenReturn(true) - - runTest { - whenever(mockStore.getAllCredentials()).thenReturn(emptyFlow()) - whenever(mockStore.getCredentialCount()).thenReturn(flowOf(0)) - whenever(neverSavedSiteRepository.neverSaveListCount()).thenReturn(emptyFlow()) - whenever(deviceAuthenticator.isAuthenticationRequiredForAutofill()).thenReturn(true) - } + whenever(autofillCapabilityChecker.webViewSupportsAutofill()).thenReturn(true) + whenever(autofillCapabilityChecker.isAutofillEnabledByConfiguration(any())).thenReturn(true) + whenever(mockStore.getAllCredentials()).thenReturn(emptyFlow()) + whenever(mockStore.getCredentialCount()).thenReturn(flowOf(0)) + whenever(neverSavedSiteRepository.neverSaveListCount()).thenReturn(emptyFlow()) + whenever(deviceAuthenticator.isAuthenticationRequiredForAutofill()).thenReturn(true) } @Test @@ -181,7 +183,7 @@ class AutofillSettingsViewModelTest { @Test fun whenUserDisablesAutofillThenCorrectPixelFired() { testee.onDisableAutofill(aAutofillSettingsLaunchSource()) - verify(pixel).fire(eq(AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_DISABLED), any(), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_DISABLED), any(), any(), eq(Count)) } @Test @@ -695,49 +697,49 @@ class AutofillSettingsViewModelTest { fun whenScreenLaunchedFromSnackbarThenCorrectLaunchPixelSent() { testee.sendLaunchPixel(BrowserSnackbar) val expectedParams = mapOf("source" to "browser_snackbar") - verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(Count)) } @Test fun whenScreenLaunchedFromBrowserThenCorrectLaunchPixelSent() { testee.sendLaunchPixel(BrowserOverflow) val expectedParams = mapOf("source" to "overflow_menu") - verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(Count)) } @Test fun whenScreenLaunchedFromSyncThenCorrectLaunchPixelSent() { testee.sendLaunchPixel(Sync) val expectedParams = mapOf("source" to "sync") - verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(Count)) } @Test fun whenScreenLaunchedFromDisablePromptThenCorrectLaunchPixelSent() { testee.sendLaunchPixel(DisableInSettingsPrompt) val expectedParams = mapOf("source" to "save_login_disable_prompt") - verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(Count)) } @Test fun whenScreenLaunchedFromNewTabShortcutThenCorrectLaunchPixelSent() { testee.sendLaunchPixel(NewTabShortcut) val expectedParams = mapOf("source" to "new_tab_page_shortcut") - verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(Count)) } @Test fun whenScreenLaunchedFromSettingsActivityThenCorrectLaunchPixelSent() { testee.sendLaunchPixel(SettingsActivity) val expectedParams = mapOf("source" to "settings") - verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(Count)) } @Test fun whenScreenLaunchedFromInternalDevSettingsActivityThenCorrectLaunchPixelSent() { testee.sendLaunchPixel(InternalDevSettings) val expectedParams = mapOf("source" to "internal_dev_settings") - verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(COUNT)) + verify(pixel).fire(eq(AUTOFILL_MANAGEMENT_SCREEN_OPENED), eq(expectedParams), any(), eq(Count)) } @Test @@ -922,6 +924,26 @@ class AutofillSettingsViewModelTest { verify(pixel).fire(AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISMISSED) } + @Test + fun whenWebViewDoesNotSupportAutofillThenShowDisabledMode() = runTest { + whenever(autofillCapabilityChecker.webViewSupportsAutofill()).thenReturn(false) + testee.onViewCreated() + testee.viewState.test { + assertEquals(false, this.awaitItem().isAutofillSupported) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenAutofillConfigDisabledThenShowDisabledMode() = runTest { + whenever(autofillCapabilityChecker.isAutofillEnabledByConfiguration(any())).thenReturn(false) + testee.onViewCreated() + testee.viewState.test { + assertEquals(false, this.awaitItem().autofillEnabled) + cancelAndIgnoreRemainingEvents() + } + } + private fun List.verifyHasCommandToShowDeleteAllConfirmation(expectedNumberOfCredentialsToDelete: Int) { val confirmationCommand = this.firstOrNull { it is LaunchDeleteAllPasswordsConfirmation } assertNotNull(confirmationCommand) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt index 6d1858112a11..d60cee2bb47a 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt @@ -49,7 +49,7 @@ class AutofillSurveyImplTest { whenever(appBuildConfig.deviceLocale).thenReturn(Locale("en")) coroutineTestRule.testScope.runTest { - surveysFeature.self().setEnabled(State(enable = true)) + surveysFeature.self().setRawStoredState(State(enable = true)) whenever(autofillSurveyStore.availableSurveys()).thenReturn( listOf( SurveyDetails("autofill-2024-04-26", "https://example.com/survey"), diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPasswordTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPasswordTest.kt index 02cdbe160e3d..45ed9e3d3040 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPasswordTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPasswordTest.kt @@ -21,6 +21,7 @@ import androidx.fragment.app.Fragment import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.autofill.api.AutofillEventListener +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.NoMatch import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_ACCEPTED @@ -30,6 +31,8 @@ import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_USER import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor import com.duckduckgo.autofill.impl.engagement.DataAutofilledListener +import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster +import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.PluginPoint @@ -51,13 +54,17 @@ class ResultHandlerUseGeneratedPasswordTest { private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor = mock() private val existingCredentialMatchDetector: ExistingCredentialMatchDetector = mock() private val callback: AutofillEventListener = mock() + private val messagePoster: AutofillMessagePoster = mock() + private val responseWriter: AutofillResponseWriter = mock() private val testee = ResultHandlerUseGeneratedPassword( dispatchers = coroutineTestRule.testDispatcherProvider, autofillStore = autofillStore, - appCoroutineScope = coroutineTestRule.testScope, autoSavedLoginsMonitor = autoSavedLoginsMonitor, existingCredentialMatchDetector = existingCredentialMatchDetector, + messagePoster = messagePoster, + responseWriter = responseWriter, + appCoroutineScope = coroutineTestRule.testScope, autofilledListeners = FakePluginPoint(), ) @@ -73,18 +80,20 @@ class ResultHandlerUseGeneratedPasswordTest { } @Test - fun whenUserRejectedToUsePasswordThenCorrectCallbackInvoked() { + fun whenUserRejectedToUsePasswordThenCorrectResponsePosted() = runTest { val bundle = bundle("example.com", acceptedGeneratedPassword = false) testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback).onRejectGeneratedPassword("example.com") + verify(responseWriter).generateResponseForRejectingGeneratedPassword() + verify(messagePoster).postMessage(anyOrNull(), any()) } @Test - fun whenUserAcceptedToUsePasswordNoAutoLoginInThenCorrectCallbackInvoked() = runTest { + fun whenUserAcceptedToUsePasswordNoAutoLoginInThenCorrectResponsePosted() = runTest { whenever(autoSavedLoginsMonitor.getAutoSavedLoginId(any())).thenReturn(null) val bundle = bundle("example.com", acceptedGeneratedPassword = true, password = "pw") testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback).onAcceptGeneratedPassword("example.com") + verify(responseWriter).generateResponseForAcceptingGeneratedPassword() + verify(messagePoster).postMessage(anyOrNull(), any()) } @Test @@ -167,10 +176,12 @@ class ResultHandlerUseGeneratedPasswordTest { } @Test - fun whenUserAcceptedToUsePasswordButPasswordIsNullThenCorrectCallbackNotInvoked() = runTest { + fun whenUserAcceptedToUsePasswordButPasswordIsNullThen() = runTest { val bundle = bundle("example.com", acceptedGeneratedPassword = true, password = null) testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback, never()).onAcceptGeneratedPassword("example.com") + + verify(responseWriter, never()).generateResponseForAcceptingGeneratedPassword() + verify(messagePoster, never()).postMessage(any(), any()) } @Test @@ -187,7 +198,9 @@ class ResultHandlerUseGeneratedPasswordTest { password: String? = null, ): Bundle { return Bundle().also { - it.putString(KEY_URL, url) + if (url != null) { + it.putParcelable(KEY_URL, AutofillWebMessageRequest(url, url, "abc-123")) + } it.putBoolean(KEY_ACCEPTED, acceptedGeneratedPassword) it.putString(KEY_USERNAME, username) it.putString(KEY_PASSWORD, password) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentialsTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentialsTest.kt index ac353e319d1b..3bf61138190d 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentialsTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentialsTest.kt @@ -20,8 +20,8 @@ import android.os.Bundle import androidx.fragment.app.Fragment import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialSavePickerDialog import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor @@ -45,14 +45,12 @@ class ResultHandlerSaveLoginCredentialsTest { private val autofillFireproofDialogSuppressor: AutofillFireproofDialogSuppressor = mock() private val declineCounter: AutofillDeclineCounter = mock() private val autofillStore: InternalAutofillStore = mock() - private val appBuildConfig: AppBuildConfig = mock() private val testee = ResultHandlerSaveLoginCredentials( autofillFireproofDialogSuppressor = autofillFireproofDialogSuppressor, dispatchers = coroutineTestRule.testDispatcherProvider, declineCounter = declineCounter, autofillStore = autofillStore, - appBuildConfig = appBuildConfig, appCoroutineScope = coroutineTestRule.testScope, ) @@ -108,7 +106,9 @@ class ResultHandlerSaveLoginCredentialsTest { credentials: LoginCredentials?, ): Bundle { return Bundle().also { - it.putString(CredentialSavePickerDialog.KEY_URL, url) + if (url != null) { + it.putParcelable(CredentialSavePickerDialog.KEY_URL, AutofillWebMessageRequest(url, url, "")) + } it.putParcelable(CredentialSavePickerDialog.KEY_CREDENTIALS, credentials) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelectionTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelectionTest.kt index 18f29ff7b9f4..6cfc63c5f72a 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelectionTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelectionTest.kt @@ -21,14 +21,16 @@ import androidx.fragment.app.Fragment import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.NoMatch import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.deviceauth.FakeAuthenticator import com.duckduckgo.autofill.impl.engagement.DataAutofilledListener +import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster +import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.PluginPoint @@ -48,10 +50,11 @@ class ResultHandlerCredentialSelectionTest { private val pixel: Pixel = mock() private val existingCredentialMatchDetector: ExistingCredentialMatchDetector = mock() private val callback: AutofillEventListener = mock() - private val appBuildConfig: AppBuildConfig = mock() private lateinit var deviceAuthenticator: FakeAuthenticator private lateinit var testee: ResultHandlerCredentialSelection private val autofillStore: InternalAutofillStore = mock() + private val messagePoster: AutofillMessagePoster = mock() + private val responseWriter: AutofillResponseWriter = mock() @Before fun setup() = runTest { @@ -65,35 +68,43 @@ class ResultHandlerCredentialSelectionTest { } @Test - fun whenUserRejectedToUseCredentialThenCorrectCallbackInvoked() = runTest { + fun whenUserRejectedToUseCredentialThenCorrectResponsePosted() = runTest { configureSuccessfulAuth() val bundle = bundleForUserCancelling("example.com") testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback).onNoCredentialsChosenForAutofill("example.com") + + verify(responseWriter).generateEmptyResponseGetAutofillData() + verify(messagePoster).postMessage(anyOrNull(), any()) } @Test - fun whenUserAcceptedToUseCredentialsAndSuccessfullyAuthenticatedThenCorrectCallbackInvoked() = runTest { + fun whenUserAcceptedToUseCredentialsAndSuccessfullyAuthenticatedThenCorrectResponsePosted() = runTest { configureSuccessfulAuth() val bundle = bundleForUserAcceptingToAutofill("example.com") testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback).onShareCredentialsForAutofill("example.com", aLogin()) + + verify(responseWriter).generateResponseGetAutofillData(any()) + verify(messagePoster).postMessage(anyOrNull(), any()) } @Test - fun whenUserAcceptedToUseCredentialsAndCancelsAuthenticationThenCorrectCallbackInvoked() = runTest { + fun whenUserAcceptedToUseCredentialsAndCancelsAuthenticationThenCorrectResponsePosted() = runTest { configureCancelledAuth() val bundle = bundleForUserAcceptingToAutofill("example.com") testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback).onNoCredentialsChosenForAutofill("example.com") + + verify(responseWriter).generateEmptyResponseGetAutofillData() + verify(messagePoster).postMessage(anyOrNull(), any()) } @Test - fun whenUserAcceptedToUseCredentialsAndAuthenticationFailsThenCorrectCallbackInvoked() = runTest { + fun whenUserAcceptedToUseCredentialsAndAuthenticationFailsThenCorrectResponsePosted() = runTest { configureFailedAuth() val bundle = bundleForUserAcceptingToAutofill("example.com") testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(callback).onNoCredentialsChosenForAutofill("example.com") + + verify(responseWriter).generateEmptyResponseGetAutofillData() + verify(messagePoster).postMessage(anyOrNull(), any()) } @Test @@ -101,7 +112,7 @@ class ResultHandlerCredentialSelectionTest { configureSuccessfulAuth() val bundle = bundleMissingCredentials("example.com") testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verifyNoInteractions(callback) + verifyNoInteractions(messagePoster) } @Test @@ -112,25 +123,25 @@ class ResultHandlerCredentialSelectionTest { verifyNoInteractions(callback) } - private fun bundleForUserCancelling(url: String?): Bundle { + private fun bundleForUserCancelling(url: String): Bundle { return Bundle().also { - it.putString(CredentialAutofillPickerDialog.KEY_URL, url) + it.putParcelable(CredentialAutofillPickerDialog.KEY_URL_REQUEST, url.asUrlRequest()) it.putBoolean(CredentialAutofillPickerDialog.KEY_CANCELLED, true) } } - private fun bundleForUserAcceptingToAutofill(url: String?): Bundle { + private fun bundleForUserAcceptingToAutofill(url: String): Bundle { return Bundle().also { - it.putString(CredentialAutofillPickerDialog.KEY_URL, url) + it.putParcelable(CredentialAutofillPickerDialog.KEY_URL_REQUEST, url.asUrlRequest()) it.putBoolean(CredentialAutofillPickerDialog.KEY_CANCELLED, false) it.putParcelable(CredentialAutofillPickerDialog.KEY_CREDENTIALS, aLogin()) } } private fun bundleMissingUrl(): Bundle = Bundle() - private fun bundleMissingCredentials(url: String?): Bundle { + private fun bundleMissingCredentials(url: String): Bundle { return Bundle().also { - it.putString(CredentialAutofillPickerDialog.KEY_URL, url) + it.putParcelable(CredentialAutofillPickerDialog.KEY_URL_REQUEST, url.asUrlRequest()) } } @@ -159,12 +170,17 @@ class ResultHandlerCredentialSelectionTest { appCoroutineScope = coroutineTestRule.testScope, pixel = pixel, deviceAuthenticator = deviceAuthenticator, - appBuildConfig = appBuildConfig, autofillStore = autofillStore, + messagePoster = messagePoster, + autofillResponseWriter = responseWriter, autofilledListeners = FakePluginPoint(), ) } + private fun String.asUrlRequest(): AutofillWebMessageRequest { + return AutofillWebMessageRequest(this, this, "request-id-123") + } + private class FakePluginPoint : PluginPoint { override fun getPlugins(): Collection { return emptyList() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentialsTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentialsTest.kt index 8d9b3c36c206..46e7bb93b88b 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentialsTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentialsTest.kt @@ -20,8 +20,8 @@ import android.os.Bundle import androidx.fragment.app.Fragment import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener +import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType.Password @@ -45,13 +45,11 @@ class ResultHandlerUpdateLoginCredentialsTest { private val autofillStore: InternalAutofillStore = mock() private val autofillDialogSuppressor: AutofillFireproofDialogSuppressor = mock() private val callback: AutofillEventListener = mock() - private val appBuildConfig: AppBuildConfig = mock() private val testee = ResultHandlerUpdateLoginCredentials( autofillFireproofDialogSuppressor = autofillDialogSuppressor, dispatchers = coroutineTestRule.testDispatcherProvider, autofillStore = autofillStore, - appBuildConfig = appBuildConfig, appCoroutineScope = coroutineTestRule.testScope, ) @@ -103,7 +101,7 @@ class ResultHandlerUpdateLoginCredentialsTest { updateType: CredentialUpdateType, ): Bundle { return Bundle().also { - if (url != null) it.putString(CredentialUpdateExistingCredentialsDialog.KEY_URL, url) + if (url != null) it.putParcelable(CredentialUpdateExistingCredentialsDialog.KEY_URL, AutofillWebMessageRequest(url, url, "")) if (credentials != null) it.putParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIALS, credentials) it.putParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIAL_UPDATE_TYPE, updateType) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/store/feature/RealAutofillDefaultStateDeciderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/store/feature/RealAutofillDefaultStateDeciderTest.kt index 748c446fc5b3..cc0ba25614e6 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/store/feature/RealAutofillDefaultStateDeciderTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/store/feature/RealAutofillDefaultStateDeciderTest.kt @@ -74,8 +74,8 @@ class RealAutofillDefaultStateDeciderTest { } private fun configureRemoteFeatureEnabled(onByDefaultNewUsers: Boolean, onByDefaultExistingUsers: Boolean = false) { - autofillFeature.onByDefault().setEnabled(State(enable = onByDefaultNewUsers)) - autofillFeature.onForExistingUsers().setEnabled(State(enable = onByDefaultExistingUsers)) + autofillFeature.onByDefault().setRawStoredState(State(enable = onByDefaultNewUsers)) + autofillFeature.onForExistingUsers().setRawStoredState(State(enable = onByDefaultExistingUsers)) } private fun configureDaysInstalled(daysInstalled: Long) { diff --git a/autofill/autofill-test/src/main/java/com/duckduckgo/autofill/api/FakeAutofillFeature.kt b/autofill/autofill-test/src/main/java/com/duckduckgo/autofill/api/FakeAutofillFeature.kt deleted file mode 100644 index 303db459c592..000000000000 --- a/autofill/autofill-test/src/main/java/com/duckduckgo/autofill/api/FakeAutofillFeature.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2023 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.autofill.api - -import com.duckduckgo.feature.toggles.api.FeatureToggles -import com.duckduckgo.feature.toggles.api.Toggle - -class FakeAutofillFeature private constructor() { - - companion object { - fun create(): AutofillFeature { - return FeatureToggles.Builder() - .store( - object : Toggle.Store { - private val map = mutableMapOf() - - override fun set(key: String, state: Toggle.State) { - map[key] = state - } - - override fun get(key: String): Toggle.State? { - return map[key] - } - }, - ) - .featureName("fakeAutofill") - .build() - .create(AutofillFeature::class.java) - } - } -} diff --git a/browser-api/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt b/browser-api/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt index 509411bfe793..31b255ae2e16 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt @@ -47,6 +47,7 @@ interface SpecialUrlDetector { class CloakedAmpLink(val ampUrl: String) : UrlType() class TrackingParameterLink(val cleanedUrl: String) : UrlType() data object ShouldLaunchPrivacyProLink : UrlType() + data class ShouldLaunchDuckPlayerLink(val url: Uri) : UrlType() class DuckScheme(val uriString: String) : UrlType() } } diff --git a/browser-api/src/main/java/com/duckduckgo/app/browser/api/WebViewCapabilityChecker.kt b/browser-api/src/main/java/com/duckduckgo/app/browser/api/WebViewCapabilityChecker.kt new file mode 100644 index 000000000000..42aabc3fc5b6 --- /dev/null +++ b/browser-api/src/main/java/com/duckduckgo/app/browser/api/WebViewCapabilityChecker.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.api + +/** + * Allows WebView capabilities to be queried. + * WebView capabilities depend on various conditions, such as WebView version, feature flags etc... + * Capabilities can change over time, so it's recommended to always check immediately before trying to use that capability. + */ +interface WebViewCapabilityChecker { + + /** + * Check if a particular capability is currently supported by the WebView + */ + suspend fun isSupported(capability: WebViewCapability): Boolean + + /** + * WebView capabilities, which can be provided to [isSupported] + */ + sealed interface WebViewCapability { + /** + * WebMessageListener + * The ability to post web messages to JS, and receive web messages from JS + */ + data object WebMessageListener : WebViewCapability + + /** + * DocumentStartJavaScript + * The ability to inject Javascript which is guaranteed to be executed first on the page, and available in all iframes + */ + data object DocumentStartJavaScript : WebViewCapability + } +} diff --git a/common/common-ui-internal/src/main/java/com/duckduckgo/common/ui/internal/ThemesPreviewInternalFeature.kt b/common/common-ui-internal/src/main/java/com/duckduckgo/common/ui/internal/ThemesPreviewInternalFeature.kt index 856f612e17e4..743c411854ed 100644 --- a/common/common-ui-internal/src/main/java/com/duckduckgo/common/ui/internal/ThemesPreviewInternalFeature.kt +++ b/common/common-ui-internal/src/main/java/com/duckduckgo/common/ui/internal/ThemesPreviewInternalFeature.kt @@ -28,7 +28,7 @@ import javax.inject.Inject @PriorityKey(InternalFeaturePlugin.ADS_SETTINGS_PRIO_KEY) class ThemesPreviewInternalFeature @Inject constructor() : InternalFeaturePlugin { override fun internalFeatureTitle(): String { - return "App Components Design Preview" + return "Android Design System Preview" } override fun internalFeatureSubtitle(): String { diff --git a/common/common-ui/lint-baseline.xml b/common/common-ui/lint-baseline.xml index 26361d0c9430..2740bfa296d0 100644 --- a/common/common-ui/lint-baseline.xml +++ b/common/common-ui/lint-baseline.xml @@ -278,7 +278,7 @@ + message="Hardcoded string "Primary Small", should use `@string` resource" + errorLine1=" android:text="Primary Small" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/component_buttons.xml" + line="44" + column="13"/> + errorLine1=" android:text="Primary Small"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="50" + column="13"/> + message="Hardcoded string "Primary Large", should use `@string` resource" + errorLine1=" android:text="Primary Large"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="57" + column="13"/> + errorLine1=" android:text="Primary Large"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="64" + column="13"/> + errorLine1=" android:text="Primary Disabled" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="73" + column="13"/> + errorLine1=" android:text="Secondary Small" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="79" + column="13"/> + + + + + errorLine1=" android:text="Secondary Large"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="92" + column="13"/> + + + + + errorLine1=" android:text="Secondary Disabled" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="108" + column="13"/> + errorLine1=" android:text="Destructive Small" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="114" + column="13"/> + + + + + errorLine1=" android:text="Destructive Large"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="127" + column="13"/> + + + + + errorLine1=" android:text="Destructive Disabled" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="143" + column="13"/> + errorLine1=" android:text="Ghost Small" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="149" + column="13"/> + + + + + errorLine1=" android:text="Ghost Large"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="162" + column="13"/> + + + + + errorLine1=" android:text="Ghost Disabled" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="178" + column="13"/> + errorLine1=" android:text="Ghost Destructive Small" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="184" + column="13"/> + + + + + errorLine1=" android:text="Ghost Destructive Large"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="197" + column="13"/> + + + + + errorLine1=" android:text="Ghost Destructive Disabled" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="213" + column="13"/> + + + + + + + + + + + + + + + + + icon.setBounds(0, 0, icon.intrinsicWidth, icon.intrinsicHeight) + + // Centers the icon vertically + // ImageSpan.ALIGN_CENTER is API 29+ and doesn't center correctly + val imageSpan = object : ImageSpan(icon) { + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint, + ) { + canvas.save() + + val fontMetricsInt = paint.fontMetricsInt + val translationY = ((y + fontMetricsInt.descent + y + fontMetricsInt.ascent) / 2) - (icon.bounds.height() / 2) + canvas.translate(x, translationY.toFloat()) + icon.draw(canvas) + + canvas.restore() + } + } + // Adds a gap at the beginning of the string to make space for the icon + spannableString.insert(0, " ") + spannableString.setSpan(imageSpan, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return spannableString +} diff --git a/common/common-ui/src/main/res/layout/activity_app_components.xml b/common/common-ui/src/main/res/layout/activity_app_components.xml index 57c10630662d..2ac3b8caeee6 100644 --- a/common/common-ui/src/main/res/layout/activity_app_components.xml +++ b/common/common-ui/src/main/res/layout/activity_app_components.xml @@ -52,7 +52,7 @@ android:layout_height="wrap_content" android:gravity="start" android:paddingTop="@dimen/keyline_4" - android:text="App Components" + android:text="@string/ads_demo_header_title" app:drawableStartCompat="@drawable/ic_dax_icon" android:drawablePadding="@dimen/keyline_2"/> diff --git a/common/common-ui/src/main/res/layout/component_buttons.xml b/common/common-ui/src/main/res/layout/component_buttons.xml index 85bbe83e97eb..8a1353411476 100644 --- a/common/common-ui/src/main/res/layout/component_buttons.xml +++ b/common/common-ui/src/main/res/layout/component_buttons.xml @@ -1,5 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - + android:layout_height="match_parent" + android:gravity="center" + android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/common-ui/src/main/res/layout/component_checkbox.xml b/common/common-ui/src/main/res/layout/component_checkbox.xml index 62d0e3837d6e..51389aea2be1 100644 --- a/common/common-ui/src/main/res/layout/component_checkbox.xml +++ b/common/common-ui/src/main/res/layout/component_checkbox.xml @@ -22,7 +22,6 @@ android:clipToPadding="false" android:paddingTop="@dimen/keyline_5" android:paddingBottom="@dimen/keyline_5" - android:paddingStart="@dimen/keyline_4" android:paddingEnd="@dimen/keyline_4"> @@ -107,6 +105,8 @@ android:id="@+id/expandableItemRootLayout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical"/> + android:orientation="vertical" + android:layout_marginStart="@dimen/keyline_4" + android:layout_marginEnd="@dimen/keyline_4" /> diff --git a/common/common-ui/src/main/res/layout/component_radio_button.xml b/common/common-ui/src/main/res/layout/component_radio_button.xml index 10e0d9f6cbf4..0fb561f1b6dc 100644 --- a/common/common-ui/src/main/res/layout/component_radio_button.xml +++ b/common/common-ui/src/main/res/layout/component_radio_button.xml @@ -1,5 +1,4 @@ - - + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:clipToPadding="false" + android:paddingTop="@dimen/keyline_5" + android:paddingEnd="@dimen/keyline_4" + android:paddingBottom="@dimen/keyline_5"> + app:layout_constraintTop_toTopOf="parent" + app:primaryText="Radio Button" /> + android:id="@+id/radio_group" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/keyline_4" + android:layout_marginTop="@dimen/keyline_4" + android:orientation="horizontal" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/label"> + android:id="@+id/radio_button_one" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:checked="true" /> + android:id="@+id/radio_button_two" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> diff --git a/common/common-ui/src/main/res/layout/component_switch.xml b/common/common-ui/src/main/res/layout/component_switch.xml index c107877031b5..0c17ba8c5f0c 100644 --- a/common/common-ui/src/main/res/layout/component_switch.xml +++ b/common/common-ui/src/main/res/layout/component_switch.xml @@ -22,7 +22,6 @@ android:clipToPadding="false" android:paddingTop="@dimen/keyline_5" android:paddingBottom="@dimen/keyline_5" - android:paddingStart="@dimen/keyline_4" android:paddingEnd="@dimen/keyline_4"> @color/black84 ?attr/daxColorSurface ?attr/daxColorSurface + + + false diff --git a/common/common-ui/src/main/res/values/donottranslate.xml b/common/common-ui/src/main/res/values/donottranslate.xml index 3b51ca6a08bb..df88cc530d51 100644 --- a/common/common-ui/src/main/res/values/donottranslate.xml +++ b/common/common-ui/src/main/res/values/donottranslate.xml @@ -27,6 +27,9 @@ Messaging List items Others + Android Design System Demo + Android Design System + Headline diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt index 9160f06c8526..5dbb21893018 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriExtension.kt @@ -185,6 +185,8 @@ fun Uri.getEncodedQueryParameters(key: String?): List { fun String.extractDomain(): String? { return if (this.startsWith("http")) { this.toUri().domain() + } else if (this.startsWith("duck")) { + this.toUri().buildUpon().path("").toString() } else { "https://$this".extractDomain() } diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/extensions/ActivityExtensions.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/extensions/ActivityExtensions.kt index 532a81469c06..5443fe8b19be 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/extensions/ActivityExtensions.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/extensions/ActivityExtensions.kt @@ -82,6 +82,5 @@ fun Activity.showKeyboard(editText: EditText) { } fun Activity.hideKeyboard(editText: EditText) { - editText.requestFocus() WindowInsetsControllerCompat(window, editText).hide(WindowInsetsCompat.Type.ime()) } diff --git a/content-scope-scripts/content-scope-scripts-impl/build.gradle b/content-scope-scripts/content-scope-scripts-impl/build.gradle index 5680d5e57ec8..b18186e66829 100644 --- a/content-scope-scripts/content-scope-scripts-impl/build.gradle +++ b/content-scope-scripts/content-scope-scripts-impl/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation project(':browser-api') implementation project(':feature-toggles-api') implementation project(':js-messaging-api') + implementation project(':duckplayer-api') anvil project(':anvil-compiler') implementation project(':anvil-annotations') diff --git a/content-scope-scripts/content-scope-scripts-impl/lint-baseline.xml b/content-scope-scripts/content-scope-scripts-impl/lint-baseline.xml index b8fa0f662f46..0c2ae42c512e 100644 --- a/content-scope-scripts/content-scope-scripts-impl/lint-baseline.xml +++ b/content-scope-scripts/content-scope-scripts-impl/lint-baseline.xml @@ -4,8 +4,8 @@ + errorLine1=" contentScopeScriptsFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" contentScopeScriptsFeature.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> = emptyList() + override val allowedDomains: List = listOf( + AppUrl.Url.HOST, + YOUTUBE_HOST, + YOUTUBE_MOBILE_HOST, + ) + override val featureName: String = "duckPlayer" override val methods: List = listOf( "getUserValues", diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt index ac7b539116bf..5f3b0373626a 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt @@ -218,13 +218,13 @@ class RealContentScopeScriptsTest { @Test fun whenContentScopeScriptsIsEnabledThenReturnTrue() { - contentScopeScriptsFeature.self().setEnabled(State(enable = true)) + contentScopeScriptsFeature.self().setRawStoredState(State(enable = true)) assertTrue(testee.isEnabled()) } @Test fun whenContentScopeScriptsIsDisabledThenReturnFalse() { - contentScopeScriptsFeature.self().setEnabled(State(enable = false)) + contentScopeScriptsFeature.self().setRawStoredState(State(enable = false)) assertFalse(testee.isEnabled()) } diff --git a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt index e0341f35986a..d9b0fcdd17f0 100644 --- a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt +++ b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt @@ -27,6 +27,9 @@ import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import kotlinx.coroutines.flow.Flow +const val YOUTUBE_HOST = "youtube.com" +const val YOUTUBE_MOBILE_HOST = "m.youtube.com" + /** * DuckPlayer interface provides a set of methods for interacting with the DuckPlayer. */ diff --git a/duckplayer/duckplayer-impl/lint-baseline.xml b/duckplayer/duckplayer-impl/lint-baseline.xml index 91166aef57df..c1f778a75e90 100644 --- a/duckplayer/duckplayer-impl/lint-baseline.xml +++ b/duckplayer/duckplayer-impl/lint-baseline.xml @@ -4,8 +4,8 @@ + errorLine1=" duckPlayerFeature.self().setRawStoredState(State(enabled))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" duckPlayerFeature.enableDuckPlayer().setRawStoredState(State(enabled))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> = emptyList() + override val allowedDomains: List = listOf( + runBlocking { duckPlayer.getYouTubeEmbedUrl() }, + ) override val featureName: String = "duckPlayerPage" override val methods: List = listOf( "initialSetup", diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt index 4a9642ce6a5f..49b52f1bb6ae 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt @@ -25,7 +25,7 @@ import android.webkit.WebView import androidx.core.net.toUri import androidx.fragment.app.FragmentManager import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.UrlScheme.Companion.duck import com.duckduckgo.common.utils.UrlScheme.Companion.https @@ -39,6 +39,8 @@ import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled +import com.duckduckgo.duckplayer.api.YOUTUBE_HOST +import com.duckduckgo.duckplayer.api.YOUTUBE_MOBILE_HOST import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_DAILY_UNIQUE_VIEW import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE @@ -57,8 +59,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -private const val YOUTUBE_HOST = "youtube.com" -private const val YOUTUBE_MOBILE_HOST = "m.youtube.com" private const val DUCK_PLAYER_VIDEO_ID_QUERY_PARAM = "videoID" private const val DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH = "openInYoutube" private const val DUCK_PLAYER_DOMAIN = "player" @@ -66,8 +66,19 @@ private const val DUCK_PLAYER_URL_BASE = "$duck://$DUCK_PLAYER_DOMAIN/" private const val DUCK_PLAYER_ASSETS_PATH = "duckplayer/" private const val DUCK_PLAYER_ASSETS_INDEX_PATH = "${DUCK_PLAYER_ASSETS_PATH}index.html" +interface DuckPlayerInternal : DuckPlayer { + /** + * Retrieves the YouTube embed URL. + * + * @return The YouTube embed URL. + */ + suspend fun getYouTubeEmbedUrl(): String +} + @SingleInstanceIn(AppScope::class) -@ContributesBinding(AppScope::class) + +@ContributesBinding(AppScope::class, boundType = DuckPlayer::class) +@ContributesBinding(AppScope::class, boundType = DuckPlayerInternal::class) class RealDuckPlayer @Inject constructor( private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, private val duckPlayerFeature: DuckPlayerFeature, @@ -75,7 +86,7 @@ class RealDuckPlayer @Inject constructor( private val duckPlayerLocalFilesPath: DuckPlayerLocalFilesPath, private val mimeTypeMap: MimeTypeMap, private val dispatchers: DispatcherProvider, -) : DuckPlayer { +) : DuckPlayerInternal { private var shouldForceYTNavigation = false private var shouldHideOverlay = false @@ -283,7 +294,7 @@ class RealDuckPlayer @Inject constructor( } else { val inputStream: InputStream = webView.context.assets.open(DUCK_PLAYER_ASSETS_INDEX_PATH) return WebResourceResponse("text/html", "UTF-8", inputStream).also { - pixel.fire(DUCK_PLAYER_DAILY_UNIQUE_VIEW, type = DAILY) + pixel.fire(DUCK_PLAYER_DAILY_UNIQUE_VIEW, type = Daily()) } } } @@ -330,6 +341,28 @@ class RealDuckPlayer @Inject constructor( return null } + private suspend fun doesYoutubeUrlComeFromDuckPlayer(url: Uri, request: WebResourceRequest? = null): Boolean { + val referer = request?.requestHeaders?.keys?.firstOrNull { it in duckPlayerFeatureRepository.getYouTubeReferrerHeaders() } + ?.let { url.getQueryParameter(it) } + val previousUrl = duckPlayerFeatureRepository.getYouTubeReferrerQueryParams() + .firstOrNull { url.getQueryParameter(it) != null } + ?.let { url.getQueryParameter(it) } + + val videoIdQueryParam = duckPlayerFeatureRepository.getVideoIDQueryParam() + val requestedVideoId = url.getQueryParameter(videoIdQueryParam) + + val isSimulated: suspend (String?) -> Boolean = { uri -> + uri?.let { isSimulatedYoutubeNoCookie(it.toUri()) } == true + } + + val isMatchingVideoId: (String?) -> Boolean = { uri -> + uri?.toUri()?.getQueryParameter(DUCK_PLAYER_VIDEO_ID_QUERY_PARAM) == requestedVideoId + } + + return isSimulated(referer) && isMatchingVideoId(referer) || + isSimulated(previousUrl) && isMatchingVideoId(previousUrl) + } + private suspend fun processDuckPlayerUri( url: Uri, webView: WebView, @@ -369,13 +402,18 @@ class RealDuckPlayer @Inject constructor( } } + override suspend fun getYouTubeEmbedUrl(): String { + return duckPlayerFeatureRepository.getYouTubeEmbedUrl() + } + override suspend fun willNavigateToDuckPlayer( destinationUrl: Uri, ): Boolean { return ( isFeatureEnabled && isYoutubeWatchUrl(destinationUrl) && - getUserPreferences().privatePlayerMode == Enabled + getUserPreferences().privatePlayerMode == Enabled && + !(shouldForceYTNavigation || doesYoutubeUrlComeFromDuckPlayer(destinationUrl)) ) } } diff --git a/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt b/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt index 7ff2c7ede122..debc59005a8e 100644 --- a/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt +++ b/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt @@ -25,8 +25,8 @@ import android.webkit.WebView import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.common.utils.UrlScheme.Companion.duck import com.duckduckgo.common.utils.UrlScheme.Companion.https import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED @@ -238,7 +238,7 @@ class RealDuckPlayerTest { testee.sendDuckPlayerPixel(pixelName, pixelData) - verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS, pixelData, emptyMap(), COUNT) + verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS, pixelData, emptyMap(), Count) } @Test @@ -247,7 +247,7 @@ class RealDuckPlayerTest { testee.sendDuckPlayerPixel(pixelName, emptyMap()) - verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS, emptyMap(), emptyMap(), COUNT) + verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS, emptyMap(), emptyMap(), Count) } @Test @@ -257,7 +257,7 @@ class RealDuckPlayerTest { testee.sendDuckPlayerPixel(pixelName, pixelData) - verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY, pixelData, emptyMap(), COUNT) + verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY, pixelData, emptyMap(), Count) } @Test @@ -266,7 +266,7 @@ class RealDuckPlayerTest { testee.sendDuckPlayerPixel(pixelName, emptyMap()) - verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY, emptyMap(), emptyMap(), COUNT) + verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY, emptyMap(), emptyMap(), Count) } @Test @@ -276,7 +276,7 @@ class RealDuckPlayerTest { testee.sendDuckPlayerPixel(pixelName, pixelData) - verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE, pixelData, emptyMap(), COUNT) + verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE, pixelData, emptyMap(), Count) } @Test @@ -285,7 +285,7 @@ class RealDuckPlayerTest { testee.sendDuckPlayerPixel(pixelName, emptyMap()) - verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE, emptyMap(), emptyMap(), COUNT) + verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE, emptyMap(), emptyMap(), Count) } // endregion @@ -607,7 +607,7 @@ class RealDuckPlayerTest { val result = testee.intercept(request, url, webView) verify(assets).open("duckplayer/index.html") - verify(mockPixel).fire(DUCK_PLAYER_DAILY_UNIQUE_VIEW, type = DAILY) + verify(mockPixel).fire(DUCK_PLAYER_DAILY_UNIQUE_VIEW, type = Daily()) assertEquals("text/html", result?.mimeType) } @@ -740,7 +740,7 @@ class RealDuckPlayerTest { // endregion private fun setFeatureToggle(enabled: Boolean) { - duckPlayerFeature.self().setEnabled(State(enabled)) - duckPlayerFeature.enableDuckPlayer().setEnabled(State(enabled)) + duckPlayerFeature.self().setRawStoredState(State(enabled)) + duckPlayerFeature.enableDuckPlayer().setRawStoredState(State(enabled)) } } diff --git a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt index d8773e528427..131d9fe261dd 100644 --- a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt +++ b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt @@ -153,7 +153,7 @@ interface Toggle { * * @param state update the stored [State] of the feature flag */ - fun setEnabled(state: State) + fun setRawStoredState(state: State) /** * The usage of this API is only useful for internal/dev settings/features @@ -293,7 +293,7 @@ internal class ToggleImpl constructor( } @Suppress("NAME_SHADOWING") - override fun setEnabled(state: Toggle.State) { + override fun setRawStoredState(state: Toggle.State) { var state = state // remote is disabled, store and skip everything diff --git a/feature-toggles/feature-toggles-impl/lint-baseline.xml b/feature-toggles/feature-toggles-impl/lint-baseline.xml index c3f8a9e5ea5b..c8e3a5e22bcf 100644 --- a/feature-toggles/feature-toggles-impl/lint-baseline.xml +++ b/feature-toggles/feature-toggles-impl/lint-baseline.xml @@ -224,143 +224,143 @@ + errorLine1=" feature.disableByDefault().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.enabledByDefault().setRawStoredState(Toggle.State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.enabledByDefault().setRawStoredState(Toggle.State(enable = true, minSupportedVersion = 11))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.enabledByDefault().setRawStoredState(Toggle.State(enable = true, minSupportedVersion = 9))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.internal().setRawStoredState(enabledState)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.internal().setRawStoredState(enabledState.copy(remoteEnableState = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state.copy(rollout = emptyList()))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state.copy(rolloutThreshold = 20.0))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state.copy(rollout = listOf(1.0, 2.0)))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state.copy(rollout = listOf(0.5, 2.0), rolloutThreshold = 0.0))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state.copy(rollout = listOf(0.5, 100.0), rolloutThreshold = 10.0))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -371,117 +371,117 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -492,18 +492,18 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" feature.self().setRawStoredState(state)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -514,7 +514,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt index 6948afa649f6..86c523ead248 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt @@ -69,7 +69,7 @@ class FeatureTogglesTest { @Test fun whenDisableByDefaultAndSetEnabledThenReturnEnabled() { - feature.disableByDefault().setEnabled(Toggle.State(enable = true)) + feature.disableByDefault().setRawStoredState(Toggle.State(enable = true)) assertTrue(feature.disableByDefault().isEnabled()) } @@ -80,7 +80,7 @@ class FeatureTogglesTest { @Test fun whenEnabledByDefaultAndSetDisabledThenReturnDisabled() { - feature.enabledByDefault().setEnabled(Toggle.State(enable = false)) + feature.enabledByDefault().setRawStoredState(Toggle.State(enable = false)) assertFalse(feature.enabledByDefault().isEnabled()) } @@ -97,14 +97,14 @@ class FeatureTogglesTest { @Test fun whenNotAllowedMinVersionThenReturnDisabled() { provider.version = 10 - feature.enabledByDefault().setEnabled(Toggle.State(enable = true, minSupportedVersion = 11)) + feature.enabledByDefault().setRawStoredState(Toggle.State(enable = true, minSupportedVersion = 11)) assertFalse(feature.enabledByDefault().isEnabled()) } @Test fun whenAllowedMinVersionThenReturnDisabled() { provider.version = 10 - feature.enabledByDefault().setEnabled(Toggle.State(enable = true, minSupportedVersion = 9)) + feature.enabledByDefault().setRawStoredState(Toggle.State(enable = true, minSupportedVersion = 9)) assertTrue(feature.enabledByDefault().isEnabled()) } @@ -126,7 +126,7 @@ class FeatureTogglesTest { remoteEnableState = true, rollout = null, ) - feature.internal().setEnabled(enabledState) + feature.internal().setRawStoredState(enabledState) provider.flavorName = BuildFlavor.PLAY.name assertTrue(feature.internal().isEnabled()) provider.flavorName = BuildFlavor.FDROID.name @@ -134,7 +134,7 @@ class FeatureTogglesTest { provider.flavorName = BuildFlavor.INTERNAL.name assertTrue(feature.internal().isEnabled()) - feature.internal().setEnabled(enabledState.copy(remoteEnableState = false)) + feature.internal().setRawStoredState(enabledState.copy(remoteEnableState = false)) provider.flavorName = BuildFlavor.PLAY.name assertFalse(feature.internal().isEnabled()) provider.flavorName = BuildFlavor.FDROID.name @@ -214,22 +214,22 @@ class FeatureTogglesTest { rollout = null, rolloutThreshold = null, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertTrue(feature.self().isEnabled()) - feature.self().setEnabled(state.copy(rollout = emptyList())) + feature.self().setRawStoredState(state.copy(rollout = emptyList())) assertTrue(feature.self().isEnabled()) - feature.self().setEnabled(state.copy(rolloutThreshold = 20.0)) + feature.self().setRawStoredState(state.copy(rolloutThreshold = 20.0)) assertTrue(feature.self().isEnabled()) - feature.self().setEnabled(state.copy(rollout = listOf(1.0, 2.0))) + feature.self().setRawStoredState(state.copy(rollout = listOf(1.0, 2.0))) assertEquals(feature.self().rolloutThreshold() < 2.0, feature.self().isEnabled()) - feature.self().setEnabled(state.copy(rollout = listOf(0.5, 2.0), rolloutThreshold = 0.0)) + feature.self().setRawStoredState(state.copy(rollout = listOf(0.5, 2.0), rolloutThreshold = 0.0)) assertTrue(feature.self().isEnabled()) - feature.self().setEnabled(state.copy(rollout = listOf(0.5, 100.0), rolloutThreshold = 10.0)) + feature.self().setRawStoredState(state.copy(rollout = listOf(0.5, 100.0), rolloutThreshold = 10.0)) assertTrue(feature.self().isEnabled()) } @@ -240,7 +240,7 @@ class FeatureTogglesTest { rollout = listOf(100.0), rolloutThreshold = null, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertTrue(feature.self().isEnabled()) val expected = state.copy(remoteEnableState = state.enable, rolloutThreshold = feature.self().getRawStoredState()?.rolloutThreshold) assertEquals(expected, toggleStore.get("test")) @@ -253,7 +253,7 @@ class FeatureTogglesTest { rollout = listOf(100.0), rolloutThreshold = null, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertTrue(feature.self().isEnabled()) val updatedState = toggleStore.get("test") @@ -267,7 +267,7 @@ class FeatureTogglesTest { rollout = listOf(1.0, 10.0, 20.0, 40.0, 100.0), rolloutThreshold = null, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertTrue(feature.self().isEnabled()) val updatedState = toggleStore.get("test") @@ -281,7 +281,7 @@ class FeatureTogglesTest { rollout = listOf(0.0), rolloutThreshold = null, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) val threshold = feature.self().rolloutThreshold() var loop = 1.0 do { @@ -292,7 +292,7 @@ class FeatureTogglesTest { rolloutThreshold = feature.self().rolloutThreshold(), rollout = state.rollout!!.toMutableList().apply { add(loop.coerceAtMost(100.0)) }, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) } while (feature.self().rolloutThreshold() > state.rollout()) val updatedState = toggleStore.get("test") @@ -306,7 +306,7 @@ class FeatureTogglesTest { rollout = listOf(1.0, 10.0, 20.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0), rolloutThreshold = 2.0, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertTrue(feature.self().isEnabled()) val updatedState = toggleStore.get("test") @@ -322,7 +322,7 @@ class FeatureTogglesTest { rolloutThreshold = 2.0, minSupportedVersion = 11, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) // the feature flag is internally enabled but isEnabled() returns disabled because it doesn't meet minSupportedVersion assertFalse(feature.self().isEnabled()) @@ -336,7 +336,7 @@ class FeatureTogglesTest { @Test fun whenRemoteEnableStateIsNullThenHonourLocalEnableStateAndUpdate() { val state = Toggle.State(enable = false) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertFalse(feature.self().isEnabled()) val updatedState = toggleStore.get("test")!! @@ -352,7 +352,7 @@ class FeatureTogglesTest { remoteEnableState = false, enable = true, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertFalse(feature.self().isEnabled()) assertEquals(state, toggleStore.get("test")) } @@ -365,7 +365,7 @@ class FeatureTogglesTest { rollout = listOf(100.0), rolloutThreshold = null, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertFalse(feature.self().isEnabled()) assertEquals(state, toggleStore.get("test")) } @@ -378,7 +378,7 @@ class FeatureTogglesTest { rollout = listOf(100.0), rolloutThreshold = null, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertTrue(feature.self().isEnabled()) val expected = state.copy(rolloutThreshold = feature.self().getRawStoredState()?.rolloutThreshold) assertEquals(expected, toggleStore.get("test")) @@ -392,7 +392,7 @@ class FeatureTogglesTest { rollout = listOf(100.0), rolloutThreshold = null, ) - feature.self().setEnabled(state) + feature.self().setRawStoredState(state) assertTrue(feature.self().isEnabled()) val expected = state.copy(enable = true, rollout = listOf(100.0), rolloutThreshold = toggleStore.get("test")?.rolloutThreshold) assertEquals(expected, toggleStore.get("test")) @@ -406,11 +406,11 @@ class FeatureTogglesTest { enable = true, ) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_disableByDefault", state) assertTrue(feature.disableByDefault().isEnabled()) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_enabledByDefault", state.copy(enable = false)) assertFalse(feature.enabledByDefault().isEnabled()) } @@ -423,11 +423,11 @@ class FeatureTogglesTest { targets = listOf(Toggle.State.Target("ma")), ) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_experimentDisabledByDefault", state) assertFalse(feature.experimentDisabledByDefault().isEnabled()) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_experimentEnabledByDefault", state.copy(enable = false)) assertFalse(feature.experimentEnabledByDefault().isEnabled()) } @@ -441,11 +441,11 @@ class FeatureTogglesTest { targets = listOf(Toggle.State.Target(provider.variantKey!!)), ) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_experimentDisabledByDefault", state) assertTrue(feature.experimentDisabledByDefault().isEnabled()) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_experimentEnabledByDefault", state.copy(enable = false)) assertFalse(feature.experimentEnabledByDefault().isEnabled()) } @@ -459,19 +459,19 @@ class FeatureTogglesTest { targets = listOf(Toggle.State.Target("zz")), ) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_disableByDefault", state) assertTrue(feature.disableByDefault().isEnabled()) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_experimentDisabledByDefault", state) assertFalse(feature.experimentDisabledByDefault().isEnabled()) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_enabledByDefault", state.copy(enable = false)) assertFalse(feature.enabledByDefault().isEnabled()) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_experimentEnabledByDefault", state.copy(enable = false)) assertFalse(feature.experimentEnabledByDefault().isEnabled()) } @@ -488,11 +488,11 @@ class FeatureTogglesTest { ), ) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_experimentDisabledByDefault", state) assertFalse(feature.experimentDisabledByDefault().isEnabled()) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_experimentEnabledByDefault", state.copy(enable = false)) assertFalse(feature.experimentEnabledByDefault().isEnabled()) } @@ -509,11 +509,11 @@ class FeatureTogglesTest { ), ) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_disableByDefault", state) assertTrue(feature.disableByDefault().isEnabled()) - // Use directly the store because setEnabled() populates the local state when the remote state is null + // Use directly the store because setRawStoredState() populates the local state when the remote state is null toggleStore.set("test_enabledByDefault", state.copy(enable = false)) assertFalse(feature.enabledByDefault().isEnabled()) } diff --git a/feature-toggles/feature-toggles-internal/lint-baseline.xml b/feature-toggles/feature-toggles-internal/lint-baseline.xml index 9bb39a3a2854..eb15241a57ca 100644 --- a/feature-toggles/feature-toggles-internal/lint-baseline.xml +++ b/feature-toggles/feature-toggles-internal/lint-baseline.xml @@ -15,8 +15,8 @@ + errorLine1=" feature.setRawStoredState(state.copy(remoteEnableState = isChecked))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" } ?: feature.setRawStoredState(State(remoteEnableState = isChecked))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/feature-toggles/feature-toggles-internal/src/main/java/com/duckduckgo/examplefeature/internal/ui/FeatureToggleInventoryActivity.kt b/feature-toggles/feature-toggles-internal/src/main/java/com/duckduckgo/examplefeature/internal/ui/FeatureToggleInventoryActivity.kt index 59ad79c623f7..b8ef44a1289e 100644 --- a/feature-toggles/feature-toggles-internal/src/main/java/com/duckduckgo/examplefeature/internal/ui/FeatureToggleInventoryActivity.kt +++ b/feature-toggles/feature-toggles-internal/src/main/java/com/duckduckgo/examplefeature/internal/ui/FeatureToggleInventoryActivity.kt @@ -152,8 +152,8 @@ class FeatureToggleInventoryActivity : DuckDuckGoActivity() { feature.getRawStoredState()?.let { state -> // we change the 'remoteEnableState' instead of the 'enable' state because the latter is // a computed state - feature.setEnabled(state.copy(remoteEnableState = isChecked)) - } ?: feature.setEnabled(State(remoteEnableState = isChecked)) + feature.setRawStoredState(state.copy(remoteEnableState = isChecked)) + } ?: feature.setRawStoredState(State(remoteEnableState = isChecked)) // Validate the toggle state. For instance, we won't be able to disable toggles forced-enabled // for internal builds diff --git a/feature-toggles/feature-toggles-test/build.gradle b/feature-toggles/feature-toggles-test/build.gradle index a3af42e7d3fa..489b1c3167bf 100644 --- a/feature-toggles/feature-toggles-test/build.gradle +++ b/feature-toggles/feature-toggles-test/build.gradle @@ -25,3 +25,14 @@ dependencies { implementation project(':feature-toggles-api') implementation KotlinX.coroutines.core } + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + + diff --git a/installation/installation-impl/build.gradle b/installation/installation-impl/build.gradle index 18f2c2a3e046..533afed819ef 100644 --- a/installation/installation-impl/build.gradle +++ b/installation/installation-impl/build.gradle @@ -36,12 +36,14 @@ dependencies { implementation JakeWharton.timber implementation AndroidX.lifecycle.runtime.ktx implementation AndroidX.lifecycle.commonJava8 + implementation "androidx.datastore:datastore-preferences:_" testImplementation Testing.junit4 testImplementation AndroidX.test.ext.junit testImplementation "org.mockito.kotlin:mockito-kotlin:_" testImplementation Testing.robolectric testImplementation project(path: ':common-test') + testImplementation project(':feature-toggles-test') coreLibraryDesugaring Android.tools.desugarJdkLibs } diff --git a/installation/installation-impl/lint-baseline.xml b/installation/installation-impl/lint-baseline.xml index ca341e324ac8..68008f8a1c38 100644 --- a/installation/installation-impl/lint-baseline.xml +++ b/installation/installation-impl/lint-baseline.xml @@ -23,15 +23,4 @@ column="18"/> - - - - diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourceLifecycleObserver.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourcePrivacyConfigObserver.kt similarity index 54% rename from installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourceLifecycleObserver.kt rename to installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourcePrivacyConfigObserver.kt index 7f5a4f893131..2e35c732ffb7 100644 --- a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourceLifecycleObserver.kt +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourcePrivacyConfigObserver.kt @@ -16,17 +16,22 @@ package com.duckduckgo.installation.impl.installer +import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import androidx.annotation.VisibleForTesting import androidx.core.content.edit -import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin +import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.installation.impl.installer.InstallationPixelName.APP_INSTALLER_FULL_PACKAGE_NAME import com.duckduckgo.installation.impl.installer.InstallationPixelName.APP_INSTALLER_PACKAGE_NAME +import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore +import com.duckduckgo.installation.impl.installer.fullpackage.feature.InstallSourceFullPackageFeature +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import javax.inject.Inject @@ -34,34 +39,34 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber +@SuppressLint("DenyListedApi") @ContributesMultibinding( scope = AppScope::class, - boundType = MainProcessLifecycleObserver::class, + boundType = PrivacyConfigCallbackPlugin::class, ) @SingleInstanceIn(AppScope::class) -class InstallSourceLifecycleObserver @Inject constructor( +class InstallSourcePrivacyConfigObserver @Inject constructor( private val installSourceExtractor: InstallSourceExtractor, private val context: Context, private val pixel: Pixel, private val dispatchers: DispatcherProvider, + private val installSourceFullPackageFeature: InstallSourceFullPackageFeature, + private val store: InstallSourceFullPackageStore, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, -) : MainProcessLifecycleObserver { +) : PrivacyConfigCallbackPlugin { private val sharedPreferences: SharedPreferences by lazy { context.getSharedPreferences(SHARED_PREFERENCES_FILENAME, Context.MODE_PRIVATE) } - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) - + override fun onPrivacyConfigDownloaded() { appCoroutineScope.launch(dispatchers.io()) { if (!hasAlreadyProcessed()) { val installationSource = installSourceExtractor.extract() Timber.i("Installation source extracted: $installationSource") - val isFromPlayStoreParam = if (installationSource == PLAY_STORE_PACKAGE_NAME) "1" else "0" - val params = mapOf(PIXEL_PARAMETER_INSTALLED_THROUGH_PLAY_STORE to isFromPlayStoreParam) - pixel.fire(APP_INSTALLER_PACKAGE_NAME, params) + sendPixelIndicatingIfPlayStoreInstall(installationSource) + conditionallySendFullInstallerPackage(installationSource) recordInstallSourceProcessed() } else { @@ -70,6 +75,28 @@ class InstallSourceLifecycleObserver @Inject constructor( } } + private fun sendPixelIndicatingIfPlayStoreInstall(installationSource: String?) { + val isFromPlayStoreParam = if (installationSource == PLAY_STORE_PACKAGE_NAME) "1" else "0" + val params = mapOf(PIXEL_PARAMETER_INSTALLED_THROUGH_PLAY_STORE to isFromPlayStoreParam) + pixel.fire(APP_INSTALLER_PACKAGE_NAME, params) + } + + private suspend fun conditionallySendFullInstallerPackage(installationSource: String?) { + if (installationSource.shouldSendFullInstallerPackage()) { + val params = mapOf(PIXEL_PARAMETER_FULL_INSTALLER_SOURCE to installationSource.toString()) + pixel.fire(APP_INSTALLER_FULL_PACKAGE_NAME, params) + } + } + + private suspend fun String?.shouldSendFullInstallerPackage(): Boolean { + if (!installSourceFullPackageFeature.self().isEnabled()) { + return false + } + + val packages = store.getInstallSourceFullPackages() + return packages.hasWildcard() || packages.list.contains(this) + } + @VisibleForTesting fun recordInstallSourceProcessed() { sharedPreferences.edit { @@ -86,9 +113,23 @@ class InstallSourceLifecycleObserver @Inject constructor( private const val SHARED_PREFERENCES_FILENAME = "com.duckduckgo.app.installer.InstallSource" private const val SHARED_PREFERENCES_PROCESSED_KEY = "processed" private const val PIXEL_PARAMETER_INSTALLED_THROUGH_PLAY_STORE = "installedThroughPlayStore" + private const val PIXEL_PARAMETER_FULL_INSTALLER_SOURCE = "package" } } enum class InstallationPixelName(override val pixelName: String) : Pixel.PixelName { APP_INSTALLER_PACKAGE_NAME("m_installation_source"), + APP_INSTALLER_FULL_PACKAGE_NAME("m_installation_installer"), +} + +@ContributesMultibinding( + scope = AppScope::class, + boundType = PixelParamRemovalPlugin::class, +) +object InstallerPixelsRequiringDataCleaning : PixelParamRemovalPlugin { + override fun names(): List>> { + return listOf( + APP_INSTALLER_FULL_PACKAGE_NAME.pixelName to PixelParameter.removeAtb(), + ) + } } diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/di/InstallerModule.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/di/InstallerModule.kt new file mode 100644 index 000000000000..87962321ee9a --- /dev/null +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/di/InstallerModule.kt @@ -0,0 +1,47 @@ +/* + * 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.installation.impl.installer.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn +import javax.inject.Qualifier + +@Module +@ContributesTo(AppScope::class) +object InstallerModule { + + private val Context.dataStore: DataStore by preferencesDataStore( + name = "com.duckduckgo.installation.impl.installer", + ) + + @Provides + @SingleInstanceIn(AppScope::class) + @InstallSourceFullPackageDataStore + fun provideInstallSourceFullPackageDataStore(context: Context): DataStore { + return context.dataStore + } + + @Qualifier + annotation class InstallSourceFullPackageDataStore +} diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/InstallSourceFullPackageStore.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/InstallSourceFullPackageStore.kt new file mode 100644 index 000000000000..7e17bd6f46b6 --- /dev/null +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/InstallSourceFullPackageStore.kt @@ -0,0 +1,74 @@ +/* + * 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.installation.impl.installer.fullpackage + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.installation.impl.installer.di.InstallerModule.InstallSourceFullPackageDataStore +import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore.IncludedPackages +import com.duckduckgo.installation.impl.installer.fullpackage.feature.InstallSourceFullPackageListJsonParser +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +interface InstallSourceFullPackageStore { + suspend fun updateInstallSourceFullPackages(json: String) + suspend fun getInstallSourceFullPackages(): IncludedPackages + + data class IncludedPackages(val list: List = emptyList()) { + + fun hasWildcard(): Boolean { + return list.contains("*") + } + } +} + +@ContributesBinding(AppScope::class, boundType = InstallSourceFullPackageStore::class) +@SingleInstanceIn(AppScope::class) +class InstallSourceFullPackageStoreImpl @Inject constructor( + private val dispatchers: DispatcherProvider, + private val jsonParser: InstallSourceFullPackageListJsonParser, + @InstallSourceFullPackageDataStore private val dataStore: DataStore, +) : InstallSourceFullPackageStore { + + override suspend fun updateInstallSourceFullPackages(json: String) { + withContext(dispatchers.io()) { + val includedPackages = jsonParser.parseJson(json) + dataStore.edit { + it[packageInstallersKey] = includedPackages.list.toSet() + } + } + } + + override suspend fun getInstallSourceFullPackages(): IncludedPackages { + return withContext(dispatchers.io()) { + val packageInstallers = dataStore.data.map { it[packageInstallersKey] }.firstOrNull() + return@withContext IncludedPackages(packageInstallers?.toList() ?: emptyList()) + } + } + + companion object { + val packageInstallersKey = stringSetPreferencesKey("package_installers") + } +} diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageFeature.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageFeature.kt new file mode 100644 index 000000000000..20ded5853e28 --- /dev/null +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageFeature.kt @@ -0,0 +1,69 @@ +/* + * 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.installation.impl.installer.fullpackage.feature + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.FeatureSettings +import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesRemoteFeature( + scope = AppScope::class, + boundType = InstallSourceFullPackageFeature::class, + featureName = "sendFullPackageInstallSource", + settingsStore = InstallSourceFullPackageFeatureSettingsStore::class, +) +/** + * This is the class that represents the feature flag for sending full installer package ID. + * This can be used to specify which app-installer package IDs we'd match on to send a pixel. + * A wildcard "*" can be used to match all package IDs. + */ +interface InstallSourceFullPackageFeature { + /** + * @return `true` when the remote config has the global "sendFullPackageInstallSource" feature flag enabled + * + * If the remote feature is not present defaults to `false` + */ + + @Toggle.DefaultValue(false) + fun self(): Toggle +} + +@ContributesBinding(AppScope::class) +@RemoteFeatureStoreNamed(InstallSourceFullPackageFeature::class) +class InstallSourceFullPackageFeatureSettingsStore @Inject constructor( + private val dataStore: InstallSourceFullPackageStore, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : FeatureSettings.Store { + + override fun store(jsonString: String) { + kotlin.runCatching { + appCoroutineScope.launch(dispatchers.io()) { + dataStore.updateInstallSourceFullPackages(jsonString) + } + } + } +} diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageListJsonParser.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageListJsonParser.kt new file mode 100644 index 000000000000..023c6faf09a1 --- /dev/null +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageListJsonParser.kt @@ -0,0 +1,57 @@ +/* + * 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.installation.impl.installer.fullpackage.feature + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore.IncludedPackages +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import javax.inject.Inject + +interface InstallSourceFullPackageListJsonParser { + suspend fun parseJson(json: String?): IncludedPackages +} + +@ContributesBinding(AppScope::class) +class InstallSourceFullPackageListJsonParserImpl @Inject constructor() : InstallSourceFullPackageListJsonParser { + + private val jsonAdapter by lazy { buildJsonAdapter() } + + private fun buildJsonAdapter(): JsonAdapter { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + return moshi.adapter(SettingsJson::class.java) + } + + override suspend fun parseJson(json: String?): IncludedPackages { + if (json == null) return IncludedPackages() + + return kotlin.runCatching { + val parsed = jsonAdapter.fromJson(json) + return parsed?.asIncludedPackages() ?: IncludedPackages() + }.getOrDefault(IncludedPackages()) + } + + private fun SettingsJson.asIncludedPackages(): IncludedPackages { + return IncludedPackages(includedPackages.map { it }) + } + + private data class SettingsJson( + val includedPackages: List, + ) +} diff --git a/installation/installation-impl/src/test/java/InstallSourceLifecycleObserverTest.kt b/installation/installation-impl/src/test/java/InstallSourceLifecycleObserverTest.kt deleted file mode 100644 index 01dd92185057..000000000000 --- a/installation/installation-impl/src/test/java/InstallSourceLifecycleObserverTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2023 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.installation.impl.installer - -import androidx.lifecycle.LifecycleOwner -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT -import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.installation.impl.installer.InstallationPixelName.APP_INSTALLER_PACKAGE_NAME -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.robolectric.RuntimeEnvironment - -@RunWith(AndroidJUnit4::class) -class InstallSourceLifecycleObserverTest { - - @get:Rule - val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - - private val mockLifecycleOwner = mock() - private val mockPixel = mock() - private val context = RuntimeEnvironment.getApplication() - private val mockInstallSourceExtractor = mock() - - private val testee = InstallSourceLifecycleObserver( - context = context, - pixel = mockPixel, - dispatchers = coroutineTestRule.testDispatcherProvider, - appCoroutineScope = coroutineTestRule.testScope, - installSourceExtractor = mockInstallSourceExtractor, - ) - - @Test - fun whenNotPreviouslyProcessedThenPixelSent() = runTest { - testee.onCreate(mockLifecycleOwner) - verify(mockPixel).fire(eq(APP_INSTALLER_PACKAGE_NAME), any(), any(), eq(COUNT)) - } - - @Test - fun whenPreviouslyProcessedThenPixelNotSent() = runTest { - testee.recordInstallSourceProcessed() - testee.onCreate(mockLifecycleOwner) - verify(mockPixel, never()).fire(eq(APP_INSTALLER_PACKAGE_NAME), any(), any(), eq(COUNT)) - } -} diff --git a/installation/installation-impl/src/test/java/InstallSourcePrivacyConfigObserverTest.kt b/installation/installation-impl/src/test/java/InstallSourcePrivacyConfigObserverTest.kt new file mode 100644 index 000000000000..d7a0f3046b29 --- /dev/null +++ b/installation/installation-impl/src/test/java/InstallSourcePrivacyConfigObserverTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.installation.impl.installer + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.installation.impl.installer.InstallationPixelName.APP_INSTALLER_FULL_PACKAGE_NAME +import com.duckduckgo.installation.impl.installer.InstallationPixelName.APP_INSTALLER_PACKAGE_NAME +import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore +import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore.IncludedPackages +import com.duckduckgo.installation.impl.installer.fullpackage.feature.InstallSourceFullPackageFeature +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RuntimeEnvironment + +@RunWith(AndroidJUnit4::class) +class InstallSourcePrivacyConfigObserverTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val mockPixel = mock() + private val context = RuntimeEnvironment.getApplication() + private val mockInstallSourceExtractor = mock() + private val mockFullPackageFeatureStore: InstallSourceFullPackageStore = mock() + private val fakeFeature = FakeFeatureToggleFactory.create(InstallSourceFullPackageFeature::class.java) + + private val testee = InstallSourcePrivacyConfigObserver( + context = context, + pixel = mockPixel, + dispatchers = coroutineTestRule.testDispatcherProvider, + appCoroutineScope = coroutineTestRule.testScope, + installSourceExtractor = mockInstallSourceExtractor, + store = mockFullPackageFeatureStore, + installSourceFullPackageFeature = fakeFeature, + ) + + @Before + @SuppressLint("DenyListedApi") + fun setup() { + fakeFeature.self().setRawStoredState(State(enable = true)) + whenever(mockInstallSourceExtractor.extract()).thenReturn("app.installer.package") + } + + @Test + fun whenNotPreviouslyProcessedThenPixelSent() = runTest { + testee.onPrivacyConfigDownloaded() + verify(mockPixel).fire(eq(APP_INSTALLER_PACKAGE_NAME), any(), any(), eq(Count)) + } + + @Test + fun whenPreviouslyProcessedThenPixelNotSent() = runTest { + testee.recordInstallSourceProcessed() + testee.onPrivacyConfigDownloaded() + verify(mockPixel, never()).fire(eq(APP_INSTALLER_PACKAGE_NAME), any(), any(), eq(Count)) + } + + @Test + fun whenInstallerPackageIsInIncludedListThenFiresInstallerPackagePixel() = runTest { + configurePackageIsMatching() + testee.onPrivacyConfigDownloaded() + verify(mockPixel).fire(eq(APP_INSTALLER_FULL_PACKAGE_NAME), any(), any(), eq(Count)) + } + + @Test + fun whenInstallerPackageIsNotInIncludedListDoesNotFirePixel() = runTest { + configurePackageNotMatching() + testee.onPrivacyConfigDownloaded() + verify(mockPixel, never()).fire(eq(APP_INSTALLER_FULL_PACKAGE_NAME), any(), any(), eq(Count)) + } + + @Test + fun whenInstallerPackageIsNotInIncludedListButListContainsWildcardThenDoesFirePixel() = runTest { + configureListHasWildcard() + testee.onPrivacyConfigDownloaded() + verify(mockPixel).fire(eq(APP_INSTALLER_FULL_PACKAGE_NAME), any(), any(), eq(Count)) + } + + private suspend fun configurePackageIsMatching() { + whenever(mockFullPackageFeatureStore.getInstallSourceFullPackages()).thenReturn(IncludedPackages(listOf("app.installer.package"))) + } + + private suspend fun configureListHasWildcard() { + whenever(mockFullPackageFeatureStore.getInstallSourceFullPackages()).thenReturn(IncludedPackages(listOf("*"))) + } + + private suspend fun configurePackageNotMatching() { + whenever(mockFullPackageFeatureStore.getInstallSourceFullPackages()).thenReturn( + IncludedPackages( + listOf( + "this.will.not.match", + "nor.will.this", + ), + ), + ) + } +} diff --git a/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/IncludedPackagesTest.kt b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/IncludedPackagesTest.kt new file mode 100644 index 000000000000..462fc9b2e01f --- /dev/null +++ b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/IncludedPackagesTest.kt @@ -0,0 +1,44 @@ +package com.duckduckgo.installation.impl.installer.fullpackage + +import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore.IncludedPackages +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class IncludedPackagesTest { + + @Test + fun whenEmptyThenDoesNotContainWildcard() { + val list = IncludedPackages(emptyList()) + assertFalse(list.hasWildcard()) + } + + @Test + fun whenHasEntriesButNoWildcardThenDoesNotContainWildcard() { + val list = IncludedPackages( + listOf( + "not a wildcard", + "also not a wildcard", + ), + ) + assertFalse(list.hasWildcard()) + } + + @Test + fun whenHasMultipleEntriesAndOneIsWildcardEntryThenDoesContainWildcard() { + val list = IncludedPackages( + listOf( + "not a wildcard", + "*", + "also not a wildcard", + ), + ) + assertTrue(list.hasWildcard()) + } + + @Test + fun whenHasSingleWildcardEntryThenDoesContainWildcard() { + val list = IncludedPackages(listOf("*")) + assertTrue(list.hasWildcard()) + } +} diff --git a/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/InstallSourceFullPackageStoreImplTest.kt b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/InstallSourceFullPackageStoreImplTest.kt new file mode 100644 index 000000000000..131506e5e4c9 --- /dev/null +++ b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/InstallSourceFullPackageStoreImplTest.kt @@ -0,0 +1,102 @@ +package com.duckduckgo.installation.impl.installer.fullpackage + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore.IncludedPackages +import com.duckduckgo.installation.impl.installer.fullpackage.feature.InstallSourceFullPackageListJsonParser +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class InstallSourceFullPackageStoreImplTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val mockInstallSourceFullPackageListJsonParser: InstallSourceFullPackageListJsonParser = mock() + private val temporaryFolder = TemporaryFolder.builder().assureDeletion().build().also { it.create() } + + private val testDataStore: DataStore = + PreferenceDataStoreFactory.create( + scope = coroutineTestRule.testScope, + produceFile = { temporaryFolder.newFile("temp.preferences_pb") }, + ) + + private val testee = InstallSourceFullPackageStoreImpl( + dispatchers = coroutineTestRule.testDispatcherProvider, + jsonParser = mockInstallSourceFullPackageListJsonParser, + dataStore = testDataStore, + ) + + @Test + fun whenPackagesIsEmptyThenEmptySetReturned() = runTest { + configurePackages(emptySet()) + val result = testee.getInstallSourceFullPackages() + assertTrue(result.list.isEmpty()) + } + + @Test + fun whenPackageHasSingleEntryThenCorrectSetReturned() = runTest { + configurePackages(setOf("a.b.c")) + val result = testee.getInstallSourceFullPackages() + assertEquals(1, result.list.size) + } + + @Test + fun whenPackageHasMultipleUniqueEntriesThenCorrectSetReturned() = runTest { + configurePackages(setOf("some value", "*", "not a wildcard")) + val result = testee.getInstallSourceFullPackages() + assertEquals(3, result.list.size) + } + + @Test + fun whenPackageHasMultipleEntriesWithDuplicatesThenCorrectSetReturned() = runTest { + configurePackages(setOf("this is a duplicate", "this is a duplicate", "unique")) + val result = testee.getInstallSourceFullPackages() + assertEquals(2, result.list.size) + } + + @Test + fun whenUpdateFirstCalledThenListIsPersisted() = runTest { + val someJson = "{}" + whenever(mockInstallSourceFullPackageListJsonParser.parseJson(someJson)).thenReturn(IncludedPackages(listOf("a.b.c"))) + testee.updateInstallSourceFullPackages(someJson) + assertTrue(testee.getInstallSourceFullPackages().list.contains("a.b.c")) + } + + @Test + fun whenUpdateCalledAgainWithEmptyListThenListIsPersisted() = runTest { + val someJson = "{}" + whenever(mockInstallSourceFullPackageListJsonParser.parseJson(someJson)).thenReturn(IncludedPackages(listOf("a.b.c"))) + testee.updateInstallSourceFullPackages(someJson) + + whenever(mockInstallSourceFullPackageListJsonParser.parseJson(someJson)).thenReturn(IncludedPackages(emptyList())) + testee.updateInstallSourceFullPackages(someJson) + + assertTrue(testee.getInstallSourceFullPackages().list.isEmpty()) + } + + @Test + fun whenUpdateCalledAgainWithDifferentListThenListIsPersisted() = runTest { + val someJson = "{}" + whenever(mockInstallSourceFullPackageListJsonParser.parseJson(someJson)).thenReturn(IncludedPackages(listOf("a.b.c"))) + testee.updateInstallSourceFullPackages(someJson) + + whenever(mockInstallSourceFullPackageListJsonParser.parseJson(someJson)).thenReturn(IncludedPackages(listOf("d.e.f"))) + testee.updateInstallSourceFullPackages(someJson) + + assertEquals("d.e.f", testee.getInstallSourceFullPackages().list[0]) + } + + private suspend fun configurePackages(set: Set) { + testDataStore.edit { it[InstallSourceFullPackageStoreImpl.packageInstallersKey] = set } + } +} diff --git a/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageListJsonParserImplTest.kt b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageListJsonParserImplTest.kt new file mode 100644 index 000000000000..1b4efe0c1de1 --- /dev/null +++ b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/fullpackage/feature/InstallSourceFullPackageListJsonParserImplTest.kt @@ -0,0 +1,52 @@ +package com.duckduckgo.installation.impl.installer.fullpackage.feature + +import com.duckduckgo.common.test.FileUtilities +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test + +class InstallSourceFullPackageListJsonParserImplTest { + + private val testee = InstallSourceFullPackageListJsonParserImpl() + + @Test + fun whenGibberishInputThenReturnsReturnsEmptyPackages() = runTest { + val result = testee.parseJson("invalid json") + assertTrue(result.list.isEmpty()) + } + + @Test + fun whenInstallerListIsMissingFieldThenReturnsEmptyPackages() = runTest { + val result = testee.parseJson("{}") + assertTrue(result.list.isEmpty()) + } + + @Test + fun whenInstallerListIsEmptyThenReturnsEmptyPackages() = runTest { + val result = testee.parseJson("installerFullSource_emptyList".loadJsonFile()) + assertTrue(result.list.isEmpty()) + } + + @Test + fun whenInstallerListHasSingleEntryThenReturnsSinglePackage() = runTest { + val result = testee.parseJson("installerFullSource_singleEntryList".loadJsonFile()) + assertEquals(1, result.list.size) + assertEquals("a.b.c", result.list[0]) + } + + @Test + fun whenInstallerListHasMultipleEntriesThenReturnsMultiplePackages() = runTest { + val result = testee.parseJson("installerFullSource_multipleEntryList".loadJsonFile()) + assertEquals(3, result.list.size) + assertEquals("a.b.c", result.list[0]) + assertEquals("d.e.f", result.list[1]) + assertEquals("g.h.i", result.list[2]) + } + + private fun String.loadJsonFile(): String { + return FileUtilities.loadText( + InstallSourceFullPackageListJsonParserImplTest::class.java.classLoader!!, + "json/$this.json", + ) + } +} diff --git a/installation/installation-impl/src/test/resources/json/installerFullSource_emptyList.json b/installation/installation-impl/src/test/resources/json/installerFullSource_emptyList.json new file mode 100644 index 000000000000..eef370c68771 --- /dev/null +++ b/installation/installation-impl/src/test/resources/json/installerFullSource_emptyList.json @@ -0,0 +1,3 @@ +{ + "includedPackages": [] +} \ No newline at end of file diff --git a/installation/installation-impl/src/test/resources/json/installerFullSource_multipleEntryList.json b/installation/installation-impl/src/test/resources/json/installerFullSource_multipleEntryList.json new file mode 100644 index 000000000000..6c547bc3bc97 --- /dev/null +++ b/installation/installation-impl/src/test/resources/json/installerFullSource_multipleEntryList.json @@ -0,0 +1,7 @@ +{ + "includedPackages": [ + "a.b.c", + "d.e.f", + "g.h.i" + ] +} \ No newline at end of file diff --git a/installation/installation-impl/src/test/resources/json/installerFullSource_singleEntryList.json b/installation/installation-impl/src/test/resources/json/installerFullSource_singleEntryList.json new file mode 100644 index 000000000000..8269ae64508a --- /dev/null +++ b/installation/installation-impl/src/test/resources/json/installerFullSource_singleEntryList.json @@ -0,0 +1,3 @@ +{ + "includedPackages": ["a.b.c"] +} \ No newline at end of file diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/DenyListedApiDetector.kt b/lint-rules/src/main/java/com/duckduckgo/lint/DenyListedApiDetector.kt index 2268235c8424..13361b01f9cb 100644 --- a/lint-rules/src/main/java/com/duckduckgo/lint/DenyListedApiDetector.kt +++ b/lint-rules/src/main/java/com/duckduckgo/lint/DenyListedApiDetector.kt @@ -42,7 +42,7 @@ internal class DenyListedApiDetector : Detector(), SourceCodeScanner, XmlScanner private val config = DenyListConfig( DenyListedEntry( className = "com.duckduckgo.feature.toggles.api.Toggle", - functionName = "setEnabled", + functionName = "setRawStoredState", errorMessage = "If you find yourself using this API in production, you're doing something wrong!!" ), DenyListedEntry( diff --git a/network-protection/network-protection-impl/lint-baseline.xml b/network-protection/network-protection-impl/lint-baseline.xml index 4ea7d11bf0c3..386f5f63dac9 100644 --- a/network-protection/network-protection-impl/lint-baseline.xml +++ b/network-protection/network-protection-impl/lint-baseline.xml @@ -70,8 +70,8 @@ + errorLine1=" netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" netPSettingsLocalConfig.vpnExcludeLocalNetworkRoutes().setRawStoredState(Toggle.State(enable = enabled))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(Toggle.State(enable = checked))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" netPSettingsLocalConfig.vpnExcludeLocalNetworkRoutes().setRawStoredState(Toggle.State(remoteEnableState = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(Toggle.State(remoteEnableState = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" localConfig.permanentRemoveExcludeAppPrompt().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - + errorLine1=" netPSettingsLocalConfig.vpnPauseDuringCalls().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" netPSettingsLocalConfig.vpnPauseDuringCalls().setRawStoredState(Toggle.State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" netPSettingsLocalConfig.blockMalware().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> netPSettingsLocalConfig.excludeSystemAppsNetworking() is Media -> netPSettingsLocalConfig.excludeSystemAppsMedia() is Others -> netPSettingsLocalConfig.excludeSystemAppsOthers() - }.setEnabled(State(true)) + }.setRawStoredState(State(true)) } override suspend fun includeCategory(category: SystemAppCategory) = withContext(dispatcherProvider.io()) { @@ -92,7 +92,7 @@ class RealSystemAppsExclusionRepository @Inject constructor( is Networking -> netPSettingsLocalConfig.excludeSystemAppsNetworking() is Media -> netPSettingsLocalConfig.excludeSystemAppsMedia() is Others -> netPSettingsLocalConfig.excludeSystemAppsOthers() - }.setEnabled(State(false)) + }.setRawStoredState(State(false)) } override suspend fun isCategoryExcluded(category: SystemAppCategory): Boolean = withContext(dispatcherProvider.io()) { diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/exclusion/ui/NetpAppExclusionListActivity.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/exclusion/ui/NetpAppExclusionListActivity.kt index 74c87aee82ad..d84a49997769 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/exclusion/ui/NetpAppExclusionListActivity.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/exclusion/ui/NetpAppExclusionListActivity.kt @@ -103,7 +103,7 @@ class NetpAppExclusionListActivity : override fun onPrepareOptionsMenu(menu: Menu): Boolean { val restoreDefault = menu.findItem(R.id.netp_exclusion_menu_restore) // onPrepareOptionsMenu is called when overflow menu is being displayed, that's why this can be an imperative call - restoreDefault?.isEnabled = viewModel.canRestoreDefaults() + restoreDefault?.setVisible(viewModel.canRestoreDefaults()) return super.onPrepareOptionsMenu(menu) } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModel.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModel.kt index 45f760b0930e..a86e5a9bc37d 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModel.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModel.kt @@ -410,7 +410,7 @@ class NetworkProtectionManagementViewModel @Inject constructor( fun onDontShowExcludeAppPromptAgain() { networkProtectionPixels.reportExcludePromptDontAskAgainClicked() - localConfig.permanentRemoveExcludeAppPrompt().setEnabled(State(enable = true)) + localConfig.permanentRemoveExcludeAppPrompt().setRawStoredState(State(enable = true)) } fun onExcludeAppSelected() { diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/NetPVpnSettingsViewModel.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/NetPVpnSettingsViewModel.kt index 634335f645f1..73c34345d6e8 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/NetPVpnSettingsViewModel.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/NetPVpnSettingsViewModel.kt @@ -128,7 +128,7 @@ class NetPVpnSettingsViewModel @Inject constructor( internal fun onExcludeLocalRoutes(enabled: Boolean) { viewModelScope.launch(dispatcherProvider.io()) { val oldValue = _viewState.value.excludeLocalNetworks - netPSettingsLocalConfig.vpnExcludeLocalNetworkRoutes().setEnabled(Toggle.State(enable = enabled)) + netPSettingsLocalConfig.vpnExcludeLocalNetworkRoutes().setRawStoredState(Toggle.State(enable = enabled)) _viewState.emit(_viewState.value.copy(excludeLocalNetworks = enabled)) shouldRestartVpn.set(enabled != oldValue) } @@ -146,7 +146,7 @@ class NetPVpnSettingsViewModel @Inject constructor( fun onVPNotificationsToggled(checked: Boolean) { logcat { "VPN alert notification settings set to $checked" } - netPSettingsLocalConfig.vpnNotificationAlerts().setEnabled(Toggle.State(enable = checked)) + netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(Toggle.State(enable = checked)) } data class RecommendedSettings(val isIgnoringBatteryOptimizations: Boolean) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsActivity.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsActivity.kt index efa400693834..12b4f733e30a 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsActivity.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsActivity.kt @@ -24,6 +24,8 @@ import android.widget.CompoundButton.OnCheckedChangeListener import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.appbuildconfig.api.isInternalBuild import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.view.gone import com.duckduckgo.common.ui.view.quietlySetIsChecked @@ -44,10 +46,11 @@ import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsAct import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.ForceApplyIfReset import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.Init import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.OnApply +import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.OnBlockMalwareDisabled +import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.OnBlockMalwareEnabled import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.State.CustomDns import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.State.DefaultDns import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.State.Done -import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.State.NeedApply import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsScreen.Default import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -61,6 +64,9 @@ import kotlinx.coroutines.launch @ContributeToActivityStarter(Default::class) class VpnCustomDnsActivity : DuckDuckGoActivity() { + @Inject + lateinit var appBuildConfig: AppBuildConfig + private val binding: ActivityNetpCustomDnsBinding by viewBinding() private val viewModel: VpnCustomDnsViewModel by bindViewModel() @@ -106,6 +112,16 @@ class VpnCustomDnsActivity : DuckDuckGoActivity() { } } + private val blockMalwareToggleListener = OnCheckedChangeListener { _, value -> + lifecycleScope.launch { + if (value) { + events.emit(OnBlockMalwareEnabled) + } else { + events.emit(OnBlockMalwareDisabled) + } + } + } + @Inject lateinit var dispatcherProvider: DispatcherProvider @@ -150,6 +166,14 @@ class VpnCustomDnsActivity : DuckDuckGoActivity() { binding.customDns.removeTextChangedListener(customDnsTextWatcher) binding.customDns.isEditable = false binding.customDnsSection.gone() + + // for now we only want to show this to internal users + if (appBuildConfig.isInternalBuild()) { + binding.blockMalwareSection.show() + binding.blockMalwareToggle.quietlySetIsChecked(state.blockMalware, blockMalwareToggleListener) + } else { + binding.blockMalwareSection.gone() + } } is CustomDns -> { @@ -158,17 +182,19 @@ class VpnCustomDnsActivity : DuckDuckGoActivity() { binding.customDnsOption.quietlySetIsChecked(true, customDnsListener) binding.customDnsSection.show() + binding.blockMalwareSection.gone() binding.customDns.removeTextChangedListener(customDnsTextWatcher) state.dns?.also { binding.customDns.text = it } binding.customDns.addTextChangedListener(customDnsTextWatcher) + binding.applyDnsChanges.isEnabled = state.applyEnabled } - - is NeedApply -> binding.applyDnsChanges.isEnabled = state.value - Done -> { + is Done -> { networkProtectionState.restart() - finish() + if (state.finish) { + finish() + } } } } @@ -184,6 +210,8 @@ class VpnCustomDnsActivity : DuckDuckGoActivity() { binding.customDns.enable() binding.customDns.isEditable = true binding.customDnsSectionHeader.enable() + + binding.blockMalwareToggle.enable() } else { binding.dnsWarning.show() binding.dnsWarning.setClickableLink( @@ -201,6 +229,8 @@ class VpnCustomDnsActivity : DuckDuckGoActivity() { binding.customDns.removeTextChangedListener(customDnsTextWatcher) binding.customDns.isEditable = false binding.customDnsSectionHeader.disable() + + binding.blockMalwareToggle.disable() } } @@ -228,13 +258,15 @@ class VpnCustomDnsActivity : DuckDuckGoActivity() { data object DefaultDnsSelected : Event() data object OnApply : Event() data object ForceApplyIfReset : Event() + data object OnBlockMalwareEnabled : Event() + data object OnBlockMalwareDisabled : Event() } internal sealed class State { - data class NeedApply(val value: Boolean) : State() - data class DefaultDns(val allowChange: Boolean) : State() - data class CustomDns(val dns: String?, val allowChange: Boolean) : State() - data object Done : State() + // data class NeedApply(val value: Boolean) : State() + data class DefaultDns(val allowChange: Boolean, val blockMalware: Boolean) : State() + data class CustomDns(val dns: String?, val allowChange: Boolean, val applyEnabled: Boolean) : State() + data class Done(val finish: Boolean = true) : State() } } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsSettingView.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsSettingView.kt index 128596e4b0ef..df4260e4cd0e 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsSettingView.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsSettingView.kt @@ -39,6 +39,7 @@ import com.duckduckgo.networkprotection.impl.settings.VpnSettingPlugin import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsSettingView.Event.Init import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsSettingView.State.CustomDns import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsSettingView.State.Default +import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsSettingView.State.DefaultBlockMalware import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsSettingView.State.Idle import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsViewSettingViewModel.Factory import com.squareup.anvil.annotations.ContributesMultibinding @@ -112,6 +113,7 @@ class VpnCustomDnsSettingView @JvmOverloads constructor( Idle -> {} is CustomDns -> binding.customDnsSetting.setSecondaryText(state.serverName) Default -> binding.customDnsSetting.setSecondaryText(context.getString(R.string.netpCustomDnsDefault)) + DefaultBlockMalware -> binding.customDnsSetting.setSecondaryText(context.getString(R.string.netpCustomDnsDefaultBlockMalware)) } } } @@ -123,6 +125,7 @@ class VpnCustomDnsSettingView @JvmOverloads constructor( sealed class State { data object Idle : State() data object Default : State() + data object DefaultBlockMalware : State() data class CustomDns(val serverName: String) : State() } } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsViewModel.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsViewModel.kt index d919a898780d..fce32dbae263 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsViewModel.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsViewModel.kt @@ -16,10 +16,15 @@ package com.duckduckgo.networkprotection.impl.settings.custom_dns +import android.annotation.SuppressLint import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixels +import com.duckduckgo.networkprotection.impl.settings.NetPSettingsLocalConfig import com.duckduckgo.networkprotection.impl.settings.NetpVpnSettingsDataStore import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.CustomDnsEntered @@ -28,12 +33,17 @@ import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsAct import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.ForceApplyIfReset import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.Init import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.OnApply +import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.OnBlockMalwareDisabled +import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.Event.OnBlockMalwareEnabled import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsActivity.State import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsViewModel.InitialState.CustomDns import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsViewModel.InitialState.DefaultDns import com.wireguard.config.InetAddresses import java.net.Inet4Address import javax.inject.Inject +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -41,10 +51,15 @@ import kotlinx.coroutines.flow.flow class VpnCustomDnsViewModel @Inject constructor( private val netpVpnSettingsDataStore: NetpVpnSettingsDataStore, private val networkProtectionPixels: NetworkProtectionPixels, + private val netPSettingsLocalConfig: NetPSettingsLocalConfig, + dispatcherProvider: DispatcherProvider, ) : ViewModel() { private lateinit var initialState: InitialState private var currentState: InitialState = DefaultDns + private val blockMalware: Deferred = viewModelScope.async(context = dispatcherProvider.io(), start = CoroutineStart.LAZY) { + netPSettingsLocalConfig.blockMalware().isEnabled() + } internal fun reduce(event: Event): Flow { return when (event) { @@ -54,14 +69,24 @@ class VpnCustomDnsViewModel @Inject constructor( is CustomDnsEntered -> handleCustomDnsEntered(event) OnApply -> handleOnApply() ForceApplyIfReset -> handleForceApply() + OnBlockMalwareDisabled -> handleBlockMalwareState(false) + OnBlockMalwareEnabled -> handleBlockMalwareState(true) } } + @SuppressLint("DenyListedApi") + private fun handleBlockMalwareState(isEnabled: Boolean) = flow { + netPSettingsLocalConfig.blockMalware().setRawStoredState(Toggle.State(enable = isEnabled)) + netpVpnSettingsDataStore.customDns = null + emit(State.DefaultDns(true, isEnabled)) + emit(State.Done(finish = false)) + } + private fun handleForceApply() = flow { if (netpVpnSettingsDataStore.customDns != null && currentState == DefaultDns) { netpVpnSettingsDataStore.customDns = null networkProtectionPixels.reportDefaultDnsSet() - emit(State.Done) + emit(State.Done()) } } @@ -73,26 +98,24 @@ class VpnCustomDnsViewModel @Inject constructor( networkProtectionPixels.reportCustomDnsSet() } } - emit(State.Done) + emit(State.Done()) } private fun handleDefaultDnsSelected() = flow { currentState = DefaultDns - emit(State.DefaultDns(true)) - emit(State.NeedApply(initialState != currentState)) + emit(State.DefaultDns(true, blockMalware.await())) } private fun handleCustomDnsSelected() = flow { currentState = CustomDns(dns = null) - emit(State.CustomDns(dns = null, allowChange = true)) + emit(State.CustomDns(dns = null, allowChange = true, applyEnabled = false)) } private fun handleCustomDnsEntered(event: CustomDnsEntered) = flow { val dns = event.dns.orEmpty() currentState = CustomDns(dns) - emit(State.CustomDns(dns, allowChange = true)) val apply = (initialState != currentState) && dns.isValidAddress() - emit(State.NeedApply(apply)) + emit(State.CustomDns(dns, allowChange = true, applyEnabled = apply)) } private fun onInit(isPrivateDnsActive: Boolean): Flow = flow { @@ -102,8 +125,8 @@ class VpnCustomDnsViewModel @Inject constructor( currentState = initialState } customDns?.let { - emit(State.CustomDns(it, !isPrivateDnsActive)) - } ?: emit(State.DefaultDns(!isPrivateDnsActive)) + emit(State.CustomDns(it, !isPrivateDnsActive, applyEnabled = false)) + } ?: emit(State.DefaultDns(!isPrivateDnsActive, blockMalware.await())) } private fun String.isValidAddress(): Boolean { diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsViewSettingViewModel.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsViewSettingViewModel.kt index ac974a4c4f0d..839a03ccebd0 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsViewSettingViewModel.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/custom_dns/VpnCustomDnsViewSettingViewModel.kt @@ -18,6 +18,7 @@ package com.duckduckgo.networkprotection.impl.settings.custom_dns import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.duckduckgo.networkprotection.impl.settings.NetPSettingsLocalConfig import com.duckduckgo.networkprotection.impl.settings.NetpVpnSettingsDataStore import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsSettingView.Event import com.duckduckgo.networkprotection.impl.settings.custom_dns.VpnCustomDnsSettingView.Event.Init @@ -28,6 +29,7 @@ import kotlinx.coroutines.flow.flow class VpnCustomDnsViewSettingViewModel( private val netpVpnSettingsDataStore: NetpVpnSettingsDataStore, + private val netPSettingsLocalConfig: NetPSettingsLocalConfig, ) : ViewModel() { internal fun reduce(event: Event): Flow { @@ -39,18 +41,23 @@ class VpnCustomDnsViewSettingViewModel( private fun onInit(): Flow = flow { netpVpnSettingsDataStore.customDns?.let { emit(State.CustomDns(it)) - } ?: emit(State.Default) + } ?: if (netPSettingsLocalConfig.blockMalware().isEnabled()) { + emit(State.DefaultBlockMalware) + } else { + emit(State.Default) + } } @Suppress("UNCHECKED_CAST") class Factory @Inject constructor( private val store: NetpVpnSettingsDataStore, + private val netPSettingsLocalConfig: NetPSettingsLocalConfig, ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class): T { return with(modelClass) { when { - isAssignableFrom(VpnCustomDnsViewSettingViewModel::class.java) -> VpnCustomDnsViewSettingViewModel(store) // store) + isAssignableFrom(VpnCustomDnsViewSettingViewModel::class.java) -> VpnCustomDnsViewSettingViewModel(store, netPSettingsLocalConfig) else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } } as T diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/snooze/VpnDisableOnCall.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/snooze/VpnDisableOnCall.kt index e8378ec4d93d..8c46bd8e92d8 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/snooze/VpnDisableOnCall.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/snooze/VpnDisableOnCall.kt @@ -48,7 +48,7 @@ class RealVpnDisableOnCall @Inject constructor( override fun enable() { appCoroutineScope.launch(dispatcherProvider.io()) { - netPSettingsLocalConfig.vpnPauseDuringCalls().setEnabled(Toggle.State(enable = true)) + netPSettingsLocalConfig.vpnPauseDuringCalls().setRawStoredState(Toggle.State(enable = true)) context.sendBroadcast(Intent(VpnCallStateReceiver.ACTION_REGISTER_STATE_CALL_LISTENER)) } } @@ -56,7 +56,7 @@ class RealVpnDisableOnCall @Inject constructor( override fun disable() { appCoroutineScope.launch(dispatcherProvider.io()) { context.sendBroadcast(Intent(VpnCallStateReceiver.ACTION_UNREGISTER_STATE_CALL_LISTENER)) - netPSettingsLocalConfig.vpnPauseDuringCalls().setEnabled(Toggle.State(enable = false)) + netPSettingsLocalConfig.vpnPauseDuringCalls().setRawStoredState(Toggle.State(enable = false)) } } diff --git a/network-protection/network-protection-impl/src/main/res/layout/activity_netp_custom_dns.xml b/network-protection/network-protection-impl/src/main/res/layout/activity_netp_custom_dns.xml index 6f2421efa0a6..d544ba3fb866 100644 --- a/network-protection/network-protection-impl/src/main/res/layout/activity_netp_custom_dns.xml +++ b/network-protection/network-protection-impl/src/main/res/layout/activity_netp_custom_dns.xml @@ -93,6 +93,27 @@ app:textType="secondary" app:typography="body2" /> + + + + + + + DNS Server Private DNS DuckDuckGo + DuckDuckGo (Block malware) DuckDuckGo (Recommended) Custom IPv4 Address @@ -231,4 +232,8 @@ Private DNS is already specified in System DNS Settings. Turn off Private DNS to set a custom DNS for DuckDuckGo VPN. Open System Settings Using a custom DNS server can impact browsing speeds and expose your activity to third parties if the server isn\'t secure or reliable. DuckDuckGo routes DNS queries through our DNS servers so your internet provider can\'t see what websites you visit. + + + Block Malware + Block malware with a DNS-level blocklist. If a website doesn\'t load, try turning blocking off. \ No newline at end of file diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStackTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStackTest.kt index 3134ef48adca..7c6ea7b18132 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStackTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStackTest.kt @@ -149,7 +149,7 @@ class WgVpnNetworkStackTest { @Test fun whenBlockMalwareIsConfigureDNSIsConputed() = runTest { whenever(wgTunnel.createAndSetWgConfig()).thenReturn(wgConfig.success()) - netPSettingsLocalConfig.blockMalware().setEnabled(Toggle.State(enable = true)) + netPSettingsLocalConfig.blockMalware().setRawStoredState(Toggle.State(enable = true)) val actual = wgVpnNetworkStack.onPrepareVpn().getOrNull() val expected = wgConfig.toTunnelConfig().copy( diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModelTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModelTest.kt index 84d9461b4931..e5be018c1e79 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModelTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModelTest.kt @@ -199,7 +199,7 @@ class NetworkProtectionManagementViewModelTest { @SuppressLint("DenyListedApi") @Test fun whenOnNetpToggleClickedToDisabledThenUnregisterFeature() = runTest { - vpnRemoteFeatures.showExcludeAppPrompt().setEnabled(Toggle.State(enable = false)) + vpnRemoteFeatures.showExcludeAppPrompt().setRawStoredState(Toggle.State(enable = false)) testee.onNetpToggleClicked(false) verify(networkProtectionState).clearVPNConfigurationAndStop() @@ -584,7 +584,7 @@ class NetworkProtectionManagementViewModelTest { @SuppressLint("DenyListedApi") @Test fun whenExcludeAppPromptEnabledAndToggleTurnedOffThenShowPrompt() = runTest { - vpnRemoteFeatures.showExcludeAppPrompt().setEnabled(Toggle.State(enable = true)) + vpnRemoteFeatures.showExcludeAppPrompt().setRawStoredState(Toggle.State(enable = true)) testee.onNetpToggleClicked(false) testee.commands().test { @@ -602,8 +602,8 @@ class NetworkProtectionManagementViewModelTest { @SuppressLint("DenyListedApi") @Test fun whenPermanentDisableExcludeAppPromptThenDontShowPrompt() = runTest { - vpnRemoteFeatures.showExcludeAppPrompt().setEnabled(Toggle.State(enable = true)) - localConfig.permanentRemoveExcludeAppPrompt().setEnabled(Toggle.State(enable = true)) + vpnRemoteFeatures.showExcludeAppPrompt().setRawStoredState(Toggle.State(enable = true)) + localConfig.permanentRemoveExcludeAppPrompt().setRawStoredState(Toggle.State(enable = true)) testee.onNetpToggleClicked(false) verify(networkProtectionState).clearVPNConfigurationAndStop() diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationSchedulerTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationSchedulerTest.kt index c81ae09df420..bc1095081882 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationSchedulerTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationSchedulerTest.kt @@ -72,7 +72,7 @@ class NetPDisabledNotificationSchedulerTest { @Test fun whenVpnManuallyStoppedThenDoNotShowSnooze() = runTest { - netPSettingsLocalConfig.vpnNotificationAlerts().setEnabled(State(enable = true)) + netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(State(enable = true)) whenever(networkProtectionState.isEnabled()).thenReturn(true) whenever(networkProtectionState.isOnboarded()).thenReturn(true) testee.onVpnStarted(coroutineRule.testScope) @@ -83,7 +83,7 @@ class NetPDisabledNotificationSchedulerTest { @Test fun whenVpnManuallyStoppedWithSnoozeButNoTriggerTimeThenDoNotShowSnooze() = runTest { - netPSettingsLocalConfig.vpnNotificationAlerts().setEnabled(State(enable = true)) + netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(State(enable = true)) whenever(networkProtectionState.isEnabled()).thenReturn(true) whenever(networkProtectionState.isOnboarded()).thenReturn(true) @@ -95,7 +95,7 @@ class NetPDisabledNotificationSchedulerTest { @Test fun whenVpnSnoozedThenShowSnoozeNotification() = runTest { - netPSettingsLocalConfig.vpnNotificationAlerts().setEnabled(State(enable = true)) + netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(State(enable = true)) whenever(networkProtectionState.isEnabled()).thenReturn(true) whenever(networkProtectionState.isOnboarded()).thenReturn(true) diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/NetPVpnSettingsViewModelTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/NetPVpnSettingsViewModelTest.kt index 16dfd9513309..391447170e55 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/NetPVpnSettingsViewModelTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/NetPVpnSettingsViewModelTest.kt @@ -91,8 +91,8 @@ class NetPVpnSettingsViewModelTest { fun onStartEmitCorrectState() = runTest { whenever(vpnDisableOnCall.isEnabled()).thenReturn(true) viewModel.viewState().test { - netPSettingsLocalConfig.vpnExcludeLocalNetworkRoutes().setEnabled(Toggle.State(remoteEnableState = true)) - netPSettingsLocalConfig.vpnNotificationAlerts().setEnabled(Toggle.State(remoteEnableState = false)) + netPSettingsLocalConfig.vpnExcludeLocalNetworkRoutes().setRawStoredState(Toggle.State(remoteEnableState = true)) + netPSettingsLocalConfig.vpnNotificationAlerts().setRawStoredState(Toggle.State(remoteEnableState = false)) viewModel.onStart(mock()) diff --git a/network-protection/network-protection-internal/lint-baseline.xml b/network-protection/network-protection-internal/lint-baseline.xml index a3b15b8835ba..22739877677a 100644 --- a/network-protection/network-protection-internal/lint-baseline.xml +++ b/network-protection/network-protection-internal/lint-baseline.xml @@ -15,55 +15,33 @@ - - - - - - - - + errorLine1=" this.setRawStoredState(Toggle.State(enable = isChecked))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" this.setRawStoredState(Toggle.State(enable = isChecked))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" this.setRawStoredState(Toggle.State(enable = isChecked))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/BlockMalwareVpnSettingView.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/BlockMalwareVpnSettingView.kt deleted file mode 100644 index 208575e7a4be..000000000000 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/BlockMalwareVpnSettingView.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.networkprotection.internal.feature - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.widget.CompoundButton.OnCheckedChangeListener -import android.widget.FrameLayout -import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.anvil.annotations.PriorityKey -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.di.scopes.ViewScope -import com.duckduckgo.feature.toggles.api.Toggle -import com.duckduckgo.mobile.android.vpn.VpnFeature -import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry -import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig -import com.duckduckgo.networkprotection.impl.settings.NetPSettingsLocalConfig -import com.duckduckgo.networkprotection.impl.settings.VpnSettingPlugin -import com.duckduckgo.networkprotection.internal.databinding.VpnViewSettingsBlockMalwareBinding -import com.squareup.anvil.annotations.ContributesMultibinding -import dagger.android.support.AndroidSupportInjection -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@InjectWith(ViewScope::class) -class BlockMalwareVpnSettingView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0, -) : FrameLayout(context, attrs, defStyle) { - - @Inject - lateinit var dispatcherProvider: DispatcherProvider - - @Inject - lateinit var netPSettingsLocalConfig: NetPSettingsLocalConfig - - @Inject - lateinit var vpnFeaturesRegistry: VpnFeaturesRegistry - - @Inject - @AppCoroutineScope - lateinit var appCoroutineScope: CoroutineScope - - @Inject - lateinit var wgTunnelConfig: WgTunnelConfig - - private var mainCoroutineScope: CoroutineScope? = null - - private val binding: VpnViewSettingsBlockMalwareBinding by viewBinding() - - private var didToggleSetting = false - - private val toggleListener = OnCheckedChangeListener { _, value -> - mainCoroutineScope?.launch(dispatcherProvider.io()) { - didToggleSetting = !didToggleSetting - netPSettingsLocalConfig.blockMalware().setEnabled(Toggle.State(enable = value)) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - override fun onAttachedToWindow() { - AndroidSupportInjection.inject(this) - super.onAttachedToWindow() - - @SuppressLint("NoHardcodedCoroutineDispatcher") - mainCoroutineScope = CoroutineScope(SupervisorJob() + dispatcherProvider.main()) - - mainCoroutineScope?.launch(dispatcherProvider.io()) { - val isEnabled = netPSettingsLocalConfig.blockMalware().isEnabled() - withContext(dispatcherProvider.main()) { - binding.blockMalware.quietlySetIsChecked(isEnabled, toggleListener) - } - } - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - if (didToggleSetting) { - // appCoroutineScope to make sure it's not cancelled - appCoroutineScope.launch(dispatcherProvider.io()) { - // wgTunnelConfig.clearWgConfig() // force config re-fetch - // VpnFeature hardcoded here as eventually we'll move this inside the netp-impl module - vpnFeaturesRegistry.refreshFeature(VpnFeature { "NETP_VPN" }) - } - } - mainCoroutineScope?.cancel() - mainCoroutineScope = null - } -} - -@ContributesMultibinding(ActivityScope::class) -@PriorityKey(BLOCK_MALWARE_PRIORITY) -class BlockMalwareSettingViewPlugin @Inject constructor() : VpnSettingPlugin { - override fun getView(context: Context): View? { - return BlockMalwareVpnSettingView(context) - } -} diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/FeaturePriorities.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/FeaturePriorities.kt index 726723b130ad..6e18de732005 100644 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/FeaturePriorities.kt +++ b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/FeaturePriorities.kt @@ -21,4 +21,3 @@ private const val INTERNAL_SETTING_BASE = 10000 internal const val INTERNAL_SETTING_SEPARATOR = INTERNAL_SETTING_BASE + 10 internal const val INTERNAL_SETTING_HEADING = INTERNAL_SETTING_BASE + 20 internal const val UNSAFE_WIFI_DETECTION_PRIORITY = INTERNAL_SETTING_BASE + 30 -internal const val BLOCK_MALWARE_PRIORITY = INTERNAL_SETTING_BASE + 40 diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalFeatureToggles.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalFeatureToggles.kt index af3664ca5b45..b71633d0eafa 100644 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalFeatureToggles.kt +++ b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalFeatureToggles.kt @@ -33,9 +33,6 @@ interface NetPInternalFeatureToggles { @Toggle.DefaultValue(defaultValue = false) fun excludeSystemApps(): Toggle - @Toggle.DefaultValue(defaultValue = false) - fun cloudflareDnsFallback(): Toggle - @Toggle.DefaultValue(defaultValue = false) fun enablePcapRecording(): Toggle diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt index ce75f6008468..29188047e401 100644 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt +++ b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt @@ -143,7 +143,6 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { val isEnabled = networkProtectionState.isEnabled() binding.excludeSystemAppsToggle.isEnabled = isEnabled - binding.dnsLeakProtectionToggle.isEnabled = isEnabled binding.netpPcapRecordingToggle.isEnabled = isEnabled binding.netpDevSettingHeaderPCAPDeleteItem.isEnabled = isEnabled && !netPInternalFeatureToggles.enablePcapRecording().isEnabled() binding.netpSharePcapFileItem.isEnabled = isEnabled && !netPInternalFeatureToggles.enablePcapRecording().isEnabled() @@ -196,7 +195,7 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { with(netPInternalFeatureToggles.excludeSystemApps()) { binding.excludeSystemAppsToggle.setIsChecked(this.isEnabled()) binding.excludeSystemAppsToggle.setOnCheckedChangeListener { _, isChecked -> - this.setEnabled(Toggle.State(enable = isChecked)) + this.setRawStoredState(Toggle.State(enable = isChecked)) networkProtectionState.restart() } } @@ -211,18 +210,10 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { startActivity(NetPSystemAppsExclusionListActivity.intent(this)) } - with(netPInternalFeatureToggles.cloudflareDnsFallback()) { - binding.dnsLeakProtectionToggle.setIsChecked(this.isEnabled()) - binding.dnsLeakProtectionToggle.setOnCheckedChangeListener { _, isChecked -> - this.setEnabled(Toggle.State(enable = isChecked)) - networkProtectionState.restart() - } - } - with(netPInternalFeatureToggles.enablePcapRecording()) { binding.netpPcapRecordingToggle.setIsChecked(this.isEnabled()) binding.netpPcapRecordingToggle.setOnCheckedChangeListener { _, isChecked -> - this.setEnabled(Toggle.State(enable = isChecked)) + this.setRawStoredState(Toggle.State(enable = isChecked)) networkProtectionState.restart() } } @@ -250,7 +241,7 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { with(netPInternalFeatureToggles.useVpnStagingEnvironment()) { binding.changeEnvironment.setIsChecked(this.isEnabled()) binding.changeEnvironment.setOnCheckedChangeListener { _, isChecked -> - this.setEnabled(Toggle.State(enable = isChecked)) + this.setRawStoredState(Toggle.State(enable = isChecked)) handleStagingInput(isChecked) } handleStagingInput(isEnabled()) diff --git a/network-protection/network-protection-internal/src/main/res/layout/activity_netp_internal_settings.xml b/network-protection/network-protection-internal/src/main/res/layout/activity_netp_internal_settings.xml index 777cfd4c6bca..2b9d3b683bdc 100644 --- a/network-protection/network-protection-internal/src/main/res/layout/activity_netp_internal_settings.xml +++ b/network-protection/network-protection-internal/src/main/res/layout/activity_netp_internal_settings.xml @@ -103,13 +103,6 @@ app:showSwitch="false" /> - MTU Size Internal IP Connection Quality - Set DNS 1.1.1.1 fallback Enable PCAP recording Force Rekey Unsafe Wi-Fi detection diff --git a/network-protection/network-protection-internal/src/test/java/com/duckduckgo/networkprotection/internal/feature/TestNetPInternalFeatureToggles.kt b/network-protection/network-protection-internal/src/test/java/com/duckduckgo/networkprotection/internal/feature/TestNetPInternalFeatureToggles.kt index 99daa5e0893f..e84020510d64 100644 --- a/network-protection/network-protection-internal/src/test/java/com/duckduckgo/networkprotection/internal/feature/TestNetPInternalFeatureToggles.kt +++ b/network-protection/network-protection-internal/src/test/java/com/duckduckgo/networkprotection/internal/feature/TestNetPInternalFeatureToggles.kt @@ -40,7 +40,6 @@ class TestNetPInternalFeatureToggles { @Test fun testDefaultValues() { assertTrue(toggles.self().isEnabled()) - assertFalse(toggles.cloudflareDnsFallback().isEnabled()) assertFalse(toggles.excludeSystemApps().isEnabled()) assertFalse(toggles.enablePcapRecording().isEnabled()) } diff --git a/new-tab-page/new-tab-page-impl/lint-baseline.xml b/new-tab-page/new-tab-page-impl/lint-baseline.xml index a195e9b5b784..5dd45f2ee1b8 100644 --- a/new-tab-page/new-tab-page-impl/lint-baseline.xml +++ b/new-tab-page/new-tab-page-impl/lint-baseline.xml @@ -59,8 +59,8 @@ + errorLine1=" setting.self().setRawStoredState(Toggle.State(true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(Toggle.State(false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> void, + * addEventListener: (eventType: string, Function) => void, + * removeEventListener: (eventType: string, Function) => void + * } + * ``` + * + * You send messages to `postMessage` and listen with `addEventListener`. Once the event is received, + * we also remove the listener with `removeEventListener`. + * + * @link https://developer.android.com/reference/androidx/webkit/WebViewCompat#addWebMessageListener(android.webkit.WebView,java.lang.String,java.util.Set%3Cjava.lang.String%3E,androidx.webkit.WebViewCompat.WebMessageListener) + * @implements {MessagingTransport} + */ +class AndroidMessagingTransport { + /** @type {AndroidMessagingConfig} */ + config; + globals = { + capturedHandlers: {} + }; + /** + * @param {AndroidMessagingConfig} config + */ + constructor(config) { + this.config = config; + } + + /** + * Given the method name, returns the related Android handler + * @param {string} methodName + * @returns {AndroidHandler} + * @private + */ + _getHandler(methodName) { + const androidSpecificName = this._getHandlerName(methodName); + if (!(androidSpecificName in window)) { + throw new _messaging.MissingHandler(`Missing android handler: '${methodName}'`, methodName); + } + return window[androidSpecificName]; + } + + /** + * Given the autofill method name, it returns the Android-specific handler name + * @param {string} internalName + * @returns {string} + * @private + */ + _getHandlerName(internalName) { + return 'ddg' + internalName[0].toUpperCase() + internalName.slice(1); + } + + /** + * @param {string} name + * @param {Record} [data] + */ + notify(name) { + let data = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + const handler = this._getHandler(name); + const message = data ? JSON.stringify(data) : ''; + handler.postMessage(message); + } + + /** + * @param {string} name + * @param {Record} [data] + */ + async request(name) { + let data = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + // Set up the listener first + const handler = this._getHandler(name); + const responseOnce = new Promise(resolve => { + const responseHandler = e => { + handler.removeEventListener('message', responseHandler); + resolve(e.data); + }; + handler.addEventListener('message', responseHandler); + }); + + // Then send the message + this.notify(name, data); + + // And return once the promise resolves + const responseJSON = await responseOnce; + return JSON.parse(responseJSON); + } +} + +/** + * Use this configuration to create an instance of {@link Messaging} for Android + */ +exports.AndroidMessagingTransport = AndroidMessagingTransport; +class AndroidMessagingConfig { + /** + * All the expected Android handler names + * @param {{messageHandlerNames: string[]}} config + */ + constructor(config) { + this.messageHandlerNames = config.messageHandlerNames; + } +} +exports.AndroidMessagingConfig = AndroidMessagingConfig; + +},{"./messaging.js":16}],16:[function(require,module,exports){ +"use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); @@ -4602,6 +4726,7 @@ Object.defineProperty(exports, "WebkitMessagingConfig", { } }); var _webkit = require("./webkit.js"); +var _android = require("./android.js"); /** * @module Messaging * @@ -4660,7 +4785,7 @@ var _webkit = require("./webkit.js"); */ class Messaging { /** - * @param {WebkitMessagingConfig} config + * @param {WebkitMessagingConfig | AndroidMessagingConfig} config */ constructor(config) { this.transport = getTransport(config); @@ -4732,7 +4857,7 @@ class MessagingTransport { } /** - * @param {WebkitMessagingConfig} config + * @param {WebkitMessagingConfig | AndroidMessagingConfig} config * @returns {MessagingTransport} */ exports.MessagingTransport = MessagingTransport; @@ -4740,6 +4865,9 @@ function getTransport(config) { if (config instanceof _webkit.WebkitMessagingConfig) { return new _webkit.WebkitMessagingTransport(config); } + if (config instanceof _android.AndroidMessagingConfig) { + return new _android.AndroidMessagingTransport(config); + } throw new Error('unreachable'); } @@ -4762,7 +4890,7 @@ class MissingHandler extends Error { */ exports.MissingHandler = MissingHandler; -},{"./webkit.js":16}],16:[function(require,module,exports){ +},{"./android.js":15,"./webkit.js":17}],17:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5157,7 +5285,7 @@ function captureGlobals() { }; } -},{"./messaging.js":15}],17:[function(require,module,exports){ +},{"./messaging.js":16}],18:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5288,7 +5416,7 @@ function _safeHostname(inputHostname) { } } -},{"./lib/apple.password.js":18,"./lib/constants.js":19,"./lib/rules-parser.js":20}],18:[function(require,module,exports){ +},{"./lib/apple.password.js":19,"./lib/constants.js":20,"./lib/rules-parser.js":21}],19:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5817,7 +5945,7 @@ class Password { } exports.Password = Password; -},{"./constants.js":19,"./rules-parser.js":20}],19:[function(require,module,exports){ +},{"./constants.js":20,"./rules-parser.js":21}],20:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5837,7 +5965,7 @@ const constants = exports.constants = { DEFAULT_UNAMBIGUOUS_CHARS }; -},{}],20:[function(require,module,exports){ +},{}],21:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -6433,7 +6561,7 @@ function parsePasswordRules(input, formatRulesForMinifiedVersion) { return newPasswordRules; } -},{}],21:[function(require,module,exports){ +},{}],22:[function(require,module,exports){ module.exports={ "163.com": { "password-rules": "minlength: 6; maxlength: 16;" @@ -7465,7 +7593,7 @@ module.exports={ "password-rules": "minlength: 8; maxlength: 32; max-consecutive: 6; required: lower; required: upper; required: digit;" } } -},{}],22:[function(require,module,exports){ +},{}],23:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -7529,7 +7657,7 @@ class CredentialsImport { } exports.CredentialsImport = CredentialsImport; -},{"./deviceApiCalls/__generated__/deviceApiCalls.js":68}],23:[function(require,module,exports){ +},{"./deviceApiCalls/__generated__/deviceApiCalls.js":69}],24:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -7585,7 +7713,7 @@ function createDevice() { return new _ExtensionInterface.ExtensionInterface(globalConfig, deviceApi, settings); } -},{"../packages/device-api/index.js":12,"./DeviceInterface/AndroidInterface.js":24,"./DeviceInterface/AppleDeviceInterface.js":25,"./DeviceInterface/AppleOverlayDeviceInterface.js":26,"./DeviceInterface/ExtensionInterface.js":27,"./DeviceInterface/WindowsInterface.js":29,"./DeviceInterface/WindowsOverlayDeviceInterface.js":30,"./Settings.js":51,"./config.js":66,"./deviceApiCalls/transports/transports.js":74}],24:[function(require,module,exports){ +},{"../packages/device-api/index.js":12,"./DeviceInterface/AndroidInterface.js":25,"./DeviceInterface/AppleDeviceInterface.js":26,"./DeviceInterface/AppleOverlayDeviceInterface.js":27,"./DeviceInterface/ExtensionInterface.js":28,"./DeviceInterface/WindowsInterface.js":30,"./DeviceInterface/WindowsOverlayDeviceInterface.js":31,"./Settings.js":52,"./config.js":67,"./deviceApiCalls/transports/transports.js":75}],25:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -7605,25 +7733,35 @@ class AndroidInterface extends _InterfacePrototype.default { * @returns {Promise} */ async getAlias() { - const { - alias - } = await (0, _autofillUtils.sendAndWaitForAnswer)(async () => { - if (this.inContextSignup.isAvailable()) { - const { - isSignedIn - } = await this.deviceApi.request(new _deviceApiCalls.ShowInContextEmailProtectionSignupPromptCall(null)); + // If in-context signup is available, do that first + if (this.inContextSignup.isAvailable()) { + const { + isSignedIn + } = await this.deviceApi.request(new _deviceApiCalls.ShowInContextEmailProtectionSignupPromptCall(null)); + if (isSignedIn) { // On Android we can't get the input type data again without // refreshing the page, so instead we can mutate it now that we // know the user has Email Protection available. - if (this.globalConfig.availableInputTypes) { - this.globalConfig.availableInputTypes.email = isSignedIn; + if (this.settings.availableInputTypes) { + this.settings.setAvailableInputTypes({ + email: isSignedIn + }); } this.updateForStateChange(); this.onFinishedAutofill(); } - return window.EmailInterface.showTooltip(); - }, 'getAliasResponse'); - return alias; + } + // Then, if successful actually prompt to fill + if (this.settings.availableInputTypes.email) { + const { + alias + } = await this.deviceApi.request(new _deviceApiCalls.EmailProtectionGetAliasCall({ + requiresUserPermission: !this.globalConfig.isApp, + shouldConsumeAliasIfProvided: !this.globalConfig.isApp, + isIncontextSignupAvailable: this.inContextSignup.isAvailable() + })); + return alias ? (0, _autofillUtils.formatDuckAddress)(alias) : undefined; + } } /** @@ -7638,14 +7776,9 @@ class AndroidInterface extends _InterfacePrototype.default { * @returns {boolean} */ isDeviceSignedIn() { - // on DDG domains, always check via `window.EmailInterface.isSignedIn()` - if (this.globalConfig.isDDGDomain) { - return window.EmailInterface.isSignedIn() === 'true'; - } - // on non-DDG domains, where `availableInputTypes.email` is present, use it - if (typeof this.globalConfig.availableInputTypes?.email === 'boolean') { - return this.globalConfig.availableInputTypes.email; + if (typeof this.settings.availableInputTypes?.email === 'boolean') { + return this.settings.availableInputTypes.email; } // ...on other domains we assume true because the script wouldn't exist otherwise @@ -7660,15 +7793,7 @@ class AndroidInterface extends _InterfacePrototype.default { * Settings page displays data of the logged in user data */ getUserData() { - let userData = null; - try { - userData = JSON.parse(window.EmailInterface.getUserData()); - } catch (e) { - if (this.globalConfig.isDDGTestMode) { - console.error(e); - } - } - return Promise.resolve(userData); + return this.deviceApi.request(new _deviceApiCalls.EmailProtectionGetUserDataCall({})); } /** @@ -7676,25 +7801,13 @@ class AndroidInterface extends _InterfacePrototype.default { * Device capabilities determine which functionality is available to the user */ getEmailProtectionCapabilities() { - let deviceCapabilities = null; - try { - deviceCapabilities = JSON.parse(window.EmailInterface.getDeviceCapabilities()); - } catch (e) { - if (this.globalConfig.isDDGTestMode) { - console.error(e); - } - } - return Promise.resolve(deviceCapabilities); + return this.deviceApi.request(new _deviceApiCalls.EmailProtectionGetCapabilitiesCall({})); } storeUserData(_ref) { let { - addUserData: { - token, - userName, - cohort - } + addUserData } = _ref; - return window.EmailInterface.storeCredentials(token, userName, cohort); + return this.deviceApi.request(new _deviceApiCalls.EmailProtectionStoreUserDataCall(addUserData)); } /** @@ -7702,13 +7815,7 @@ class AndroidInterface extends _InterfacePrototype.default { * Provides functionality to log the user out */ removeUserData() { - try { - return window.EmailInterface.removeCredentials(); - } catch (e) { - if (this.globalConfig.isDDGTestMode) { - console.error(e); - } - } + return this.deviceApi.request(new _deviceApiCalls.EmailProtectionRemoveUserDataCall({})); } /** @@ -7733,7 +7840,7 @@ class AndroidInterface extends _InterfacePrototype.default { } exports.AndroidInterface = AndroidInterface; -},{"../InContextSignup.js":45,"../UI/controllers/NativeUIController.js":59,"../autofill-utils.js":64,"../deviceApiCalls/__generated__/deviceApiCalls.js":68,"./InterfacePrototype.js":28}],25:[function(require,module,exports){ +},{"../InContextSignup.js":46,"../UI/controllers/NativeUIController.js":60,"../autofill-utils.js":65,"../deviceApiCalls/__generated__/deviceApiCalls.js":69,"./InterfacePrototype.js":29}],26:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8089,7 +8196,7 @@ class AppleDeviceInterface extends _InterfacePrototype.default { } exports.AppleDeviceInterface = AppleDeviceInterface; -},{"../../packages/device-api/index.js":12,"../Form/matching.js":44,"../InContextSignup.js":45,"../ThirdPartyProvider.js":52,"../UI/HTMLTooltip.js":57,"../UI/controllers/HTMLTooltipUIController.js":58,"../UI/controllers/NativeUIController.js":59,"../UI/controllers/OverlayUIController.js":60,"../autofill-utils.js":64,"../deviceApiCalls/__generated__/deviceApiCalls.js":68,"../deviceApiCalls/additionalDeviceApiCalls.js":70,"./InterfacePrototype.js":28}],26:[function(require,module,exports){ +},{"../../packages/device-api/index.js":12,"../Form/matching.js":45,"../InContextSignup.js":46,"../ThirdPartyProvider.js":53,"../UI/HTMLTooltip.js":58,"../UI/controllers/HTMLTooltipUIController.js":59,"../UI/controllers/NativeUIController.js":60,"../UI/controllers/OverlayUIController.js":61,"../autofill-utils.js":65,"../deviceApiCalls/__generated__/deviceApiCalls.js":69,"../deviceApiCalls/additionalDeviceApiCalls.js":71,"./InterfacePrototype.js":29}],27:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8208,7 +8315,7 @@ class AppleOverlayDeviceInterface extends _AppleDeviceInterface.AppleDeviceInter } exports.AppleOverlayDeviceInterface = AppleOverlayDeviceInterface; -},{"../../packages/device-api/index.js":12,"../UI/controllers/HTMLTooltipUIController.js":58,"./AppleDeviceInterface.js":25,"./overlayApi.js":32}],27:[function(require,module,exports){ +},{"../../packages/device-api/index.js":12,"../UI/controllers/HTMLTooltipUIController.js":59,"./AppleDeviceInterface.js":26,"./overlayApi.js":33}],28:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8427,7 +8534,7 @@ class ExtensionInterface extends _InterfacePrototype.default { } exports.ExtensionInterface = ExtensionInterface; -},{"../Form/matching.js":44,"../InContextSignup.js":45,"../UI/HTMLTooltip.js":57,"../UI/controllers/HTMLTooltipUIController.js":58,"../autofill-utils.js":64,"./InterfacePrototype.js":28}],28:[function(require,module,exports){ +},{"../Form/matching.js":45,"../InContextSignup.js":46,"../UI/HTMLTooltip.js":58,"../UI/controllers/HTMLTooltipUIController.js":59,"../autofill-utils.js":65,"./InterfacePrototype.js":29}],29:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8696,8 +8803,7 @@ class InterfacePrototype { } async startInit() { if (this.isInitializationStarted) return; - this.alreadyInitialized = true; - await this.settings.refresh(); + this.isInitializationStarted = true; this.addDeviceListeners(); await this.setupAutofill(); this.uiController = this.createUIController(); @@ -9031,11 +9137,19 @@ class InterfacePrototype { let userData; try { userData = await this.getUserData(); - } catch (e) {} + } catch (e) { + if (this.isTestMode()) { + console.log('getUserData failed with', e); + } + } let capabilities; try { capabilities = await this.getEmailProtectionCapabilities(); - } catch (e) {} + } catch (e) { + if (this.isTestMode()) { + console.log('capabilities fetching failed with', e); + } + } // Set up listener for web app actions if (this.globalConfig.isDDGDomain) { @@ -9091,6 +9205,13 @@ class InterfacePrototype { const data = await (0, _autofillUtils.sendAndWaitForAnswer)(_autofillUtils.SIGN_IN_MSG, 'addUserData'); // This call doesn't send a response, so we can't know if it succeeded this.storeUserData(data); + + // Assuming the previous call succeeded, let's update availableInputTypes + if (this.settings.availableInputTypes) { + this.settings.setAvailableInputTypes({ + email: true + }); + } await this.setupAutofill(); await this.settings.refresh(); await this.setupSettingsPage({ @@ -9259,7 +9380,7 @@ class InterfacePrototype { } var _default = exports.default = InterfacePrototype; -},{"../../packages/device-api/index.js":12,"../CredentialsImport.js":22,"../EmailProtection.js":33,"../Form/formatters.js":37,"../Form/matching.js":44,"../InputTypes/Credentials.js":46,"../PasswordGenerator.js":49,"../Scanner.js":50,"../Settings.js":51,"../UI/controllers/NativeUIController.js":59,"../autofill-utils.js":64,"../config.js":66,"../deviceApiCalls/__generated__/deviceApiCalls.js":68,"../deviceApiCalls/transports/transports.js":74,"../locales/strings.js":99,"./initFormSubmissionsApi.js":31}],29:[function(require,module,exports){ +},{"../../packages/device-api/index.js":12,"../CredentialsImport.js":23,"../EmailProtection.js":34,"../Form/formatters.js":38,"../Form/matching.js":45,"../InputTypes/Credentials.js":47,"../PasswordGenerator.js":50,"../Scanner.js":51,"../Settings.js":52,"../UI/controllers/NativeUIController.js":60,"../autofill-utils.js":65,"../config.js":67,"../deviceApiCalls/__generated__/deviceApiCalls.js":69,"../deviceApiCalls/transports/transports.js":75,"../locales/strings.js":100,"./initFormSubmissionsApi.js":32}],30:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -9344,13 +9465,13 @@ class WindowsInterface extends _InterfacePrototype.default { return await this.credentialsImport.refresh(); } default: - if (this.globalConfig.isDDGTestMode) { + if (this.isTestMode()) { console.warn('unhandled response', resp); } return this._closeAutofillParent(); } } catch (e) { - if (this.globalConfig.isDDGTestMode) { + if (this.isTestMode()) { if (e instanceof DOMException && e.name === 'AbortError') { console.log('Promise Aborted'); } else { @@ -9425,7 +9546,7 @@ class WindowsInterface extends _InterfacePrototype.default { } exports.WindowsInterface = WindowsInterface; -},{"../UI/controllers/OverlayUIController.js":60,"../deviceApiCalls/__generated__/deviceApiCalls.js":68,"./InterfacePrototype.js":28}],30:[function(require,module,exports){ +},{"../UI/controllers/OverlayUIController.js":61,"../deviceApiCalls/__generated__/deviceApiCalls.js":69,"./InterfacePrototype.js":29}],31:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -9604,7 +9725,7 @@ class WindowsOverlayDeviceInterface extends _InterfacePrototype.default { } exports.WindowsOverlayDeviceInterface = WindowsOverlayDeviceInterface; -},{"../UI/controllers/HTMLTooltipUIController.js":58,"../deviceApiCalls/__generated__/deviceApiCalls.js":68,"./InterfacePrototype.js":28,"./overlayApi.js":32}],31:[function(require,module,exports){ +},{"../UI/controllers/HTMLTooltipUIController.js":59,"../deviceApiCalls/__generated__/deviceApiCalls.js":69,"./InterfacePrototype.js":29,"./overlayApi.js":33}],32:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -9703,7 +9824,7 @@ function initFormSubmissionsApi(forms, matching) { }); } -},{"../Form/label-util.js":40,"../autofill-utils.js":64}],32:[function(require,module,exports){ +},{"../Form/label-util.js":41,"../autofill-utils.js":65}],33:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -9761,7 +9882,7 @@ function overlayApi(device) { }; } -},{"../deviceApiCalls/__generated__/deviceApiCalls.js":68}],33:[function(require,module,exports){ +},{"../deviceApiCalls/__generated__/deviceApiCalls.js":69}],34:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -9796,7 +9917,7 @@ class EmailProtection { } exports.EmailProtection = EmailProtection; -},{}],34:[function(require,module,exports){ +},{}],35:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -9936,7 +10057,7 @@ class Form { } submitHandler() { let via = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'unknown'; - if (this.device.globalConfig.isDDGTestMode) { + if (this.device.isTestMode()) { console.log('Form.submitHandler via:', via, this); } if (this.submitHandlerExecuted) return; @@ -10703,7 +10824,7 @@ class Form { } exports.Form = Form; -},{"../InputTypes/Credentials.js":46,"../autofill-utils.js":64,"../constants.js":67,"./FormAnalyzer.js":35,"./formatters.js":37,"./inputStyles.js":38,"./inputTypeConfig.js":39,"./matching.js":44}],35:[function(require,module,exports){ +},{"../InputTypes/Credentials.js":47,"../autofill-utils.js":65,"../constants.js":68,"./FormAnalyzer.js":36,"./formatters.js":38,"./inputStyles.js":39,"./inputTypeConfig.js":40,"./matching.js":45}],36:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11073,7 +11194,7 @@ class FormAnalyzer { } var _default = exports.default = FormAnalyzer; -},{"../autofill-utils.js":64,"../constants.js":67,"./matching-config/__generated__/compiled-matching-config.js":42,"./matching.js":44}],36:[function(require,module,exports){ +},{"../autofill-utils.js":65,"../constants.js":68,"./matching-config/__generated__/compiled-matching-config.js":43,"./matching.js":45}],37:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11638,7 +11759,7 @@ const COUNTRY_NAMES_TO_CODES = exports.COUNTRY_NAMES_TO_CODES = { 'Unknown Region': 'ZZ' }; -},{}],37:[function(require,module,exports){ +},{}],38:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11943,7 +12064,7 @@ const prepareFormValuesForStorage = formValues => { }; exports.prepareFormValuesForStorage = prepareFormValuesForStorage; -},{"./countryNames.js":36,"./matching.js":44}],38:[function(require,module,exports){ +},{"./countryNames.js":37,"./matching.js":45}],39:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12034,7 +12155,7 @@ const getIconStylesAutofilled = (input, form) => { }; exports.getIconStylesAutofilled = getIconStylesAutofilled; -},{"./inputTypeConfig.js":39}],39:[function(require,module,exports){ +},{"./inputTypeConfig.js":40}],40:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12288,7 +12409,7 @@ const isFieldDecorated = input => { }; exports.isFieldDecorated = isFieldDecorated; -},{"../InputTypes/Credentials.js":46,"../InputTypes/CreditCard.js":47,"../InputTypes/Identity.js":48,"../UI/img/ddgPasswordIcon.js":62,"../constants.js":67,"./logo-svg.js":41,"./matching.js":44}],40:[function(require,module,exports){ +},{"../InputTypes/Credentials.js":47,"../InputTypes/CreditCard.js":48,"../InputTypes/Identity.js":49,"../UI/img/ddgPasswordIcon.js":63,"../constants.js":68,"./logo-svg.js":42,"./matching.js":45}],41:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12336,7 +12457,7 @@ const extractElementStrings = element => { }; exports.extractElementStrings = extractElementStrings; -},{"./matching.js":44}],41:[function(require,module,exports){ +},{"./matching.js":45}],42:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12369,7 +12490,7 @@ const daxGrayscaleSvg = ` `.trim(); const daxGrayscaleBase64 = exports.daxGrayscaleBase64 = `data:image/svg+xml;base64,${window.btoa(daxGrayscaleSvg)}`; -},{}],42:[function(require,module,exports){ +},{}],43:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12822,7 +12943,7 @@ const matchingConfiguration = exports.matchingConfiguration = { } }; -},{}],43:[function(require,module,exports){ +},{}],44:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12897,7 +13018,7 @@ function logUnmatched(el, allStrings) { console.groupEnd(); } -},{"../autofill-utils.js":64,"./matching.js":44}],44:[function(require,module,exports){ +},{"../autofill-utils.js":65,"./matching.js":45}],45:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -13889,7 +14010,7 @@ function createMatching() { return new Matching(_compiledMatchingConfig.matchingConfiguration); } -},{"../autofill-utils.js":64,"../constants.js":67,"./label-util.js":40,"./matching-config/__generated__/compiled-matching-config.js":42,"./matching-utils.js":43}],45:[function(require,module,exports){ +},{"../autofill-utils.js":65,"../constants.js":68,"./label-util.js":41,"./matching-config/__generated__/compiled-matching-config.js":43,"./matching-utils.js":44}],46:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14021,7 +14142,7 @@ class InContextSignup { } exports.InContextSignup = InContextSignup; -},{"./autofill-utils.js":64,"./deviceApiCalls/__generated__/deviceApiCalls.js":68}],46:[function(require,module,exports){ +},{"./autofill-utils.js":65,"./deviceApiCalls/__generated__/deviceApiCalls.js":69}],47:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14177,7 +14298,7 @@ function createCredentialsTooltipItem(data) { return new CredentialsTooltipItem(data); } -},{"../autofill-utils.js":64}],47:[function(require,module,exports){ +},{"../autofill-utils.js":65}],48:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14202,7 +14323,7 @@ class CreditCardTooltipItem { } exports.CreditCardTooltipItem = CreditCardTooltipItem; -},{}],48:[function(require,module,exports){ +},{}],49:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14248,7 +14369,7 @@ class IdentityTooltipItem { } exports.IdentityTooltipItem = IdentityTooltipItem; -},{"../Form/formatters.js":37}],49:[function(require,module,exports){ +},{"../Form/formatters.js":38}],50:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14290,7 +14411,7 @@ class PasswordGenerator { } exports.PasswordGenerator = PasswordGenerator; -},{"../packages/password/index.js":17,"../packages/password/rules.json":21}],50:[function(require,module,exports){ +},{"../packages/password/index.js":18,"../packages/password/rules.json":22}],51:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14751,7 +14872,7 @@ function createScanner(device, scannerOptions) { }); } -},{"./Form/Form.js":34,"./Form/matching.js":44,"./autofill-utils.js":64,"./constants.js":67,"./deviceApiCalls/__generated__/deviceApiCalls.js":68}],51:[function(require,module,exports){ +},{"./Form/Form.js":35,"./Form/matching.js":45,"./autofill-utils.js":65,"./constants.js":68,"./deviceApiCalls/__generated__/deviceApiCalls.js":69}],52:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14897,6 +15018,11 @@ class Settings { if (this._runtimeConfiguration) return this._runtimeConfiguration; const runtimeConfig = await this.deviceApi.request(new _deviceApiCalls.GetRuntimeConfigurationCall(null)); this._runtimeConfiguration = runtimeConfig; + + // If the platform sends availableInputTypes here, store them + if (runtimeConfig.availableInputTypes) { + this.setAvailableInputTypes(runtimeConfig.availableInputTypes); + } return this._runtimeConfiguration; } @@ -14912,6 +15038,9 @@ class Settings { if (this.globalConfig.isTopFrame) { return Settings.defaults.availableInputTypes; } + if (this._availableInputTypes) { + return this.availableInputTypes; + } return await this.deviceApi.request(new _deviceApiCalls.GetAvailableInputTypesCall(null)); } catch (e) { if (this.globalConfig.isDDGTestMode) { @@ -15164,7 +15293,7 @@ class Settings { } exports.Settings = Settings; -},{"../packages/device-api/index.js":12,"./autofill-utils.js":64,"./deviceApiCalls/__generated__/deviceApiCalls.js":68,"./deviceApiCalls/__generated__/validators.zod.js":69}],52:[function(require,module,exports){ +},{"../packages/device-api/index.js":12,"./autofill-utils.js":65,"./deviceApiCalls/__generated__/deviceApiCalls.js":69,"./deviceApiCalls/__generated__/validators.zod.js":70}],53:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -15231,7 +15360,7 @@ class ThirdPartyProvider { this.device.scanner.forms.forEach(form => form.recategorizeAllInputs()); } } catch (e) { - if (this.device.globalConfig.isDDGTestMode) { + if (this.device.isTestMode()) { console.log('isDDGTestMode: providerStatusUpdated error: ❌', e); } } @@ -15246,7 +15375,7 @@ class ThirdPartyProvider { } setTimeout(() => this._pollForUpdatesToCredentialsProvider(), 2000); } catch (e) { - if (this.device.globalConfig.isDDGTestMode) { + if (this.device.isTestMode()) { console.log('isDDGTestMode: _pollForUpdatesToCredentialsProvider: ❌', e); } } @@ -15254,7 +15383,7 @@ class ThirdPartyProvider { } exports.ThirdPartyProvider = ThirdPartyProvider; -},{"../packages/device-api/index.js":12,"./Form/matching.js":44,"./deviceApiCalls/__generated__/deviceApiCalls.js":68,"./deviceApiCalls/__generated__/validators.zod.js":69}],53:[function(require,module,exports){ +},{"../packages/device-api/index.js":12,"./Form/matching.js":45,"./deviceApiCalls/__generated__/deviceApiCalls.js":69,"./deviceApiCalls/__generated__/validators.zod.js":70}],54:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -15295,7 +15424,7 @@ ${this.options.css} } var _default = exports.default = CredentialsImportTooltip; -},{"./HTMLTooltip.js":57}],54:[function(require,module,exports){ +},{"./HTMLTooltip.js":58}],55:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -15446,7 +15575,7 @@ ${css} } var _default = exports.default = DataHTMLTooltip; -},{"../InputTypes/Credentials.js":46,"../autofill-utils.js":64,"./HTMLTooltip.js":57}],55:[function(require,module,exports){ +},{"../InputTypes/Credentials.js":47,"../autofill-utils.js":65,"./HTMLTooltip.js":58}],56:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -15528,7 +15657,7 @@ ${this.options.css} } var _default = exports.default = EmailHTMLTooltip; -},{"../autofill-utils.js":64,"./HTMLTooltip.js":57}],56:[function(require,module,exports){ +},{"../autofill-utils.js":65,"./HTMLTooltip.js":58}],57:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -15581,7 +15710,7 @@ ${this.options.css} } var _default = exports.default = EmailSignupHTMLTooltip; -},{"./HTMLTooltip.js":57}],57:[function(require,module,exports){ +},{"./HTMLTooltip.js":58}],58:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -15969,7 +16098,7 @@ class HTMLTooltip { exports.HTMLTooltip = HTMLTooltip; var _default = exports.default = HTMLTooltip; -},{"../Form/matching.js":44,"../autofill-utils.js":64,"./styles/styles.js":63}],58:[function(require,module,exports){ +},{"../Form/matching.js":45,"../autofill-utils.js":65,"./styles/styles.js":64}],59:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -16337,7 +16466,7 @@ class HTMLTooltipUIController extends _UIController.UIController { } exports.HTMLTooltipUIController = HTMLTooltipUIController; -},{"../../Form/inputTypeConfig.js":39,"../../Form/matching.js":44,"../../autofill-utils.js":64,"../CredentialsImportTooltip.js":53,"../DataHTMLTooltip.js":54,"../EmailHTMLTooltip.js":55,"../EmailSignupHTMLTooltip.js":56,"../HTMLTooltip.js":57,"./UIController.js":61}],59:[function(require,module,exports){ +},{"../../Form/inputTypeConfig.js":40,"../../Form/matching.js":45,"../../autofill-utils.js":65,"../CredentialsImportTooltip.js":54,"../DataHTMLTooltip.js":55,"../EmailHTMLTooltip.js":56,"../EmailSignupHTMLTooltip.js":57,"../HTMLTooltip.js":58,"./UIController.js":62}],60:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -16439,6 +16568,11 @@ class NativeUIController extends _UIController.UIController { form.activeInput?.focus(); break; } + case 'none': + { + // do nothing + break; + } default: { if (args.device.isTestMode()) { @@ -16499,7 +16633,7 @@ class NativeUIController extends _UIController.UIController { } exports.NativeUIController = NativeUIController; -},{"../../Form/matching.js":44,"../../InputTypes/Credentials.js":46,"../../deviceApiCalls/__generated__/deviceApiCalls.js":68,"./UIController.js":61}],60:[function(require,module,exports){ +},{"../../Form/matching.js":45,"../../InputTypes/Credentials.js":47,"../../deviceApiCalls/__generated__/deviceApiCalls.js":69,"./UIController.js":62}],61:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -16736,7 +16870,7 @@ class OverlayUIController extends _UIController.UIController { } exports.OverlayUIController = OverlayUIController; -},{"../../Form/matching.js":44,"./UIController.js":61}],61:[function(require,module,exports){ +},{"../../Form/matching.js":45,"./UIController.js":62}],62:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -16820,7 +16954,7 @@ class UIController { } exports.UIController = UIController; -},{}],62:[function(require,module,exports){ +},{}],63:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -16837,7 +16971,7 @@ const ddgCcIconBase = exports.ddgCcIconBase = 'data:image/svg+xml;base64,PD94bWw const ddgCcIconFilled = exports.ddgCcIconFilled = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+CiAgICA8cGF0aCBkPSJNNSA5Yy0uNTUyIDAtMSAuNDQ4LTEgMXYyYzAgLjU1Mi40NDggMSAxIDFoM2MuNTUyIDAgMS0uNDQ4IDEtMXYtMmMwLS41NTItLjQ0OC0xLTEtMUg1eiIgZmlsbD0iIzc2NDMxMCIvPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xIDZjMC0yLjIxIDEuNzktNCA0LTRoMTRjMi4yMSAwIDQgMS43OSA0IDR2MTJjMCAyLjIxLTEuNzkgNC00IDRINWMtMi4yMSAwLTQtMS43OS00LTRWNnptNC0yYy0xLjEwNSAwLTIgLjg5NS0yIDJ2OWgxOFY2YzAtMS4xMDUtLjg5NS0yLTItMkg1em0wIDE2Yy0xLjEwNSAwLTItLjg5NS0yLTJoMThjMCAxLjEwNS0uODk1IDItMiAySDV6IiBmaWxsPSIjNzY0MzEwIi8+Cjwvc3ZnPgo='; const ddgIdentityIconBase = exports.ddgIdentityIconBase = `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+CiAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTEyIDIxYzIuMTQzIDAgNC4xMTEtLjc1IDUuNjU3LTItLjYyNi0uNTA2LTEuMzE4LS45MjctMi4wNi0xLjI1LTEuMS0uNDgtMi4yODUtLjczNS0zLjQ4Ni0uNzUtMS4yLS4wMTQtMi4zOTIuMjExLTMuNTA0LjY2NC0uODE3LjMzMy0xLjU4Ljc4My0yLjI2NCAxLjMzNiAxLjU0NiAxLjI1IDMuNTE0IDIgNS42NTcgMnptNC4zOTctNS4wODNjLjk2Ny40MjIgMS44NjYuOTggMi42NzIgMS42NTVDMjAuMjc5IDE2LjAzOSAyMSAxNC4xMDQgMjEgMTJjMC00Ljk3LTQuMDMtOS05LTlzLTkgNC4wMy05IDljMCAyLjEwNC43MjIgNC4wNCAxLjkzMiA1LjU3Mi44NzQtLjczNCAxLjg2LTEuMzI4IDIuOTIxLTEuNzYgMS4zNi0uNTU0IDIuODE2LS44MyA0LjI4My0uODExIDEuNDY3LjAxOCAyLjkxNi4zMyA0LjI2LjkxNnpNMTIgMjNjNi4wNzUgMCAxMS00LjkyNSAxMS0xMVMxOC4wNzUgMSAxMiAxIDEgNS45MjUgMSAxMnM0LjkyNSAxMSAxMSAxMXptMy0xM2MwIDEuNjU3LTEuMzQzIDMtMyAzcy0zLTEuMzQzLTMtMyAxLjM0My0zIDMtMyAzIDEuMzQzIDMgM3ptMiAwYzAgMi43NjEtMi4yMzkgNS01IDVzLTUtMi4yMzktNS01IDIuMjM5LTUgNS01IDUgMi4yMzkgNSA1eiIgZmlsbD0iIzAwMCIvPgo8L3N2Zz4KPHBhdGggeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTEyIDIxYzIuMTQzIDAgNC4xMTEtLjc1IDUuNjU3LTItLjYyNi0uNTA2LTEuMzE4LS45MjctMi4wNi0xLjI1LTEuMS0uNDgtMi4yODUtLjczNS0zLjQ4Ni0uNzUtMS4yLS4wMTQtMi4zOTIuMjExLTMuNTA0LjY2NC0uODE3LjMzMy0xLjU4Ljc4My0yLjI2NCAxLjMzNiAxLjU0NiAxLjI1IDMuNTE0IDIgNS42NTcgMnptNC4zOTctNS4wODNjLjk2Ny40MjIgMS44NjYuOTggMi42NzIgMS42NTVDMjAuMjc5IDE2LjAzOSAyMSAxNC4xMDQgMjEgMTJjMC00Ljk3LTQuMDMtOS05LTlzLTkgNC4wMy05IDljMCAyLjEwNC43MjIgNC4wNCAxLjkzMiA1LjU3Mi44NzQtLjczNCAxLjg2LTEuMzI4IDIuOTIxLTEuNzYgMS4zNi0uNTU0IDIuODE2LS44MyA0LjI4My0uODExIDEuNDY3LjAxOCAyLjkxNi4zMyA0LjI2LjkxNnpNMTIgMjNjNi4wNzUgMCAxMS00LjkyNSAxMS0xMVMxOC4wNzUgMSAxMiAxIDEgNS45MjUgMSAxMnM0LjkyNSAxMSAxMSAxMXptMy0xM2MwIDEuNjU3LTEuMzQzIDMtMyAzcy0zLTEuMzQzLTMtMyAxLjM0My0zIDMtMyAzIDEuMzQzIDMgM3ptMiAwYzAgMi43NjEtMi4yMzkgNS01IDVzLTUtMi4yMzktNS01IDIuMjM5LTUgNS01IDUgMi4yMzkgNSA1eiIgZmlsbD0iIzAwMCIvPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSJub25lIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMiAyMWMyLjE0MyAwIDQuMTExLS43NSA1LjY1Ny0yLS42MjYtLjUwNi0xLjMxOC0uOTI3LTIuMDYtMS4yNS0xLjEtLjQ4LTIuMjg1LS43MzUtMy40ODYtLjc1LTEuMi0uMDE0LTIuMzkyLjIxMS0zLjUwNC42NjQtLjgxNy4zMzMtMS41OC43ODMtMi4yNjQgMS4zMzYgMS41NDYgMS4yNSAzLjUxNCAyIDUuNjU3IDJ6bTQuMzk3LTUuMDgzYy45NjcuNDIyIDEuODY2Ljk4IDIuNjcyIDEuNjU1QzIwLjI3OSAxNi4wMzkgMjEgMTQuMTA0IDIxIDEyYzAtNC45Ny00LjAzLTktOS05cy05IDQuMDMtOSA5YzAgMi4xMDQuNzIyIDQuMDQgMS45MzIgNS41NzIuODc0LS43MzQgMS44Ni0xLjMyOCAyLjkyMS0xLjc2IDEuMzYtLjU1NCAyLjgxNi0uODMgNC4yODMtLjgxMSAxLjQ2Ny4wMTggMi45MTYuMzMgNC4yNi45MTZ6TTEyIDIzYzYuMDc1IDAgMTEtNC45MjUgMTEtMTFTMTguMDc1IDEgMTIgMSAxIDUuOTI1IDEgMTJzNC45MjUgMTEgMTEgMTF6bTMtMTNjMCAxLjY1Ny0xLjM0MyAzLTMgM3MtMy0xLjM0My0zLTMgMS4zNDMtMyAzLTMgMyAxLjM0MyAzIDN6bTIgMGMwIDIuNzYxLTIuMjM5IDUtNSA1cy01LTIuMjM5LTUtNSAyLjIzOS01IDUtNSA1IDIuMjM5IDUgNXoiIGZpbGw9IiMwMDAiLz4KPC9zdmc+Cg==`; -},{}],63:[function(require,module,exports){ +},{}],64:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -16846,7 +16980,7 @@ Object.defineProperty(exports, "__esModule", { exports.CSS_STYLES = void 0; const CSS_STYLES = exports.CSS_STYLES = ":root {\n color-scheme: light dark;\n}\n\n.wrapper *, .wrapper *::before, .wrapper *::after {\n box-sizing: border-box;\n}\n.wrapper {\n position: fixed;\n top: 0;\n left: 0;\n padding: 0;\n font-family: 'DDG_ProximaNova', 'Proxima Nova', system-ui, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',\n 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\n -webkit-font-smoothing: antialiased;\n z-index: 2147483647;\n}\n.wrapper--data {\n font-family: 'SF Pro Text', system-ui, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',\n 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\n}\n.wrapper:not(.top-autofill) .tooltip {\n position: absolute;\n width: 300px;\n max-width: calc(100vw - 25px);\n transform: translate(-1000px, -1000px);\n z-index: 2147483647;\n}\n.tooltip--data, #topAutofill {\n background-color: rgba(242, 240, 240, 1);\n -webkit-backdrop-filter: blur(40px);\n backdrop-filter: blur(40px);\n}\n@media (prefers-color-scheme: dark) {\n .tooltip--data, #topAutofill {\n background: rgb(100, 98, 102, .9);\n }\n}\n.tooltip--data {\n padding: 6px;\n font-size: 13px;\n line-height: 14px;\n width: 315px;\n max-height: 290px;\n overflow-y: auto;\n}\n.top-autofill .tooltip--data {\n min-height: 100vh;\n}\n.tooltip--data.tooltip--incontext-signup {\n width: 360px;\n}\n.wrapper:not(.top-autofill) .tooltip--data {\n top: 100%;\n left: 100%;\n border: 0.5px solid rgba(255, 255, 255, 0.2);\n border-radius: 6px;\n box-shadow: 0 10px 20px rgba(0, 0, 0, 0.32);\n}\n@media (prefers-color-scheme: dark) {\n .wrapper:not(.top-autofill) .tooltip--data {\n border: 1px solid rgba(255, 255, 255, 0.2);\n }\n}\n.wrapper:not(.top-autofill) .tooltip--email {\n top: calc(100% + 6px);\n right: calc(100% - 48px);\n padding: 8px;\n border: 1px solid #D0D0D0;\n border-radius: 10px;\n background-color: #FFFFFF;\n font-size: 14px;\n line-height: 1.3;\n color: #333333;\n box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);\n}\n.tooltip--email__caret {\n position: absolute;\n transform: translate(-1000px, -1000px);\n z-index: 2147483647;\n}\n.tooltip--email__caret::before,\n.tooltip--email__caret::after {\n content: \"\";\n width: 0;\n height: 0;\n border-left: 10px solid transparent;\n border-right: 10px solid transparent;\n display: block;\n border-bottom: 8px solid #D0D0D0;\n position: absolute;\n right: -28px;\n}\n.tooltip--email__caret::before {\n border-bottom-color: #D0D0D0;\n top: -1px;\n}\n.tooltip--email__caret::after {\n border-bottom-color: #FFFFFF;\n top: 0px;\n}\n\n/* Buttons */\n.tooltip__button {\n display: flex;\n width: 100%;\n padding: 8px 8px 8px 0px;\n font-family: inherit;\n color: inherit;\n background: transparent;\n border: none;\n border-radius: 6px;\n text-align: left;\n}\n.tooltip__button.currentFocus,\n.wrapper:not(.top-autofill) .tooltip__button:hover {\n background-color: #3969EF;\n color: #FFFFFF;\n}\n\n/* Data autofill tooltip specific */\n.tooltip__button--data {\n position: relative;\n min-height: 48px;\n flex-direction: row;\n justify-content: flex-start;\n font-size: inherit;\n font-weight: 500;\n line-height: 16px;\n text-align: left;\n border-radius: 3px;\n}\n.tooltip--data__item-container {\n max-height: 220px;\n overflow: auto;\n}\n.tooltip__button--data:first-child {\n margin-top: 0;\n}\n.tooltip__button--data:last-child {\n margin-bottom: 0;\n}\n.tooltip__button--data::before {\n content: '';\n flex-shrink: 0;\n display: block;\n width: 32px;\n height: 32px;\n margin: 0 8px;\n background-size: 20px 20px;\n background-repeat: no-repeat;\n background-position: center center;\n}\n#provider_locked::after {\n position: absolute;\n content: '';\n flex-shrink: 0;\n display: block;\n width: 32px;\n height: 32px;\n margin: 0 8px;\n background-size: 11px 13px;\n background-repeat: no-repeat;\n background-position: right bottom;\n}\n.tooltip__button--data.currentFocus:not(.tooltip__button--data--bitwarden)::before,\n.wrapper:not(.top-autofill) .tooltip__button--data:not(.tooltip__button--data--bitwarden):hover::before {\n filter: invert(100%);\n}\n@media (prefers-color-scheme: dark) {\n .tooltip__button--data:not(.tooltip__button--data--bitwarden)::before,\n .tooltip__button--data:not(.tooltip__button--data--bitwarden)::before {\n filter: invert(100%);\n opacity: .9;\n }\n}\n.tooltip__button__text-container {\n margin: auto 0;\n}\n.label {\n display: block;\n font-weight: 400;\n letter-spacing: -0.25px;\n color: rgba(0,0,0,.8);\n font-size: 13px;\n line-height: 1;\n}\n.label + .label {\n margin-top: 2px;\n}\n.label.label--medium {\n font-weight: 500;\n letter-spacing: -0.25px;\n color: rgba(0,0,0,.9);\n}\n.label.label--small {\n font-size: 11px;\n font-weight: 400;\n letter-spacing: 0.06px;\n color: rgba(0,0,0,0.6);\n}\n@media (prefers-color-scheme: dark) {\n .tooltip--data .label {\n color: #ffffff;\n }\n .tooltip--data .label--medium {\n color: #ffffff;\n }\n .tooltip--data .label--small {\n color: #cdcdcd;\n }\n}\n.tooltip__button.currentFocus .label,\n.wrapper:not(.top-autofill) .tooltip__button:hover .label {\n color: #FFFFFF;\n}\n\n.tooltip__button--manage {\n font-size: 13px;\n padding: 5px 9px;\n border-radius: 3px;\n margin: 0;\n}\n\n/* Icons */\n.tooltip__button--data--credentials::before,\n.tooltip__button--data--credentials__current::before {\n background-size: 28px 28px;\n background-image: url('data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8cGF0aCBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNS4zMzQgNi42NjdhMiAyIDAgMSAwIDAgNCAyIDIgMCAwIDAgMC00Wm0tLjY2NyAyYS42NjcuNjY3IDAgMSAxIDEuMzMzIDAgLjY2Ny42NjcgMCAwIDEtMS4zMzMgMFoiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMTQuNjY3IDRhNS4zMzMgNS4zMzMgMCAwIDAtNS4xODggNi41NzhsLTUuMjg0IDUuMjg0YS42NjcuNjY3IDAgMCAwLS4xOTUuNDcxdjNjMCAuMzY5LjI5OC42NjcuNjY3LjY2N2gyLjY2NmMuNzM3IDAgMS4zMzQtLjU5NyAxLjMzNC0xLjMzM1YxOGguNjY2Yy43MzcgMCAxLjMzNC0uNTk3IDEuMzM0LTEuMzMzdi0xLjMzNEgxMmMuMTc3IDAgLjM0Ni0uMDcuNDcxLS4xOTVsLjY4OC0uNjg4QTUuMzMzIDUuMzMzIDAgMSAwIDE0LjY2NyA0Wm0tNCA1LjMzM2E0IDQgMCAxIDEgMi41NTUgMy43MzIuNjY3LjY2NyAwIDAgMC0uNzEzLjE1bC0uNzg1Ljc4NUgxMGEuNjY3LjY2NyAwIDAgMC0uNjY3LjY2N3YySDhhLjY2Ny42NjcgMCAwIDAtLjY2Ny42NjZ2MS4zMzRoLTJ2LTIuMDU4bDUuMzY1LTUuMzY0YS42NjcuNjY3IDAgMCAwIC4xNjMtLjY3NyAzLjk5NiAzLjk5NiAwIDAgMS0uMTk0LTEuMjM1WiIgY2xpcC1ydWxlPSJldmVub2RkIi8+Cjwvc3ZnPgo=');\n}\n.tooltip__button--data--credentials__new::before {\n background-size: 28px 28px;\n background-image: url('data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8cGF0aCBmaWxsPSIjMDAwIiBkPSJNOC4wNDcgNC42MjVDNy45MzcgNC4xMjUgNy44NjIgNCA3LjUgNGMtLjM2MiAwLS40MzguMTI1LS41NDcuNjI1LS4wNjguMzEtLjE3NyAxLjMzOC0uMjUxIDIuMDc3LS43MzguMDc0LTEuNzY3LjE4My0yLjA3Ny4yNTEtLjUuMTEtLjYyNS4xODQtLjYyNS41NDcgMCAuMzYyLjEyNS40MzcuNjI1LjU0Ny4zMS4wNjcgMS4zMzYuMTc3IDIuMDc0LjI1LjA3My43NjcuMTg1IDEuODQyLjI1NCAyLjA3OC4xMS4zNzUuMTg1LjYyNS41NDcuNjI1LjM2MiAwIC40MzgtLjEyNS41NDctLjYyNS4wNjgtLjMxLjE3Ny0xLjMzNi4yNS0yLjA3NC43NjctLjA3MyAxLjg0Mi0uMTg1IDIuMDc4LS4yNTQuMzc1LS4xMS42MjUtLjE4NS42MjUtLjU0NyAwLS4zNjMtLjEyNS0uNDM4LS42MjUtLjU0Ny0uMzEtLjA2OC0xLjMzOS0uMTc3LTIuMDc3LS4yNTEtLjA3NC0uNzM5LS4xODMtMS43NjctLjI1MS0yLjA3N1oiLz4KICAgIDxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik0xNC42ODEgNS4xOTljLS43NjYgMC0xLjQ4Mi4yMS0yLjA5My41NzhhLjYzNi42MzYgMCAwIDEtLjY1NS0xLjA5IDUuMzQgNS4zNCAwIDEgMSAxLjMwMiA5LjcyMmwtLjc3NS43NzZhLjYzNi42MzYgMCAwIDEtLjQ1LjE4NmgtMS4zOTh2MS42NWMwIC40OTMtLjQuODkzLS44OTMuODkzSDguNTc4djEuMTQxYzAgLjQ5NC0uNC44OTMtLjg5NC44OTNINC42MzZBLjYzNi42MzYgMCAwIDEgNCAxOS4zMTNWMTYuMjZjMC0uMTY5LjA2Ny0uMzMuMTg2LS40NWw1LjU2Mi01LjU2MmEuNjM2LjYzNiAwIDEgMSAuOS45bC01LjM3NiA1LjM3NXYyLjE1M2gyLjAzNHYtMS4zOTljMC0uMzUuMjg1LS42MzYuNjM2LS42MzZIOS4zNHYtMS45MDdjMC0uMzUxLjI4NC0uNjM2LjYzNS0uNjM2aDEuNzcxbC44NjQtLjg2M2EuNjM2LjYzNiAwIDAgMSAuNjY4LS4xNDcgNC4wNjkgNC4wNjkgMCAxIDAgMS40MDItNy44OVoiLz4KICAgIDxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTEzLjYyNSA4LjQ5OWExLjg3NSAxLjg3NSAwIDEgMSAzLjc1IDAgMS44NzUgMS44NzUgMCAwIDEtMy43NSAwWm0xLjg3NS0uNjI1YS42MjUuNjI1IDAgMSAwIDAgMS4yNS42MjUuNjI1IDAgMCAwIDAtMS4yNVoiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZD0iTTQuNjI1IDEyLjEyNWEuNjI1LjYyNSAwIDEgMCAwLTEuMjUuNjI1LjYyNSAwIDAgMCAwIDEuMjVaIi8+Cjwvc3ZnPgo=');\n}\n.tooltip__button--data--creditCards::before {\n background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+CiAgICA8cGF0aCBkPSJNNSA5Yy0uNTUyIDAtMSAuNDQ4LTEgMXYyYzAgLjU1Mi40NDggMSAxIDFoM2MuNTUyIDAgMS0uNDQ4IDEtMXYtMmMwLS41NTItLjQ0OC0xLTEtMUg1eiIgZmlsbD0iIzAwMCIvPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xIDZjMC0yLjIxIDEuNzktNCA0LTRoMTRjMi4yMSAwIDQgMS43OSA0IDR2MTJjMCAyLjIxLTEuNzkgNC00IDRINWMtMi4yMSAwLTQtMS43OS00LTRWNnptNC0yYy0xLjEwNSAwLTIgLjg5NS0yIDJ2OWgxOFY2YzAtMS4xMDUtLjg5NS0yLTItMkg1em0wIDE2Yy0xLjEwNSAwLTItLjg5NS0yLTJoMThjMCAxLjEwNS0uODk1IDItMiAySDV6IiBmaWxsPSIjMDAwIi8+Cjwvc3ZnPgo=');\n}\n.tooltip__button--data--identities::before {\n background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+CiAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTEyIDIxYzIuMTQzIDAgNC4xMTEtLjc1IDUuNjU3LTItLjYyNi0uNTA2LTEuMzE4LS45MjctMi4wNi0xLjI1LTEuMS0uNDgtMi4yODUtLjczNS0zLjQ4Ni0uNzUtMS4yLS4wMTQtMi4zOTIuMjExLTMuNTA0LjY2NC0uODE3LjMzMy0xLjU4Ljc4My0yLjI2NCAxLjMzNiAxLjU0NiAxLjI1IDMuNTE0IDIgNS42NTcgMnptNC4zOTctNS4wODNjLjk2Ny40MjIgMS44NjYuOTggMi42NzIgMS42NTVDMjAuMjc5IDE2LjAzOSAyMSAxNC4xMDQgMjEgMTJjMC00Ljk3LTQuMDMtOS05LTlzLTkgNC4wMy05IDljMCAyLjEwNC43MjIgNC4wNCAxLjkzMiA1LjU3Mi44NzQtLjczNCAxLjg2LTEuMzI4IDIuOTIxLTEuNzYgMS4zNi0uNTU0IDIuODE2LS44MyA0LjI4My0uODExIDEuNDY3LjAxOCAyLjkxNi4zMyA0LjI2LjkxNnpNMTIgMjNjNi4wNzUgMCAxMS00LjkyNSAxMS0xMVMxOC4wNzUgMSAxMiAxIDEgNS45MjUgMSAxMnM0LjkyNSAxMSAxMSAxMXptMy0xM2MwIDEuNjU3LTEuMzQzIDMtMyAzcy0zLTEuMzQzLTMtMyAxLjM0My0zIDMtMyAzIDEuMzQzIDMgM3ptMiAwYzAgMi43NjEtMi4yMzkgNS01IDVzLTUtMi4yMzktNS01IDIuMjM5LTUgNS01IDUgMi4yMzkgNSA1eiIgZmlsbD0iIzAwMCIvPgo8L3N2Zz4=');\n}\n.tooltip__button--data--credentials.tooltip__button--data--bitwarden::before,\n.tooltip__button--data--credentials__current.tooltip__button--data--bitwarden::before {\n background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iOCIgZmlsbD0iIzE3NUREQyIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE4LjU2OTYgNS40MzM1NUMxOC41MDg0IDUuMzc0NDIgMTguNDM0NyA1LjMyNzYzIDE4LjM1MzEgNS4yOTYxMUMxOC4yNzE1IDUuMjY0NiAxOC4xODM3IDUuMjQ5MDQgMTguMDk1MyA1LjI1MDQxSDUuOTIxOTFDNS44MzMyNiA1LjI0NzI5IDUuNzQ0OTMgNS4yNjIwNSA1LjY2MzA0IDUuMjkzNjdDNS41ODExNSA1LjMyNTI5IDUuNTA3NjUgNS4zNzMwMiA1LjQ0NzYyIDUuNDMzNTVDNS4zMjE3IDUuNTUwMTMgNS4yNTA2NSA1LjcwODE1IDUuMjUgNS44NzMxVjEzLjM4MjFDNS4yNTMzNiAxMy45NTM1IDUuMzc0MDggMTQuNTE5MSA1LjYwNTcyIDE1LjA0ODdDNS44MTkzMSAxNS41NzI4IDYuMTEyMDcgMTYuMDY2MSA2LjQ3NTI0IDE2LjUxMzlDNi44NDIgMTYuOTY4MyA3LjI1OTI5IDE3LjM4NTcgNy43MjAyNSAxNy43NTkzQzguMTQwNTMgMTguMTI1NiA4LjU4OTcxIDE4LjQ2MjMgOS4wNjQwNyAxOC43NjY2QzkuNDU5MzEgMTkuMDIzIDkuOTEzODMgMTkuMjc5NCAxMC4zNDg2IDE5LjUxNzVDMTAuNzgzNCAxOS43NTU2IDExLjA5OTYgMTkuOTIwNCAxMS4yNzc0IDE5Ljk5MzdDMTEuNDU1MyAyMC4wNjY5IDExLjYxMzQgMjAuMTQwMiAxMS43MTIyIDIwLjE5NTFDMTEuNzk5MiAyMC4yMzEzIDExLjg5MzUgMjAuMjUgMTEuOTg4OCAyMC4yNUMxMi4wODQyIDIwLjI1IDEyLjE3ODUgMjAuMjMxMyAxMi4yNjU1IDIwLjE5NTFDMTIuNDIxMiAyMC4xMzYzIDEyLjU3MjkgMjAuMDY5IDEyLjcyIDE5Ljk5MzdDMTIuNzcxMSAxOS45Njc0IDEyLjgzMzUgMTkuOTM2NiAxMi45MDY5IDE5LjkwMDRDMTMuMDg5MSAxOS44MTA1IDEzLjMzODggMTkuNjg3MiAxMy42NDg5IDE5LjUxNzVDMTQuMDgzNiAxOS4yNzk0IDE0LjUxODQgMTkuMDIzIDE0LjkzMzQgMTguNzY2NkMxNS40MDQgMTguNDU3NyAxNS44NTI4IDE4LjEyMTIgMTYuMjc3MiAxNy43NTkzQzE2LjczMzEgMTcuMzgwOSAxNy4xNDk5IDE2Ljk2NCAxNy41MjIyIDE2LjUxMzlDMTcuODc4IDE2LjA2MTcgMTguMTcwMiAxNS41NjkzIDE4LjM5MTcgMTUuMDQ4N0MxOC42MjM0IDE0LjUxOTEgMTguNzQ0MSAxMy45NTM1IDE4Ljc0NzQgMTMuMzgyMVY1Ljg3MzFDMTguNzU1NyA1Ljc5MjE0IDE4Ljc0MzkgNS43MTA1IDE4LjcxMzEgNS42MzQzNUMxOC42ODIzIDUuNTU4MiAxOC42MzMyIDUuNDg5NTQgMTguNTY5NiA1LjQzMzU1Wk0xNy4wMDg0IDEzLjQ1NTNDMTcuMDA4NCAxNi4xODQyIDEyLjAwODYgMTguNTI4NSAxMi4wMDg2IDE4LjUyODVWNi44NjIwOUgxNy4wMDg0VjEzLjQ1NTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K');\n}\n#provider_locked:after {\n background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTEiIGhlaWdodD0iMTMiIHZpZXdCb3g9IjAgMCAxMSAxMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEgNy42MDA1N1Y3LjYwMjVWOS41MjI1QzEgMTAuMDgwMSAxLjIyMTUxIDEwLjYxNDkgMS42MTU4MSAxMS4wMDkyQzIuMDEwMSAxMS40MDM1IDIuNTQ0ODggMTEuNjI1IDMuMTAyNSAxMS42MjVINy4yNzI1QzcuNTQ4NjEgMTEuNjI1IDcuODIyMDEgMTEuNTcwNiA4LjA3NzA5IDExLjQ2NUM4LjMzMjE4IDExLjM1OTMgOC41NjM5NiAxMS4yMDQ0IDguNzU5MTkgMTEuMDA5MkM4Ljk1NDQzIDEwLjgxNCA5LjEwOTMgMTAuNTgyMiA5LjIxNDk2IDEwLjMyNzFDOS4zMjA2MiAxMC4wNzIgOS4zNzUgOS43OTg2MSA5LjM3NSA5LjUyMjVMOS4zNzUgNy42MDI1TDkuMzc1IDcuNjAwNTdDOS4zNzQxNSA3LjE2MTMxIDkuMjM1NzQgNi43MzMzNSA4Ljk3OTIyIDYuMzc2NzhDOC44NzY4MyA2LjIzNDQ2IDguNzU3NjggNi4xMDYzNyA4LjYyNSA1Ljk5NDg5VjUuMTg3NUM4LjYyNSA0LjI3NTgyIDguMjYyODQgMy40MDE0OCA3LjYxODE4IDIuNzU2ODJDNi45NzM1MiAyLjExMjE2IDYuMDk5MTggMS43NSA1LjE4NzUgMS43NUM0LjI3NTgyIDEuNzUgMy40MDE0OCAyLjExMjE2IDIuNzU2ODIgMi43NTY4MkMyLjExMjE2IDMuNDAxNDggMS43NSA0LjI3NTgyIDEuNzUgNS4xODc1VjUuOTk0ODlDMS42MTczMiA2LjEwNjM3IDEuNDk4MTcgNi4yMzQ0NiAxLjM5NTc4IDYuMzc2NzhDMS4xMzkyNiA2LjczMzM1IDEuMDAwODUgNy4xNjEzMSAxIDcuNjAwNTdaTTQuOTY4NyA0Ljk2ODdDNS4wMjY5NCA0LjkxMDQ3IDUuMTA1MzIgNC44NzY5OSA1LjE4NzUgNC44NzUwN0M1LjI2OTY4IDQuODc2OTkgNS4zNDgwNiA0LjkxMDQ3IDUuNDA2MyA0Ljk2ODdDNS40NjU0MiA1LjAyNzgzIDUuNDk5MDQgNS4xMDc3NCA1LjUgNS4xOTEzVjUuNUg0Ljg3NVY1LjE5MTNDNC44NzU5NiA1LjEwNzc0IDQuOTA5NTggNS4wMjc4MyA0Ljk2ODcgNC45Njg3WiIgZmlsbD0iIzIyMjIyMiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=');\n}\n\nhr {\n display: block;\n margin: 5px 9px;\n border: none; /* reset the border */\n border-top: 1px solid rgba(0,0,0,.1);\n}\n\nhr:first-child {\n display: none;\n}\n\n@media (prefers-color-scheme: dark) {\n hr {\n border-top: 1px solid rgba(255,255,255,.2);\n }\n}\n\n#privateAddress {\n align-items: flex-start;\n}\n#personalAddress::before,\n#privateAddress::before,\n#incontextSignup::before,\n#personalAddress.currentFocus::before,\n#personalAddress:hover::before,\n#privateAddress.currentFocus::before,\n#privateAddress:hover::before {\n filter: none;\n /* This is the same icon as `daxBase64` in `src/Form/logo-svg.js` */\n background-image: url('data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiB2aWV3Qm94PSIwIDAgMTI4IDEyOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTY0IDEyOGMzNS4zNDYgMCA2NC0yOC42NTQgNjQtNjRzLTI4LjY1NC02NC02NC02NC02NCAyOC42NTQtNjQgNjQgMjguNjU0IDY0IDY0IDY0eiIgZmlsbD0iI2RlNTgzMyIgZmlsbC1ydWxlPSJldmVub2RkIi8+CiAgICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im03MyAxMTEuNzVjMC0uNS4xMjMtLjYxNC0xLjQ2Ni0zLjc4Mi00LjIyNC04LjQ1OS04LjQ3LTIwLjM4NC02LjU0LTI4LjA3NS4zNTMtMS4zOTctMy45NzgtNTEuNzQ0LTcuMDQtNTMuMzY1LTMuNDAyLTEuODEzLTcuNTg4LTQuNjktMTEuNDE4LTUuMzMtMS45NDMtLjMxLTQuNDktLjE2NC02LjQ4Mi4xMDUtLjM1My4wNDctLjM2OC42ODMtLjAzLjc5OCAxLjMwOC40NDMgMi44OTUgMS4yMTIgMy44MyAyLjM3NS4xNzguMjItLjA2LjU2Ni0uMzQyLjU3Ny0uODgyLjAzMi0yLjQ4Mi40MDItNC41OTMgMi4xOTUtLjI0NC4yMDctLjA0MS41OTIuMjczLjUzIDQuNTM2LS44OTcgOS4xNy0uNDU1IDExLjkgMi4wMjcuMTc3LjE2LjA4NC40NS0uMTQ3LjUxMi0yMy42OTQgNi40NC0xOS4wMDMgMjcuMDUtMTIuNjk2IDUyLjM0NCA1LjYxOSAyMi41MyA3LjczMyAyOS43OTIgOC40IDMyLjAwNGEuNzE4LjcxOCAwIDAgMCAuNDIzLjQ2N2M4LjE1NiAzLjI0OCAyNS45MjggMy4zOTIgMjUuOTI4LTIuMTMyeiIgZmlsbD0iI2RkZCIgZmlsbC1ydWxlPSJldmVub2RkIi8+CiAgICA8cGF0aCBkPSJtNzYuMjUgMTE2LjVjLTIuODc1IDEuMTI1LTguNSAxLjYyNS0xMS43NSAxLjYyNS00Ljc2NCAwLTExLjYyNS0uNzUtMTQuMTI1LTEuODc1LTEuNTQ0LTQuNzUxLTYuMTY0LTE5LjQ4LTEwLjcyNy0zOC4xODVsLS40NDctMS44MjctLjAwNC0uMDE1Yy01LjQyNC0yMi4xNTctOS44NTUtNDAuMjUzIDE0LjQyNy00NS45MzguMjIyLS4wNTIuMzMtLjMxNy4xODQtLjQ5Mi0yLjc4Ni0zLjMwNS04LjAwNS00LjM4OC0xNC42MDUtMi4xMTEtLjI3LjA5My0uNTA2LS4xOC0uMzM3LS40MTIgMS4yOTQtMS43ODMgMy44MjMtMy4xNTUgNS4wNzEtMy43NTYuMjU4LS4xMjQuMjQyLS41MDItLjAzLS41ODhhMjcuODc3IDI3Ljg3NyAwIDAgMCAtMy43NzItLjljLS4zNy0uMDU5LS40MDMtLjY5My0uMDMyLS43NDMgOS4zNTYtMS4yNTkgMTkuMTI1IDEuNTUgMjQuMDI4IDcuNzI2YS4zMjYuMzI2IDAgMCAwIC4xODYuMTE0YzE3Ljk1MiAzLjg1NiAxOS4yMzggMzIuMjM1IDE3LjE3IDMzLjUyOC0uNDA4LjI1NS0xLjcxNS4xMDgtMy40MzgtLjA4NS02Ljk4Ni0uNzgxLTIwLjgxOC0yLjMyOS05LjQwMiAxOC45NDguMTEzLjIxLS4wMzYuNDg4LS4yNzIuNTI1LTYuNDM4IDEgMS44MTIgMjEuMTczIDcuODc1IDM0LjQ2MXoiIGZpbGw9IiNmZmYiLz4KICAgIDxwYXRoIGQ9Im04NC4yOCA5MC42OThjLTEuMzY3LS42MzMtNi42MjEgMy4xMzUtMTAuMTEgNi4wMjgtLjcyOC0xLjAzMS0yLjEwMy0xLjc4LTUuMjAzLTEuMjQyLTIuNzEzLjQ3Mi00LjIxMSAxLjEyNi00Ljg4IDIuMjU0LTQuMjgzLTEuNjIzLTExLjQ4OC00LjEzLTEzLjIyOS0xLjcxLTEuOTAyIDIuNjQ2LjQ3NiAxNS4xNjEgMy4wMDMgMTYuNzg2IDEuMzIuODQ5IDcuNjMtMy4yMDggMTAuOTI2LTYuMDA1LjUzMi43NDkgMS4zODggMS4xNzggMy4xNDggMS4xMzcgMi42NjItLjA2MiA2Ljk3OS0uNjgxIDcuNjQ5LTEuOTIxLjA0LS4wNzUuMDc1LS4xNjQuMTA1LS4yNjYgMy4zODggMS4yNjYgOS4zNSAyLjYwNiAxMC42ODIgMi40MDYgMy40Ny0uNTIxLS40ODQtMTYuNzIzLTIuMDktMTcuNDY3eiIgZmlsbD0iIzNjYTgyYiIvPgogICAgPHBhdGggZD0ibTc0LjQ5IDk3LjA5N2MuMTQ0LjI1Ni4yNi41MjYuMzU4LjguNDgzIDEuMzUyIDEuMjcgNS42NDguNjc0IDYuNzA5LS41OTUgMS4wNjItNC40NTkgMS41NzQtNi44NDMgMS42MTVzLTIuOTItLjgzMS0zLjQwMy0yLjE4MWMtLjM4Ny0xLjA4MS0uNTc3LTMuNjIxLS41NzItNS4wNzUtLjA5OC0yLjE1OC42OS0yLjkxNiA0LjMzNC0zLjUwNiAyLjY5Ni0uNDM2IDQuMTIxLjA3MSA0Ljk0NC45NCAzLjgyOC0yLjg1NyAxMC4yMTUtNi44ODkgMTAuODM4LTYuMTUyIDMuMTA2IDMuNjc0IDMuNDk5IDEyLjQyIDIuODI2IDE1LjkzOS0uMjIgMS4xNTEtMTAuNTA1LTEuMTM5LTEwLjUwNS0yLjM4IDAtNS4xNTItMS4zMzctNi41NjUtMi42NS02Ljcxem0tMjIuNTMtMS42MDljLjg0My0xLjMzMyA3LjY3NC4zMjUgMTEuNDI0IDEuOTkzIDAgMC0uNzcgMy40OTEuNDU2IDcuNjA0LjM1OSAxLjIwMy04LjYyNyA2LjU1OC05LjggNS42MzctMS4zNTUtMS4wNjUtMy44NS0xMi40MzItMi4wOC0xNS4yMzR6IiBmaWxsPSIjNGNiYTNjIi8+CiAgICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im01NS4yNjkgNjguNDA2Yy41NTMtMi40MDMgMy4xMjctNi45MzIgMTIuMzIxLTYuODIyIDQuNjQ4LS4wMTkgMTAuNDIyLS4wMDIgMTQuMjUtLjQzNmE1MS4zMTIgNTEuMzEyIDAgMCAwIDEyLjcyNi0zLjA5NWMzLjk4LTEuNTE5IDUuMzkyLTEuMTggNS44ODctLjI3Mi41NDQuOTk5LS4wOTcgMi43MjItMS40ODggNC4zMDktMi42NTYgMy4wMy03LjQzMSA1LjM4LTE1Ljg2NSA2LjA3Ni04LjQzMy42OTgtMTQuMDItMS41NjUtMTYuNDI1IDIuMTE4LTEuMDM4IDEuNTg5LS4yMzYgNS4zMzMgNy45MiA2LjUxMiAxMS4wMiAxLjU5IDIwLjA3Mi0xLjkxNyAyMS4xOS4yMDEgMS4xMTkgMi4xMTgtNS4zMjMgNi40MjgtMTYuMzYyIDYuNTE4cy0xNy45MzQtMy44NjUtMjAuMzc5LTUuODNjLTMuMTAyLTIuNDk1LTQuNDktNi4xMzMtMy43NzUtOS4yNzl6IiBmaWxsPSIjZmMzIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KICAgIDxnIGZpbGw9IiMxNDMwN2UiIG9wYWNpdHk9Ii44Ij4KICAgICAgPHBhdGggZD0ibTY5LjMyNyA0Mi4xMjdjLjYxNi0xLjAwOCAxLjk4MS0xLjc4NiA0LjIxNi0xLjc4NiAyLjIzNCAwIDMuMjg1Ljg4OSA0LjAxMyAxLjg4LjE0OC4yMDItLjA3Ni40NC0uMzA2LjM0YTU5Ljg2OSA1OS44NjkgMCAwIDEgLS4xNjgtLjA3M2MtLjgxNy0uMzU3LTEuODItLjc5NS0zLjU0LS44Mi0xLjgzOC0uMDI2LTIuOTk3LjQzNS0zLjcyNy44MzEtLjI0Ni4xMzQtLjYzNC0uMTMzLS40ODgtLjM3MnptLTI1LjE1NyAxLjI5YzIuMTctLjkwNyAzLjg3Ni0uNzkgNS4wODEtLjUwNC4yNTQuMDYuNDMtLjIxMy4yMjctLjM3Ny0uOTM1LS43NTUtMy4wMy0xLjY5Mi01Ljc2LS42NzQtMi40MzcuOTA5LTMuNTg1IDIuNzk2LTMuNTkyIDQuMDM4LS4wMDIuMjkyLjYuMzE3Ljc1Ni4wNy40Mi0uNjcgMS4xMi0xLjY0NiAzLjI4OS0yLjU1M3oiLz4KICAgICAgPHBhdGggY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtNzUuNDQgNTUuOTJhMy40NyAzLjQ3IDAgMCAxIC0zLjQ3NC0zLjQ2MiAzLjQ3IDMuNDcgMCAwIDEgMy40NzUtMy40NiAzLjQ3IDMuNDcgMCAwIDEgMy40NzQgMy40NiAzLjQ3IDMuNDcgMCAwIDEgLTMuNDc1IDMuNDYyem0yLjQ0Ny00LjYwOGEuODk5Ljg5OSAwIDAgMCAtMS43OTkgMGMwIC40OTQuNDA1Ljg5NS45Ljg5NS40OTkgMCAuOS0uNC45LS44OTV6bS0yNS40NjQgMy41NDJhNC4wNDIgNC4wNDIgMCAwIDEgLTQuMDQ5IDQuMDM3IDQuMDQ1IDQuMDQ1IDAgMCAxIC00LjA1LTQuMDM3IDQuMDQ1IDQuMDQ1IDAgMCAxIDQuMDUtNC4wMzcgNC4wNDUgNC4wNDUgMCAwIDEgNC4wNSA0LjAzN3ptLTEuMTkzLTEuMzM4YTEuMDUgMS4wNSAwIDAgMCAtMi4wOTcgMCAxLjA0OCAxLjA0OCAwIDAgMCAyLjA5NyAweiIgZmlsbC1ydWxlPSJldmVub2RkIi8+CiAgICA8L2c+CiAgICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im02NCAxMTcuNzVjMjkuNjg1IDAgNTMuNzUtMjQuMDY1IDUzLjc1LTUzLjc1cy0yNC4wNjUtNTMuNzUtNTMuNzUtNTMuNzUtNTMuNzUgMjQuMDY1LTUzLjc1IDUzLjc1IDI0LjA2NSA1My43NSA1My43NSA1My43NXptMCA1YzMyLjQ0NyAwIDU4Ljc1LTI2LjMwMyA1OC43NS01OC43NXMtMjYuMzAzLTU4Ljc1LTU4Ljc1LTU4Ljc1LTU4Ljc1IDI2LjMwMy01OC43NSA1OC43NSAyNi4zMDMgNTguNzUgNTguNzUgNTguNzV6IiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KPC9zdmc+');\n}\n\n/* Email tooltip specific */\n.tooltip__button--email {\n flex-direction: column;\n justify-content: center;\n align-items: flex-start;\n font-size: 14px;\n padding: 4px 8px;\n}\n.tooltip__button--email__primary-text {\n font-weight: bold;\n}\n.tooltip__button--email__secondary-text {\n font-size: 12px;\n}\n\n/* Email Protection signup notice */\n:not(.top-autofill) .tooltip--email-signup {\n text-align: left;\n color: #222222;\n padding: 16px 20px;\n width: 380px;\n}\n\n.tooltip--email-signup h1 {\n font-weight: 700;\n font-size: 16px;\n line-height: 1.5;\n margin: 0;\n}\n\n.tooltip--email-signup p {\n font-weight: 400;\n font-size: 14px;\n line-height: 1.4;\n}\n\n.notice-controls {\n display: flex;\n}\n\n.tooltip--email-signup .notice-controls > * {\n border-radius: 8px;\n border: 0;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-style: normal;\n font-weight: bold;\n padding: 8px 12px;\n text-decoration: none;\n}\n\n.notice-controls .ghost {\n margin-left: 1rem;\n}\n\n.tooltip--email-signup a.primary {\n background: #3969EF;\n color: #fff;\n}\n\n.tooltip--email-signup a.primary:hover,\n.tooltip--email-signup a.primary:focus {\n background: #2b55ca;\n}\n\n.tooltip--email-signup a.primary:active {\n background: #1e42a4;\n}\n\n.tooltip--email-signup button.ghost {\n background: transparent;\n color: #3969EF;\n}\n\n.tooltip--email-signup button.ghost:hover,\n.tooltip--email-signup button.ghost:focus {\n background-color: rgba(0, 0, 0, 0.06);\n color: #2b55ca;\n}\n\n.tooltip--email-signup button.ghost:active {\n background-color: rgba(0, 0, 0, 0.12);\n color: #1e42a4;\n}\n\n.tooltip--email-signup button.close-tooltip {\n background-color: transparent;\n background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTMiIHZpZXdCb3g9IjAgMCAxMiAxMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0wLjI5Mjg5NCAwLjY1NjkwN0MwLjY4MzQxOCAwLjI2NjM4MyAxLjMxNjU4IDAuMjY2MzgzIDEuNzA3MTEgMC42NTY5MDdMNiA0Ljk0OThMMTAuMjkyOSAwLjY1NjkwN0MxMC42ODM0IDAuMjY2MzgzIDExLjMxNjYgMC4yNjYzODMgMTEuNzA3MSAwLjY1NjkwN0MxMi4wOTc2IDEuMDQ3NDMgMTIuMDk3NiAxLjY4MDYgMTEuNzA3MSAyLjA3MTEyTDcuNDE0MjEgNi4zNjQwMUwxMS43MDcxIDEwLjY1NjlDMTIuMDk3NiAxMS4wNDc0IDEyLjA5NzYgMTEuNjgwNiAxMS43MDcxIDEyLjA3MTFDMTEuMzE2NiAxMi40NjE2IDEwLjY4MzQgMTIuNDYxNiAxMC4yOTI5IDEyLjA3MTFMNiA3Ljc3ODIzTDEuNzA3MTEgMTIuMDcxMUMxLjMxNjU4IDEyLjQ2MTYgMC42ODM0MTcgMTIuNDYxNiAwLjI5Mjg5MyAxMi4wNzExQy0wLjA5NzYzMTEgMTEuNjgwNiAtMC4wOTc2MzExIDExLjA0NzQgMC4yOTI4OTMgMTAuNjU2OUw0LjU4NTc5IDYuMzY0MDFMMC4yOTI4OTQgMi4wNzExMkMtMC4wOTc2MzA2IDEuNjgwNiAtMC4wOTc2MzA2IDEuMDQ3NDMgMC4yOTI4OTQgMC42NTY5MDdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg0Ii8+Cjwvc3ZnPgo=);\n background-position: center center;\n background-repeat: no-repeat;\n border: 0;\n cursor: pointer;\n padding: 16px;\n position: absolute;\n right: 12px;\n top: 12px;\n}\n\n/* Import promotion prompt icon style */\n\n.tooltip__button--credentials-import::before {\n content: \"\";\n background-image: url(data:image/svg+xml;base64,Cjxzdmcgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03Ljk3MjY5IDkuMDEzN0M3Ljk3MjY5IDUuMTQwMTQgMTEuMTEyOCAyIDE0Ljk4NjQgMkMxOC44NTk5IDIgMjIuMDAwMSA1LjE0MDE0IDIyLjAwMDEgOS4wMTM3QzIyLjAwMDEgOS44MDM5NyAyMS44Njk0IDEwLjU2MzcgMjEuNjI4NCAxMS4yNzI1QzIxLjQ2MDEgMTEuNzY3NiAyMC44NjU2IDExLjkxMzUgMjAuMzk3NyAxMS42OEMxOS45MjIxIDExLjQ0MjggMTkuNjc3OSAxMC44NjQ0IDE5LjgxOTUgMTAuMzUyQzE5LjkzNzIgOS45MjU5OSAyMC4wMDAxIDkuNDc3MTkgMjAuMDAwMSA5LjAxMzdDMjAuMDAwMSA2LjI0NDcxIDE3Ljc1NTQgNCAxNC45ODY0IDRDMTIuMjE3NCA0IDkuOTcyNjkgNi4yNDQ3MSA5Ljk3MjY5IDkuMDEzN0M5Ljk3MjY5IDkuNTU1NCAxMC4wNTgyIDEwLjA3NTIgMTAuMjE1OCAxMC41NjEzQzEwLjMzMTcgMTAuOTE4OCAxMC4yMzc0IDExLjMxMTEgOS45NzE2NSAxMS41NzY4TDMuOTk5OTQgMTcuNTQ4NVYyMC4wMDAyTDYuNDk5OTggMjAuMDAwMlYxOC4zMDAyQzYuNDk5OTggMTcuNzQ3OSA2Ljk0NzcgMTcuMzAwMiA3LjQ5OTk4IDE3LjMwMDJIOS4yMDAwMVYxNS42MDAxQzkuMjAwMDEgMTUuMDQ3OCA5LjY0NzczIDE0LjYwMDEgMTAuMiAxNC42MDAxSDEwLjVDMTEuMjEwNiAxNC41ODcgMTEuNDI1MiAxNS4zMzY5IDExLjMxNTYgMTUuNzMzMUMxMS4xNTY1IDE2LjMwODUgMTEgMTcuMDAxIDExIDE3LjVDMTEgMTcuNzI4NCAxMS4wMTE4IDE3Ljk1NCAxMS4wMzQ4IDE4LjE3NjNDMTAuNzM3OSAxOC44Mzg3IDEwLjA3MjkgMTkuMzAwMiA5LjMgMTkuMzAwMkg4LjQ5OTk4VjIwLjEwMDJDOC40OTk5OCAyMS4xNDk1IDcuNjQ5MzMgMjIuMDAwMiA2LjU5OTk5IDIyLjAwMDJMMi45OTk5NSAyMi4wMDAyQzIuNzM0NzMgMjIuMDAwMiAyLjQ4MDM3IDIxLjg5NDkgMi4yOTI4NCAyMS43MDczQzIuMTA1MyAyMS41MTk4IDEuOTk5OTQgMjEuMjY1NCAxLjk5OTk0IDIxLjAwMDJWMTcuMTM0M0MxLjk5OTk0IDE2Ljg2OTEgMi4xMDUzIDE2LjYxNDcgMi4yOTI4MyAxNi40MjcyTDguMTQ2ODkgMTAuNTczMUM4LjAzMjc5IDEwLjA3MDkgNy45NzI2OSA5LjU0ODgxIDcuOTcyNjkgOS4wMTM3WiIgZmlsbD0iYmxhY2siIC8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTMgOC40OTk4OUMxMyA3LjExOTE4IDE0LjExOTMgNS45OTk4OSAxNS41IDUuOTk5ODlDMTYuODgwNyA1Ljk5OTg5IDE4IDcuMTE5MTggMTggOC40OTk4OUMxOCA5Ljg4MDYgMTYuODgwNyAxMC45OTk5IDE1LjUgMTAuOTk5OUMxNC4xMTkzIDEwLjk5OTkgMTMgOS44ODA2IDEzIDguNDk5ODlaTTE1LjUgNy45OTk4OUMxNS4yMjM5IDcuOTk5ODkgMTUgOC4yMjM3NSAxNSA4LjQ5OTg5QzE1IDguNzc2MDQgMTUuMjIzOSA4Ljk5OTg5IDE1LjUgOC45OTk4OUMxNS43NzYyIDguOTk5ODkgMTYgOC43NzYwNCAxNiA4LjQ5OTg5QzE2IDguMjIzNzUgMTUuNzc2MiA3Ljk5OTg5IDE1LjUgNy45OTk4OVoiIGZpbGw9ImJsYWNrIiAvPgo8cGF0aCBkPSJNMTcgMTVMMTQuNSAxNy41TDE3IDIwIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMjEuNSAxNy41TDE2IDE3LjUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=);\n background-repeat: no-repeat;\n}\n"; -},{}],64:[function(require,module,exports){ +},{}],65:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -17492,7 +17626,7 @@ function findEnclosedElements(root, selector) { return shadowElements; } -},{"./Form/matching.js":44,"./constants.js":67,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],65:[function(require,module,exports){ +},{"./Form/matching.js":45,"./constants.js":68,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],66:[function(require,module,exports){ "use strict"; require("./requestIdleCallback.js"); @@ -17523,7 +17657,7 @@ var _autofillUtils = require("./autofill-utils.js"); } })(); -},{"./DeviceInterface.js":23,"./autofill-utils.js":64,"./requestIdleCallback.js":104}],66:[function(require,module,exports){ +},{"./DeviceInterface.js":24,"./autofill-utils.js":65,"./requestIdleCallback.js":105}],67:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -17540,6 +17674,10 @@ const DDG_DOMAIN_REGEX = exports.DDG_DOMAIN_REGEX = new RegExp(/^https:\/\/(([a- * @returns {GlobalConfig} */ function createGlobalConfig(overrides) { + /** + * Defines whether it's one of our desktop apps + * @type {boolean} + */ let isApp = false; let isTopFrame = false; let supportsTopFrame = false; @@ -17609,7 +17747,7 @@ function createGlobalConfig(overrides) { return config; } -},{}],67:[function(require,module,exports){ +},{}],68:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -17626,13 +17764,13 @@ const constants = exports.constants = { MAX_FORM_RESCANS: 50 }; -},{}],68:[function(require,module,exports){ +},{}],69:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.StoreFormDataCall = exports.StartEmailProtectionSignupCall = exports.StartCredentialsImportFlowCall = exports.ShowInContextEmailProtectionSignupPromptCall = exports.SetSizeCall = exports.SetIncontextSignupPermanentlyDismissedAtCall = exports.SendJSPixelCall = exports.SelectedDetailCall = exports.OpenManagePasswordsCall = exports.OpenManageIdentitiesCall = exports.OpenManageCreditCardsCall = exports.GetRuntimeConfigurationCall = exports.GetIncontextSignupDismissedAtCall = exports.GetAvailableInputTypesCall = exports.GetAutofillInitDataCall = exports.GetAutofillDataCall = exports.GetAutofillCredentialsCall = exports.EmailProtectionStoreUserDataCall = exports.EmailProtectionRemoveUserDataCall = exports.EmailProtectionRefreshPrivateAddressCall = exports.EmailProtectionGetUserDataCall = exports.EmailProtectionGetIsLoggedInCall = exports.EmailProtectionGetCapabilitiesCall = exports.EmailProtectionGetAddressesCall = exports.CloseEmailProtectionTabCall = exports.CloseAutofillParentCall = exports.CheckCredentialsProviderStatusCall = exports.AskToUnlockProviderCall = exports.AddDebugFlagCall = void 0; +exports.StoreFormDataCall = exports.StartEmailProtectionSignupCall = exports.StartCredentialsImportFlowCall = exports.ShowInContextEmailProtectionSignupPromptCall = exports.SetSizeCall = exports.SetIncontextSignupPermanentlyDismissedAtCall = exports.SendJSPixelCall = exports.SelectedDetailCall = exports.OpenManagePasswordsCall = exports.OpenManageIdentitiesCall = exports.OpenManageCreditCardsCall = exports.GetRuntimeConfigurationCall = exports.GetIncontextSignupDismissedAtCall = exports.GetAvailableInputTypesCall = exports.GetAutofillInitDataCall = exports.GetAutofillDataCall = exports.GetAutofillCredentialsCall = exports.EmailProtectionStoreUserDataCall = exports.EmailProtectionRemoveUserDataCall = exports.EmailProtectionRefreshPrivateAddressCall = exports.EmailProtectionGetUserDataCall = exports.EmailProtectionGetIsLoggedInCall = exports.EmailProtectionGetCapabilitiesCall = exports.EmailProtectionGetAliasCall = exports.EmailProtectionGetAddressesCall = exports.CloseEmailProtectionTabCall = exports.CloseAutofillParentCall = exports.CheckCredentialsProviderStatusCall = exports.AskToUnlockProviderCall = exports.AddDebugFlagCall = void 0; var _validatorsZod = require("./validators.zod.js"); var _deviceApi = require("../../../packages/device-api"); /* DO NOT EDIT, this file was generated by scripts/api-call-generator.js */ @@ -17794,9 +17932,19 @@ class StartCredentialsImportFlowCall extends _deviceApi.DeviceApiCall { method = "startCredentialsImportFlow"; } /** - * @extends {DeviceApiCall} + * @extends {DeviceApiCall} */ exports.StartCredentialsImportFlowCall = StartCredentialsImportFlowCall; +class EmailProtectionGetAliasCall extends _deviceApi.DeviceApiCall { + method = "emailProtectionGetAlias"; + id = "emailProtectionGetAliasResponse"; + paramsValidator = _validatorsZod.emailProtectionGetAliasParamsSchema; + resultValidator = _validatorsZod.emailProtectionGetAliasResultSchema; +} +/** + * @extends {DeviceApiCall} + */ +exports.EmailProtectionGetAliasCall = EmailProtectionGetAliasCall; class EmailProtectionStoreUserDataCall extends _deviceApi.DeviceApiCall { method = "emailProtectionStoreUserData"; id = "emailProtectionStoreUserDataResponse"; @@ -17879,13 +18027,13 @@ class ShowInContextEmailProtectionSignupPromptCall extends _deviceApi.DeviceApiC } exports.ShowInContextEmailProtectionSignupPromptCall = ShowInContextEmailProtectionSignupPromptCall; -},{"../../../packages/device-api":12,"./validators.zod.js":69}],69:[function(require,module,exports){ +},{"../../../packages/device-api":12,"./validators.zod.js":70}],70:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.userPreferencesSchema = exports.triggerContextSchema = exports.storeFormDataSchema = exports.showInContextEmailProtectionSignupPromptSchema = exports.setSizeParamsSchema = exports.setIncontextSignupPermanentlyDismissedAtSchema = exports.sendJSPixelParamsSchema = exports.selectedDetailParamsSchema = exports.runtimeConfigurationSchema = exports.providerStatusUpdatedSchema = exports.outgoingCredentialsSchema = exports.getRuntimeConfigurationResponseSchema = exports.getIncontextSignupDismissedAtSchema = exports.getAvailableInputTypesResultSchema = exports.getAutofillInitDataResponseSchema = exports.getAutofillDataResponseSchema = exports.getAutofillDataRequestSchema = exports.getAutofillCredentialsResultSchema = exports.getAutofillCredentialsParamsSchema = exports.getAliasResultSchema = exports.getAliasParamsSchema = exports.genericErrorSchema = exports.generatedPasswordSchema = exports.emailProtectionStoreUserDataParamsSchema = exports.emailProtectionRefreshPrivateAddressResultSchema = exports.emailProtectionGetUserDataResultSchema = exports.emailProtectionGetIsLoggedInResultSchema = exports.emailProtectionGetCapabilitiesResultSchema = exports.emailProtectionGetAddressesResultSchema = exports.credentialsSchema = exports.contentScopeSchema = exports.checkCredentialsProviderStatusResultSchema = exports.availableInputTypesSchema = exports.availableInputTypes1Schema = exports.autofillSettingsSchema = exports.autofillFeatureTogglesSchema = exports.askToUnlockProviderResultSchema = exports.apiSchema = exports.addDebugFlagParamsSchema = void 0; +exports.userPreferencesSchema = exports.triggerContextSchema = exports.storeFormDataSchema = exports.showInContextEmailProtectionSignupPromptSchema = exports.setSizeParamsSchema = exports.setIncontextSignupPermanentlyDismissedAtSchema = exports.sendJSPixelParamsSchema = exports.selectedDetailParamsSchema = exports.runtimeConfigurationSchema = exports.providerStatusUpdatedSchema = exports.outgoingCredentialsSchema = exports.getRuntimeConfigurationResponseSchema = exports.getIncontextSignupDismissedAtSchema = exports.getAvailableInputTypesResultSchema = exports.getAutofillInitDataResponseSchema = exports.getAutofillDataResponseSchema = exports.getAutofillDataRequestSchema = exports.getAutofillCredentialsResultSchema = exports.getAutofillCredentialsParamsSchema = exports.getAliasResultSchema = exports.getAliasParamsSchema = exports.genericErrorSchema = exports.generatedPasswordSchema = exports.emailProtectionStoreUserDataParamsSchema = exports.emailProtectionRefreshPrivateAddressResultSchema = exports.emailProtectionGetUserDataResultSchema = exports.emailProtectionGetIsLoggedInResultSchema = exports.emailProtectionGetCapabilitiesResultSchema = exports.emailProtectionGetAliasResultSchema = exports.emailProtectionGetAliasParamsSchema = exports.emailProtectionGetAddressesResultSchema = exports.credentialsSchema = exports.contentScopeSchema = exports.checkCredentialsProviderStatusResultSchema = exports.availableInputTypesSchema = exports.availableInputTypes1Schema = exports.autofillSettingsSchema = exports.autofillFeatureTogglesSchema = exports.askToUnlockProviderResultSchema = exports.apiSchema = exports.addDebugFlagParamsSchema = void 0; var _zod = require("zod"); /* DO NOT EDIT, this file was generated by scripts/api-call-generator.js */ // Generated by ts-to-zod @@ -17945,6 +18093,11 @@ const getAliasResultSchema = exports.getAliasResultSchema = _zod.z.object({ alias: _zod.z.string().optional() }) }); +const emailProtectionGetAliasParamsSchema = exports.emailProtectionGetAliasParamsSchema = _zod.z.object({ + requiresUserPermission: _zod.z.boolean(), + shouldConsumeAliasIfProvided: _zod.z.boolean(), + isIncontextSignupAvailable: _zod.z.boolean().optional() +}); const emailProtectionStoreUserDataParamsSchema = exports.emailProtectionStoreUserDataParamsSchema = _zod.z.object({ token: _zod.z.string(), userName: _zod.z.string(), @@ -17999,10 +18152,6 @@ const userPreferencesSchema = exports.userPreferencesSchema = _zod.z.object({ settings: _zod.z.record(_zod.z.unknown()) })) }); -const outgoingCredentialsSchema = exports.outgoingCredentialsSchema = _zod.z.object({ - username: _zod.z.string().optional(), - password: _zod.z.string().optional() -}); const availableInputTypesSchema = exports.availableInputTypesSchema = _zod.z.object({ credentials: _zod.z.object({ username: _zod.z.boolean().optional(), @@ -18035,6 +18184,10 @@ const availableInputTypesSchema = exports.availableInputTypesSchema = _zod.z.obj credentialsProviderStatus: _zod.z.union([_zod.z.literal("locked"), _zod.z.literal("unlocked")]).optional(), credentialsImport: _zod.z.boolean().optional() }); +const outgoingCredentialsSchema = exports.outgoingCredentialsSchema = _zod.z.object({ + username: _zod.z.string().optional(), + password: _zod.z.string().optional() +}); const availableInputTypes1Schema = exports.availableInputTypes1Schema = _zod.z.object({ credentials: _zod.z.object({ username: _zod.z.boolean().optional(), @@ -18067,6 +18220,11 @@ const availableInputTypes1Schema = exports.availableInputTypes1Schema = _zod.z.o credentialsProviderStatus: _zod.z.union([_zod.z.literal("locked"), _zod.z.literal("unlocked")]).optional(), credentialsImport: _zod.z.boolean().optional() }); +const providerStatusUpdatedSchema = exports.providerStatusUpdatedSchema = _zod.z.object({ + status: _zod.z.union([_zod.z.literal("locked"), _zod.z.literal("unlocked")]), + credentials: _zod.z.array(credentialsSchema), + availableInputTypes: availableInputTypesSchema +}); const autofillFeatureTogglesSchema = exports.autofillFeatureTogglesSchema = _zod.z.object({ inputType_credentials: _zod.z.boolean().optional(), inputType_identities: _zod.z.boolean().optional(), @@ -18102,7 +18260,7 @@ const storeFormDataSchema = exports.storeFormDataSchema = _zod.z.object({ }); const getAvailableInputTypesResultSchema = exports.getAvailableInputTypesResultSchema = _zod.z.object({ type: _zod.z.literal("getAvailableInputTypesResponse").optional(), - success: availableInputTypesSchema, + success: availableInputTypes1Schema, error: genericErrorSchema.optional() }); const getAutofillInitDataResponseSchema = exports.getAutofillInitDataResponseSchema = _zod.z.object({ @@ -18125,9 +18283,25 @@ const getAutofillCredentialsResultSchema = exports.getAutofillCredentialsResultS }).optional(), error: genericErrorSchema.optional() }); +const askToUnlockProviderResultSchema = exports.askToUnlockProviderResultSchema = _zod.z.object({ + type: _zod.z.literal("askToUnlockProviderResponse").optional(), + success: providerStatusUpdatedSchema, + error: genericErrorSchema.optional() +}); +const checkCredentialsProviderStatusResultSchema = exports.checkCredentialsProviderStatusResultSchema = _zod.z.object({ + type: _zod.z.literal("checkCredentialsProviderStatusResponse").optional(), + success: providerStatusUpdatedSchema, + error: genericErrorSchema.optional() +}); const autofillSettingsSchema = exports.autofillSettingsSchema = _zod.z.object({ featureToggles: autofillFeatureTogglesSchema }); +const emailProtectionGetAliasResultSchema = exports.emailProtectionGetAliasResultSchema = _zod.z.object({ + success: _zod.z.object({ + alias: _zod.z.string() + }).optional(), + error: genericErrorSchema.optional() +}); const emailProtectionGetIsLoggedInResultSchema = exports.emailProtectionGetIsLoggedInResultSchema = _zod.z.object({ success: _zod.z.boolean().optional(), error: genericErrorSchema.optional() @@ -18165,28 +18339,14 @@ const emailProtectionRefreshPrivateAddressResultSchema = exports.emailProtection const runtimeConfigurationSchema = exports.runtimeConfigurationSchema = _zod.z.object({ contentScope: contentScopeSchema, userUnprotectedDomains: _zod.z.array(_zod.z.string()), - userPreferences: userPreferencesSchema -}); -const providerStatusUpdatedSchema = exports.providerStatusUpdatedSchema = _zod.z.object({ - status: _zod.z.union([_zod.z.literal("locked"), _zod.z.literal("unlocked")]), - credentials: _zod.z.array(credentialsSchema), - availableInputTypes: availableInputTypes1Schema + userPreferences: userPreferencesSchema, + availableInputTypes: availableInputTypesSchema.optional() }); const getRuntimeConfigurationResponseSchema = exports.getRuntimeConfigurationResponseSchema = _zod.z.object({ type: _zod.z.literal("getRuntimeConfigurationResponse").optional(), success: runtimeConfigurationSchema.optional(), error: genericErrorSchema.optional() }); -const askToUnlockProviderResultSchema = exports.askToUnlockProviderResultSchema = _zod.z.object({ - type: _zod.z.literal("askToUnlockProviderResponse").optional(), - success: providerStatusUpdatedSchema, - error: genericErrorSchema.optional() -}); -const checkCredentialsProviderStatusResultSchema = exports.checkCredentialsProviderStatusResultSchema = _zod.z.object({ - type: _zod.z.literal("checkCredentialsProviderStatusResponse").optional(), - success: providerStatusUpdatedSchema, - error: genericErrorSchema.optional() -}); const apiSchema = exports.apiSchema = _zod.z.object({ addDebugFlag: _zod.z.record(_zod.z.unknown()).and(_zod.z.object({ paramsValidator: addDebugFlagParamsSchema.optional() @@ -18254,6 +18414,11 @@ const apiSchema = exports.apiSchema = _zod.z.object({ openManageCreditCards: _zod.z.record(_zod.z.unknown()).optional(), openManageIdentities: _zod.z.record(_zod.z.unknown()).optional(), startCredentialsImportFlow: _zod.z.record(_zod.z.unknown()).optional(), + emailProtectionGetAlias: _zod.z.record(_zod.z.unknown()).and(_zod.z.object({ + id: _zod.z.literal("emailProtectionGetAliasResponse").optional(), + paramsValidator: emailProtectionGetAliasParamsSchema.optional(), + resultValidator: emailProtectionGetAliasResultSchema.optional() + })).optional(), emailProtectionStoreUserData: _zod.z.record(_zod.z.unknown()).and(_zod.z.object({ id: _zod.z.literal("emailProtectionStoreUserDataResponse").optional(), paramsValidator: emailProtectionStoreUserDataParamsSchema.optional() @@ -18287,7 +18452,7 @@ const apiSchema = exports.apiSchema = _zod.z.object({ })).optional() }); -},{"zod":9}],70:[function(require,module,exports){ +},{"zod":9}],71:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -18313,7 +18478,7 @@ class GetAlias extends _index.DeviceApiCall { } exports.GetAlias = GetAlias; -},{"../../packages/device-api/index.js":12,"./__generated__/validators.zod.js":69}],71:[function(require,module,exports){ +},{"../../packages/device-api/index.js":12,"./__generated__/validators.zod.js":70}],72:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -18321,7 +18486,8 @@ Object.defineProperty(exports, "__esModule", { }); exports.AndroidTransport = void 0; var _index = require("../../../packages/device-api/index.js"); -var _deviceApiCalls = require("../__generated__/deviceApiCalls.js"); +var _messaging = require("../../../packages/messaging/messaging.js"); +var _android = require("../../../packages/messaging/android.js"); class AndroidTransport extends _index.DeviceApiTransport { /** @type {GlobalConfig} */ config; @@ -18330,133 +18496,39 @@ class AndroidTransport extends _index.DeviceApiTransport { constructor(globalConfig) { super(); this.config = globalConfig; - if (this.config.isDDGTestMode) { - if (typeof window.BrowserAutofill?.getAutofillData !== 'function') { - console.warn('window.BrowserAutofill.getAutofillData missing'); - } - if (typeof window.BrowserAutofill?.storeFormData !== 'function') { - console.warn('window.BrowserAutofill.storeFormData missing'); - } - } + const messageHandlerNames = ['EmailProtectionStoreUserData', 'EmailProtectionRemoveUserData', 'EmailProtectionGetUserData', 'EmailProtectionGetCapabilities', 'EmailProtectionGetAlias', 'SetIncontextSignupPermanentlyDismissedAt', 'StartEmailProtectionSignup', 'CloseEmailProtectionTab', 'ShowInContextEmailProtectionSignupPrompt', 'StoreFormData', 'GetIncontextSignupDismissedAt', 'GetRuntimeConfiguration', 'GetAutofillData']; + const androidMessagingConfig = new _android.AndroidMessagingConfig({ + messageHandlerNames + }); + this.messaging = new _messaging.Messaging(androidMessagingConfig); } /** * @param {import("../../../packages/device-api").DeviceApiCall} deviceApiCall * @returns {Promise} */ async send(deviceApiCall) { - if (deviceApiCall instanceof _deviceApiCalls.GetRuntimeConfigurationCall) { - return androidSpecificRuntimeConfiguration(this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.GetAvailableInputTypesCall) { - return androidSpecificAvailableInputTypes(this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.GetIncontextSignupDismissedAtCall) { - window.BrowserAutofill.getIncontextSignupDismissedAt(JSON.stringify(deviceApiCall.params)); - return waitForResponse(deviceApiCall.id, this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.SetIncontextSignupPermanentlyDismissedAtCall) { - return window.BrowserAutofill.setIncontextSignupPermanentlyDismissedAt(JSON.stringify(deviceApiCall.params)); - } - if (deviceApiCall instanceof _deviceApiCalls.StartEmailProtectionSignupCall) { - return window.BrowserAutofill.startEmailProtectionSignup(JSON.stringify(deviceApiCall.params)); - } - if (deviceApiCall instanceof _deviceApiCalls.CloseEmailProtectionTabCall) { - return window.BrowserAutofill.closeEmailProtectionTab(JSON.stringify(deviceApiCall.params)); - } - if (deviceApiCall instanceof _deviceApiCalls.ShowInContextEmailProtectionSignupPromptCall) { - window.BrowserAutofill.showInContextEmailProtectionSignupPrompt(JSON.stringify(deviceApiCall.params)); - return waitForResponse(deviceApiCall.id, this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.GetAutofillDataCall) { - window.BrowserAutofill.getAutofillData(JSON.stringify(deviceApiCall.params)); - return waitForResponse(deviceApiCall.id, this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.StoreFormDataCall) { - return window.BrowserAutofill.storeFormData(JSON.stringify(deviceApiCall.params)); - } - throw new Error('android: not implemented: ' + deviceApiCall.method); - } -} - -/** - * @param {string} expectedResponse - the name/id of the response - * @param {GlobalConfig} config - * @returns {Promise<*>} - */ -exports.AndroidTransport = AndroidTransport; -function waitForResponse(expectedResponse, config) { - return new Promise(resolve => { - const handler = e => { - if (!config.isDDGTestMode) { - if (e.origin !== '') { - return; - } - } - if (!e.data) { - return; - } - if (typeof e.data !== 'string') { - if (config.isDDGTestMode) { - console.log('❌ event.data was not a string. Expected a string so that it can be JSON parsed'); - } - return; + try { + // if the call has an `id`, it means that it expects a response + if (deviceApiCall.id) { + return await this.messaging.request(deviceApiCall.method, deviceApiCall.params || undefined); + } else { + return this.messaging.notify(deviceApiCall.method, deviceApiCall.params || undefined); } - try { - let data = JSON.parse(e.data); - if (data.type === expectedResponse) { - window.removeEventListener('message', handler); - return resolve(data); - } - if (config.isDDGTestMode) { - console.log(`❌ event.data.type was '${data.type}', which didnt match '${expectedResponse}'`, JSON.stringify(data)); - } - } catch (e) { - window.removeEventListener('message', handler); - if (config.isDDGTestMode) { - console.log('❌ Could not JSON.parse the response'); + } catch (e) { + if (e instanceof _messaging.MissingHandler) { + if (this.config.isDDGTestMode) { + console.log('MissingAndroidHandler error for:', deviceApiCall.method); } + throw new Error('unimplemented handler: ' + deviceApiCall.method); + } else { + throw e; } - }; - window.addEventListener('message', handler); - }); -} - -/** - * @param {GlobalConfig} globalConfig - * @returns {{success: import('../__generated__/validators-ts').RuntimeConfiguration}} - */ -function androidSpecificRuntimeConfiguration(globalConfig) { - if (!globalConfig.userPreferences) { - throw new Error('globalConfig.userPreferences not supported yet on Android'); - } - return { - success: { - // @ts-ignore - contentScope: globalConfig.contentScope, - // @ts-ignore - userPreferences: globalConfig.userPreferences, - // @ts-ignore - userUnprotectedDomains: globalConfig.userUnprotectedDomains, - // @ts-ignore - availableInputTypes: globalConfig.availableInputTypes } - }; -} - -/** - * @param {GlobalConfig} globalConfig - * @returns {{success: import('../__generated__/validators-ts').AvailableInputTypes}} - */ -function androidSpecificAvailableInputTypes(globalConfig) { - if (!globalConfig.availableInputTypes) { - throw new Error('globalConfig.availableInputTypes not supported yet on Android'); } - return { - success: globalConfig.availableInputTypes - }; } +exports.AndroidTransport = AndroidTransport; -},{"../../../packages/device-api/index.js":12,"../__generated__/deviceApiCalls.js":68}],72:[function(require,module,exports){ +},{"../../../packages/device-api/index.js":12,"../../../packages/messaging/android.js":15,"../../../packages/messaging/messaging.js":16}],73:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -18499,7 +18571,7 @@ class AppleTransport extends _index.DeviceApiTransport { } exports.AppleTransport = AppleTransport; -},{"../../../packages/device-api/index.js":12,"../../../packages/messaging/messaging.js":15}],73:[function(require,module,exports){ +},{"../../../packages/device-api/index.js":12,"../../../packages/messaging/messaging.js":16}],74:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -18653,7 +18725,7 @@ async function extensionSpecificSetIncontextSignupPermanentlyDismissedAtCall(par }); } -},{"../../../packages/device-api/index.js":12,"../../Settings.js":51,"../../autofill-utils.js":64,"../__generated__/deviceApiCalls.js":68}],74:[function(require,module,exports){ +},{"../../../packages/device-api/index.js":12,"../../Settings.js":52,"../../autofill-utils.js":65,"../__generated__/deviceApiCalls.js":69}],75:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -18697,7 +18769,7 @@ function createTransport(globalConfig) { return new _extensionTransport.ExtensionTransport(globalConfig); } -},{"./android.transport.js":71,"./apple.transport.js":72,"./extension.transport.js":73,"./windows.transport.js":75}],75:[function(require,module,exports){ +},{"./android.transport.js":72,"./apple.transport.js":73,"./extension.transport.js":74,"./windows.transport.js":76}],76:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -18782,7 +18854,7 @@ function waitForWindowsResponse(responseId, options) { }); } -},{"../../../packages/device-api/index.js":12}],76:[function(require,module,exports){ +},{"../../../packages/device-api/index.js":12}],77:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -18883,7 +18955,7 @@ module.exports={ } } -},{}],77:[function(require,module,exports){ +},{}],78:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -18984,7 +19056,7 @@ module.exports={ } } -},{}],78:[function(require,module,exports){ +},{}],79:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19085,7 +19157,7 @@ module.exports={ } } -},{}],79:[function(require,module,exports){ +},{}],80:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19186,7 +19258,7 @@ module.exports={ } } -},{}],80:[function(require,module,exports){ +},{}],81:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19287,7 +19359,7 @@ module.exports={ } } -},{}],81:[function(require,module,exports){ +},{}],82:[function(require,module,exports){ module.exports={ "smartling": { "string_format": "icu", @@ -19389,7 +19461,7 @@ module.exports={ } } -},{}],82:[function(require,module,exports){ +},{}],83:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19490,7 +19562,7 @@ module.exports={ } } -},{}],83:[function(require,module,exports){ +},{}],84:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19591,7 +19663,7 @@ module.exports={ } } -},{}],84:[function(require,module,exports){ +},{}],85:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19692,7 +19764,7 @@ module.exports={ } } -},{}],85:[function(require,module,exports){ +},{}],86:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19793,7 +19865,7 @@ module.exports={ } } -},{}],86:[function(require,module,exports){ +},{}],87:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19894,7 +19966,7 @@ module.exports={ } } -},{}],87:[function(require,module,exports){ +},{}],88:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -19995,7 +20067,7 @@ module.exports={ } } -},{}],88:[function(require,module,exports){ +},{}],89:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20096,7 +20168,7 @@ module.exports={ } } -},{}],89:[function(require,module,exports){ +},{}],90:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20197,7 +20269,7 @@ module.exports={ } } -},{}],90:[function(require,module,exports){ +},{}],91:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20298,7 +20370,7 @@ module.exports={ } } -},{}],91:[function(require,module,exports){ +},{}],92:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20399,7 +20471,7 @@ module.exports={ } } -},{}],92:[function(require,module,exports){ +},{}],93:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20500,7 +20572,7 @@ module.exports={ } } -},{}],93:[function(require,module,exports){ +},{}],94:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20601,7 +20673,7 @@ module.exports={ } } -},{}],94:[function(require,module,exports){ +},{}],95:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20702,7 +20774,7 @@ module.exports={ } } -},{}],95:[function(require,module,exports){ +},{}],96:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20803,7 +20875,7 @@ module.exports={ } } -},{}],96:[function(require,module,exports){ +},{}],97:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -20904,7 +20976,7 @@ module.exports={ } } -},{}],97:[function(require,module,exports){ +},{}],98:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -21005,7 +21077,7 @@ module.exports={ } } -},{}],98:[function(require,module,exports){ +},{}],99:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -21106,7 +21178,7 @@ module.exports={ } } -},{}],99:[function(require,module,exports){ +},{}],100:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -21194,7 +21266,7 @@ function translateImpl(library, namespacedId, opts) { return out; } -},{"./translations.js":102}],100:[function(require,module,exports){ +},{"./translations.js":103}],101:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -21295,7 +21367,7 @@ module.exports={ } } -},{}],101:[function(require,module,exports){ +},{}],102:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -21396,7 +21468,7 @@ module.exports={ } } -},{}],102:[function(require,module,exports){ +},{}],103:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -21515,7 +21587,7 @@ var _default = exports.default = { } }; -},{"./bg/autofill.json":76,"./cs/autofill.json":77,"./da/autofill.json":78,"./de/autofill.json":79,"./el/autofill.json":80,"./en/autofill.json":81,"./es/autofill.json":82,"./et/autofill.json":83,"./fi/autofill.json":84,"./fr/autofill.json":85,"./hr/autofill.json":86,"./hu/autofill.json":87,"./it/autofill.json":88,"./lt/autofill.json":89,"./lv/autofill.json":90,"./nb/autofill.json":91,"./nl/autofill.json":92,"./pl/autofill.json":93,"./pt/autofill.json":94,"./ro/autofill.json":95,"./ru/autofill.json":96,"./sk/autofill.json":97,"./sl/autofill.json":98,"./sv/autofill.json":100,"./tr/autofill.json":101,"./xa/autofill.json":103}],103:[function(require,module,exports){ +},{"./bg/autofill.json":77,"./cs/autofill.json":78,"./da/autofill.json":79,"./de/autofill.json":80,"./el/autofill.json":81,"./en/autofill.json":82,"./es/autofill.json":83,"./et/autofill.json":84,"./fi/autofill.json":85,"./fr/autofill.json":86,"./hr/autofill.json":87,"./hu/autofill.json":88,"./it/autofill.json":89,"./lt/autofill.json":90,"./lv/autofill.json":91,"./nb/autofill.json":92,"./nl/autofill.json":93,"./pl/autofill.json":94,"./pt/autofill.json":95,"./ro/autofill.json":96,"./ru/autofill.json":97,"./sk/autofill.json":98,"./sl/autofill.json":99,"./sv/autofill.json":101,"./tr/autofill.json":102,"./xa/autofill.json":104}],104:[function(require,module,exports){ module.exports={ "smartling": { "string_format": "icu", @@ -21608,7 +21680,7 @@ module.exports={ "note": "Button that prevents the DuckDuckGo email protection signup prompt from appearing again." } } -},{}],104:[function(require,module,exports){ +},{}],105:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -21651,4 +21723,4 @@ window.cancelIdleCallback = window.cancelIdleCallback || function (id) { }; var _default = exports.default = {}; -},{}]},{},[65]); +},{}]},{},[66]); diff --git a/node_modules/@duckduckgo/autofill/dist/autofill.js b/node_modules/@duckduckgo/autofill/dist/autofill.js index 2552b407a2e9..e07247a53195 100644 --- a/node_modules/@duckduckgo/autofill/dist/autofill.js +++ b/node_modules/@duckduckgo/autofill/dist/autofill.js @@ -425,6 +425,130 @@ exports.DeviceApi = DeviceApi; },{}],5:[function(require,module,exports){ "use strict"; +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.AndroidMessagingTransport = exports.AndroidMessagingConfig = void 0; +var _messaging = require("./messaging.js"); +/** + * @module Android Messaging + * + * @description A wrapper for messaging on Android. See example usage in android.transport.js + */ + +/** + * @typedef {import("./messaging").MessagingTransport} MessagingTransport + */ + +/** + * On Android, handlers are added to the window object and are prefixed with `ddg`. The object looks like this: + * + * ```typescript + * { + * onMessage: undefined, + * postMessage: (message) => void, + * addEventListener: (eventType: string, Function) => void, + * removeEventListener: (eventType: string, Function) => void + * } + * ``` + * + * You send messages to `postMessage` and listen with `addEventListener`. Once the event is received, + * we also remove the listener with `removeEventListener`. + * + * @link https://developer.android.com/reference/androidx/webkit/WebViewCompat#addWebMessageListener(android.webkit.WebView,java.lang.String,java.util.Set%3Cjava.lang.String%3E,androidx.webkit.WebViewCompat.WebMessageListener) + * @implements {MessagingTransport} + */ +class AndroidMessagingTransport { + /** @type {AndroidMessagingConfig} */ + config; + globals = { + capturedHandlers: {} + }; + /** + * @param {AndroidMessagingConfig} config + */ + constructor(config) { + this.config = config; + } + + /** + * Given the method name, returns the related Android handler + * @param {string} methodName + * @returns {AndroidHandler} + * @private + */ + _getHandler(methodName) { + const androidSpecificName = this._getHandlerName(methodName); + if (!(androidSpecificName in window)) { + throw new _messaging.MissingHandler(`Missing android handler: '${methodName}'`, methodName); + } + return window[androidSpecificName]; + } + + /** + * Given the autofill method name, it returns the Android-specific handler name + * @param {string} internalName + * @returns {string} + * @private + */ + _getHandlerName(internalName) { + return 'ddg' + internalName[0].toUpperCase() + internalName.slice(1); + } + + /** + * @param {string} name + * @param {Record} [data] + */ + notify(name) { + let data = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + const handler = this._getHandler(name); + const message = data ? JSON.stringify(data) : ''; + handler.postMessage(message); + } + + /** + * @param {string} name + * @param {Record} [data] + */ + async request(name) { + let data = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + // Set up the listener first + const handler = this._getHandler(name); + const responseOnce = new Promise(resolve => { + const responseHandler = e => { + handler.removeEventListener('message', responseHandler); + resolve(e.data); + }; + handler.addEventListener('message', responseHandler); + }); + + // Then send the message + this.notify(name, data); + + // And return once the promise resolves + const responseJSON = await responseOnce; + return JSON.parse(responseJSON); + } +} + +/** + * Use this configuration to create an instance of {@link Messaging} for Android + */ +exports.AndroidMessagingTransport = AndroidMessagingTransport; +class AndroidMessagingConfig { + /** + * All the expected Android handler names + * @param {{messageHandlerNames: string[]}} config + */ + constructor(config) { + this.messageHandlerNames = config.messageHandlerNames; + } +} +exports.AndroidMessagingConfig = AndroidMessagingConfig; + +},{"./messaging.js":6}],6:[function(require,module,exports){ +"use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); @@ -436,6 +560,7 @@ Object.defineProperty(exports, "WebkitMessagingConfig", { } }); var _webkit = require("./webkit.js"); +var _android = require("./android.js"); /** * @module Messaging * @@ -494,7 +619,7 @@ var _webkit = require("./webkit.js"); */ class Messaging { /** - * @param {WebkitMessagingConfig} config + * @param {WebkitMessagingConfig | AndroidMessagingConfig} config */ constructor(config) { this.transport = getTransport(config); @@ -566,7 +691,7 @@ class MessagingTransport { } /** - * @param {WebkitMessagingConfig} config + * @param {WebkitMessagingConfig | AndroidMessagingConfig} config * @returns {MessagingTransport} */ exports.MessagingTransport = MessagingTransport; @@ -574,6 +699,9 @@ function getTransport(config) { if (config instanceof _webkit.WebkitMessagingConfig) { return new _webkit.WebkitMessagingTransport(config); } + if (config instanceof _android.AndroidMessagingConfig) { + return new _android.AndroidMessagingTransport(config); + } throw new Error('unreachable'); } @@ -596,7 +724,7 @@ class MissingHandler extends Error { */ exports.MissingHandler = MissingHandler; -},{"./webkit.js":6}],6:[function(require,module,exports){ +},{"./android.js":5,"./webkit.js":7}],7:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -991,7 +1119,7 @@ function captureGlobals() { }; } -},{"./messaging.js":5}],7:[function(require,module,exports){ +},{"./messaging.js":6}],8:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -1122,7 +1250,7 @@ function _safeHostname(inputHostname) { } } -},{"./lib/apple.password.js":8,"./lib/constants.js":9,"./lib/rules-parser.js":10}],8:[function(require,module,exports){ +},{"./lib/apple.password.js":9,"./lib/constants.js":10,"./lib/rules-parser.js":11}],9:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -1651,7 +1779,7 @@ class Password { } exports.Password = Password; -},{"./constants.js":9,"./rules-parser.js":10}],9:[function(require,module,exports){ +},{"./constants.js":10,"./rules-parser.js":11}],10:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -1671,7 +1799,7 @@ const constants = exports.constants = { DEFAULT_UNAMBIGUOUS_CHARS }; -},{}],10:[function(require,module,exports){ +},{}],11:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -2267,7 +2395,7 @@ function parsePasswordRules(input, formatRulesForMinifiedVersion) { return newPasswordRules; } -},{}],11:[function(require,module,exports){ +},{}],12:[function(require,module,exports){ module.exports={ "163.com": { "password-rules": "minlength: 6; maxlength: 16;" @@ -3299,7 +3427,7 @@ module.exports={ "password-rules": "minlength: 8; maxlength: 32; max-consecutive: 6; required: lower; required: upper; required: digit;" } } -},{}],12:[function(require,module,exports){ +},{}],13:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -3363,7 +3491,7 @@ class CredentialsImport { } exports.CredentialsImport = CredentialsImport; -},{"./deviceApiCalls/__generated__/deviceApiCalls.js":58}],13:[function(require,module,exports){ +},{"./deviceApiCalls/__generated__/deviceApiCalls.js":59}],14:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -3419,7 +3547,7 @@ function createDevice() { return new _ExtensionInterface.ExtensionInterface(globalConfig, deviceApi, settings); } -},{"../packages/device-api/index.js":2,"./DeviceInterface/AndroidInterface.js":14,"./DeviceInterface/AppleDeviceInterface.js":15,"./DeviceInterface/AppleOverlayDeviceInterface.js":16,"./DeviceInterface/ExtensionInterface.js":17,"./DeviceInterface/WindowsInterface.js":19,"./DeviceInterface/WindowsOverlayDeviceInterface.js":20,"./Settings.js":41,"./config.js":56,"./deviceApiCalls/transports/transports.js":64}],14:[function(require,module,exports){ +},{"../packages/device-api/index.js":2,"./DeviceInterface/AndroidInterface.js":15,"./DeviceInterface/AppleDeviceInterface.js":16,"./DeviceInterface/AppleOverlayDeviceInterface.js":17,"./DeviceInterface/ExtensionInterface.js":18,"./DeviceInterface/WindowsInterface.js":20,"./DeviceInterface/WindowsOverlayDeviceInterface.js":21,"./Settings.js":42,"./config.js":57,"./deviceApiCalls/transports/transports.js":65}],15:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -3439,25 +3567,35 @@ class AndroidInterface extends _InterfacePrototype.default { * @returns {Promise} */ async getAlias() { - const { - alias - } = await (0, _autofillUtils.sendAndWaitForAnswer)(async () => { - if (this.inContextSignup.isAvailable()) { - const { - isSignedIn - } = await this.deviceApi.request(new _deviceApiCalls.ShowInContextEmailProtectionSignupPromptCall(null)); + // If in-context signup is available, do that first + if (this.inContextSignup.isAvailable()) { + const { + isSignedIn + } = await this.deviceApi.request(new _deviceApiCalls.ShowInContextEmailProtectionSignupPromptCall(null)); + if (isSignedIn) { // On Android we can't get the input type data again without // refreshing the page, so instead we can mutate it now that we // know the user has Email Protection available. - if (this.globalConfig.availableInputTypes) { - this.globalConfig.availableInputTypes.email = isSignedIn; + if (this.settings.availableInputTypes) { + this.settings.setAvailableInputTypes({ + email: isSignedIn + }); } this.updateForStateChange(); this.onFinishedAutofill(); } - return window.EmailInterface.showTooltip(); - }, 'getAliasResponse'); - return alias; + } + // Then, if successful actually prompt to fill + if (this.settings.availableInputTypes.email) { + const { + alias + } = await this.deviceApi.request(new _deviceApiCalls.EmailProtectionGetAliasCall({ + requiresUserPermission: !this.globalConfig.isApp, + shouldConsumeAliasIfProvided: !this.globalConfig.isApp, + isIncontextSignupAvailable: this.inContextSignup.isAvailable() + })); + return alias ? (0, _autofillUtils.formatDuckAddress)(alias) : undefined; + } } /** @@ -3472,14 +3610,9 @@ class AndroidInterface extends _InterfacePrototype.default { * @returns {boolean} */ isDeviceSignedIn() { - // on DDG domains, always check via `window.EmailInterface.isSignedIn()` - if (this.globalConfig.isDDGDomain) { - return window.EmailInterface.isSignedIn() === 'true'; - } - // on non-DDG domains, where `availableInputTypes.email` is present, use it - if (typeof this.globalConfig.availableInputTypes?.email === 'boolean') { - return this.globalConfig.availableInputTypes.email; + if (typeof this.settings.availableInputTypes?.email === 'boolean') { + return this.settings.availableInputTypes.email; } // ...on other domains we assume true because the script wouldn't exist otherwise @@ -3494,15 +3627,7 @@ class AndroidInterface extends _InterfacePrototype.default { * Settings page displays data of the logged in user data */ getUserData() { - let userData = null; - try { - userData = JSON.parse(window.EmailInterface.getUserData()); - } catch (e) { - if (this.globalConfig.isDDGTestMode) { - console.error(e); - } - } - return Promise.resolve(userData); + return this.deviceApi.request(new _deviceApiCalls.EmailProtectionGetUserDataCall({})); } /** @@ -3510,25 +3635,13 @@ class AndroidInterface extends _InterfacePrototype.default { * Device capabilities determine which functionality is available to the user */ getEmailProtectionCapabilities() { - let deviceCapabilities = null; - try { - deviceCapabilities = JSON.parse(window.EmailInterface.getDeviceCapabilities()); - } catch (e) { - if (this.globalConfig.isDDGTestMode) { - console.error(e); - } - } - return Promise.resolve(deviceCapabilities); + return this.deviceApi.request(new _deviceApiCalls.EmailProtectionGetCapabilitiesCall({})); } storeUserData(_ref) { let { - addUserData: { - token, - userName, - cohort - } + addUserData } = _ref; - return window.EmailInterface.storeCredentials(token, userName, cohort); + return this.deviceApi.request(new _deviceApiCalls.EmailProtectionStoreUserDataCall(addUserData)); } /** @@ -3536,13 +3649,7 @@ class AndroidInterface extends _InterfacePrototype.default { * Provides functionality to log the user out */ removeUserData() { - try { - return window.EmailInterface.removeCredentials(); - } catch (e) { - if (this.globalConfig.isDDGTestMode) { - console.error(e); - } - } + return this.deviceApi.request(new _deviceApiCalls.EmailProtectionRemoveUserDataCall({})); } /** @@ -3567,7 +3674,7 @@ class AndroidInterface extends _InterfacePrototype.default { } exports.AndroidInterface = AndroidInterface; -},{"../InContextSignup.js":35,"../UI/controllers/NativeUIController.js":49,"../autofill-utils.js":54,"../deviceApiCalls/__generated__/deviceApiCalls.js":58,"./InterfacePrototype.js":18}],15:[function(require,module,exports){ +},{"../InContextSignup.js":36,"../UI/controllers/NativeUIController.js":50,"../autofill-utils.js":55,"../deviceApiCalls/__generated__/deviceApiCalls.js":59,"./InterfacePrototype.js":19}],16:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -3923,7 +4030,7 @@ class AppleDeviceInterface extends _InterfacePrototype.default { } exports.AppleDeviceInterface = AppleDeviceInterface; -},{"../../packages/device-api/index.js":2,"../Form/matching.js":34,"../InContextSignup.js":35,"../ThirdPartyProvider.js":42,"../UI/HTMLTooltip.js":47,"../UI/controllers/HTMLTooltipUIController.js":48,"../UI/controllers/NativeUIController.js":49,"../UI/controllers/OverlayUIController.js":50,"../autofill-utils.js":54,"../deviceApiCalls/__generated__/deviceApiCalls.js":58,"../deviceApiCalls/additionalDeviceApiCalls.js":60,"./InterfacePrototype.js":18}],16:[function(require,module,exports){ +},{"../../packages/device-api/index.js":2,"../Form/matching.js":35,"../InContextSignup.js":36,"../ThirdPartyProvider.js":43,"../UI/HTMLTooltip.js":48,"../UI/controllers/HTMLTooltipUIController.js":49,"../UI/controllers/NativeUIController.js":50,"../UI/controllers/OverlayUIController.js":51,"../autofill-utils.js":55,"../deviceApiCalls/__generated__/deviceApiCalls.js":59,"../deviceApiCalls/additionalDeviceApiCalls.js":61,"./InterfacePrototype.js":19}],17:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -4042,7 +4149,7 @@ class AppleOverlayDeviceInterface extends _AppleDeviceInterface.AppleDeviceInter } exports.AppleOverlayDeviceInterface = AppleOverlayDeviceInterface; -},{"../../packages/device-api/index.js":2,"../UI/controllers/HTMLTooltipUIController.js":48,"./AppleDeviceInterface.js":15,"./overlayApi.js":22}],17:[function(require,module,exports){ +},{"../../packages/device-api/index.js":2,"../UI/controllers/HTMLTooltipUIController.js":49,"./AppleDeviceInterface.js":16,"./overlayApi.js":23}],18:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -4261,7 +4368,7 @@ class ExtensionInterface extends _InterfacePrototype.default { } exports.ExtensionInterface = ExtensionInterface; -},{"../Form/matching.js":34,"../InContextSignup.js":35,"../UI/HTMLTooltip.js":47,"../UI/controllers/HTMLTooltipUIController.js":48,"../autofill-utils.js":54,"./InterfacePrototype.js":18}],18:[function(require,module,exports){ +},{"../Form/matching.js":35,"../InContextSignup.js":36,"../UI/HTMLTooltip.js":48,"../UI/controllers/HTMLTooltipUIController.js":49,"../autofill-utils.js":55,"./InterfacePrototype.js":19}],19:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -4530,8 +4637,7 @@ class InterfacePrototype { } async startInit() { if (this.isInitializationStarted) return; - this.alreadyInitialized = true; - await this.settings.refresh(); + this.isInitializationStarted = true; this.addDeviceListeners(); await this.setupAutofill(); this.uiController = this.createUIController(); @@ -4865,11 +4971,19 @@ class InterfacePrototype { let userData; try { userData = await this.getUserData(); - } catch (e) {} + } catch (e) { + if (this.isTestMode()) { + console.log('getUserData failed with', e); + } + } let capabilities; try { capabilities = await this.getEmailProtectionCapabilities(); - } catch (e) {} + } catch (e) { + if (this.isTestMode()) { + console.log('capabilities fetching failed with', e); + } + } // Set up listener for web app actions if (this.globalConfig.isDDGDomain) { @@ -4925,6 +5039,13 @@ class InterfacePrototype { const data = await (0, _autofillUtils.sendAndWaitForAnswer)(_autofillUtils.SIGN_IN_MSG, 'addUserData'); // This call doesn't send a response, so we can't know if it succeeded this.storeUserData(data); + + // Assuming the previous call succeeded, let's update availableInputTypes + if (this.settings.availableInputTypes) { + this.settings.setAvailableInputTypes({ + email: true + }); + } await this.setupAutofill(); await this.settings.refresh(); await this.setupSettingsPage({ @@ -5093,7 +5214,7 @@ class InterfacePrototype { } var _default = exports.default = InterfacePrototype; -},{"../../packages/device-api/index.js":2,"../CredentialsImport.js":12,"../EmailProtection.js":23,"../Form/formatters.js":27,"../Form/matching.js":34,"../InputTypes/Credentials.js":36,"../PasswordGenerator.js":39,"../Scanner.js":40,"../Settings.js":41,"../UI/controllers/NativeUIController.js":49,"../autofill-utils.js":54,"../config.js":56,"../deviceApiCalls/__generated__/deviceApiCalls.js":58,"../deviceApiCalls/transports/transports.js":64,"../locales/strings.js":89,"./initFormSubmissionsApi.js":21}],19:[function(require,module,exports){ +},{"../../packages/device-api/index.js":2,"../CredentialsImport.js":13,"../EmailProtection.js":24,"../Form/formatters.js":28,"../Form/matching.js":35,"../InputTypes/Credentials.js":37,"../PasswordGenerator.js":40,"../Scanner.js":41,"../Settings.js":42,"../UI/controllers/NativeUIController.js":50,"../autofill-utils.js":55,"../config.js":57,"../deviceApiCalls/__generated__/deviceApiCalls.js":59,"../deviceApiCalls/transports/transports.js":65,"../locales/strings.js":90,"./initFormSubmissionsApi.js":22}],20:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5178,13 +5299,13 @@ class WindowsInterface extends _InterfacePrototype.default { return await this.credentialsImport.refresh(); } default: - if (this.globalConfig.isDDGTestMode) { + if (this.isTestMode()) { console.warn('unhandled response', resp); } return this._closeAutofillParent(); } } catch (e) { - if (this.globalConfig.isDDGTestMode) { + if (this.isTestMode()) { if (e instanceof DOMException && e.name === 'AbortError') { console.log('Promise Aborted'); } else { @@ -5259,7 +5380,7 @@ class WindowsInterface extends _InterfacePrototype.default { } exports.WindowsInterface = WindowsInterface; -},{"../UI/controllers/OverlayUIController.js":50,"../deviceApiCalls/__generated__/deviceApiCalls.js":58,"./InterfacePrototype.js":18}],20:[function(require,module,exports){ +},{"../UI/controllers/OverlayUIController.js":51,"../deviceApiCalls/__generated__/deviceApiCalls.js":59,"./InterfacePrototype.js":19}],21:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5438,7 +5559,7 @@ class WindowsOverlayDeviceInterface extends _InterfacePrototype.default { } exports.WindowsOverlayDeviceInterface = WindowsOverlayDeviceInterface; -},{"../UI/controllers/HTMLTooltipUIController.js":48,"../deviceApiCalls/__generated__/deviceApiCalls.js":58,"./InterfacePrototype.js":18,"./overlayApi.js":22}],21:[function(require,module,exports){ +},{"../UI/controllers/HTMLTooltipUIController.js":49,"../deviceApiCalls/__generated__/deviceApiCalls.js":59,"./InterfacePrototype.js":19,"./overlayApi.js":23}],22:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5537,7 +5658,7 @@ function initFormSubmissionsApi(forms, matching) { }); } -},{"../Form/label-util.js":30,"../autofill-utils.js":54}],22:[function(require,module,exports){ +},{"../Form/label-util.js":31,"../autofill-utils.js":55}],23:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5595,7 +5716,7 @@ function overlayApi(device) { }; } -},{"../deviceApiCalls/__generated__/deviceApiCalls.js":58}],23:[function(require,module,exports){ +},{"../deviceApiCalls/__generated__/deviceApiCalls.js":59}],24:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5630,7 +5751,7 @@ class EmailProtection { } exports.EmailProtection = EmailProtection; -},{}],24:[function(require,module,exports){ +},{}],25:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -5770,7 +5891,7 @@ class Form { } submitHandler() { let via = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'unknown'; - if (this.device.globalConfig.isDDGTestMode) { + if (this.device.isTestMode()) { console.log('Form.submitHandler via:', via, this); } if (this.submitHandlerExecuted) return; @@ -6537,7 +6658,7 @@ class Form { } exports.Form = Form; -},{"../InputTypes/Credentials.js":36,"../autofill-utils.js":54,"../constants.js":57,"./FormAnalyzer.js":25,"./formatters.js":27,"./inputStyles.js":28,"./inputTypeConfig.js":29,"./matching.js":34}],25:[function(require,module,exports){ +},{"../InputTypes/Credentials.js":37,"../autofill-utils.js":55,"../constants.js":58,"./FormAnalyzer.js":26,"./formatters.js":28,"./inputStyles.js":29,"./inputTypeConfig.js":30,"./matching.js":35}],26:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -6907,7 +7028,7 @@ class FormAnalyzer { } var _default = exports.default = FormAnalyzer; -},{"../autofill-utils.js":54,"../constants.js":57,"./matching-config/__generated__/compiled-matching-config.js":32,"./matching.js":34}],26:[function(require,module,exports){ +},{"../autofill-utils.js":55,"../constants.js":58,"./matching-config/__generated__/compiled-matching-config.js":33,"./matching.js":35}],27:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -7472,7 +7593,7 @@ const COUNTRY_NAMES_TO_CODES = exports.COUNTRY_NAMES_TO_CODES = { 'Unknown Region': 'ZZ' }; -},{}],27:[function(require,module,exports){ +},{}],28:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -7777,7 +7898,7 @@ const prepareFormValuesForStorage = formValues => { }; exports.prepareFormValuesForStorage = prepareFormValuesForStorage; -},{"./countryNames.js":26,"./matching.js":34}],28:[function(require,module,exports){ +},{"./countryNames.js":27,"./matching.js":35}],29:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -7868,7 +7989,7 @@ const getIconStylesAutofilled = (input, form) => { }; exports.getIconStylesAutofilled = getIconStylesAutofilled; -},{"./inputTypeConfig.js":29}],29:[function(require,module,exports){ +},{"./inputTypeConfig.js":30}],30:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8122,7 +8243,7 @@ const isFieldDecorated = input => { }; exports.isFieldDecorated = isFieldDecorated; -},{"../InputTypes/Credentials.js":36,"../InputTypes/CreditCard.js":37,"../InputTypes/Identity.js":38,"../UI/img/ddgPasswordIcon.js":52,"../constants.js":57,"./logo-svg.js":31,"./matching.js":34}],30:[function(require,module,exports){ +},{"../InputTypes/Credentials.js":37,"../InputTypes/CreditCard.js":38,"../InputTypes/Identity.js":39,"../UI/img/ddgPasswordIcon.js":53,"../constants.js":58,"./logo-svg.js":32,"./matching.js":35}],31:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8170,7 +8291,7 @@ const extractElementStrings = element => { }; exports.extractElementStrings = extractElementStrings; -},{"./matching.js":34}],31:[function(require,module,exports){ +},{"./matching.js":35}],32:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8203,7 +8324,7 @@ const daxGrayscaleSvg = ` `.trim(); const daxGrayscaleBase64 = exports.daxGrayscaleBase64 = `data:image/svg+xml;base64,${window.btoa(daxGrayscaleSvg)}`; -},{}],32:[function(require,module,exports){ +},{}],33:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8656,7 +8777,7 @@ const matchingConfiguration = exports.matchingConfiguration = { } }; -},{}],33:[function(require,module,exports){ +},{}],34:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -8731,7 +8852,7 @@ function logUnmatched(el, allStrings) { console.groupEnd(); } -},{"../autofill-utils.js":54,"./matching.js":34}],34:[function(require,module,exports){ +},{"../autofill-utils.js":55,"./matching.js":35}],35:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -9723,7 +9844,7 @@ function createMatching() { return new Matching(_compiledMatchingConfig.matchingConfiguration); } -},{"../autofill-utils.js":54,"../constants.js":57,"./label-util.js":30,"./matching-config/__generated__/compiled-matching-config.js":32,"./matching-utils.js":33}],35:[function(require,module,exports){ +},{"../autofill-utils.js":55,"../constants.js":58,"./label-util.js":31,"./matching-config/__generated__/compiled-matching-config.js":33,"./matching-utils.js":34}],36:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -9855,7 +9976,7 @@ class InContextSignup { } exports.InContextSignup = InContextSignup; -},{"./autofill-utils.js":54,"./deviceApiCalls/__generated__/deviceApiCalls.js":58}],36:[function(require,module,exports){ +},{"./autofill-utils.js":55,"./deviceApiCalls/__generated__/deviceApiCalls.js":59}],37:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -10011,7 +10132,7 @@ function createCredentialsTooltipItem(data) { return new CredentialsTooltipItem(data); } -},{"../autofill-utils.js":54}],37:[function(require,module,exports){ +},{"../autofill-utils.js":55}],38:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -10036,7 +10157,7 @@ class CreditCardTooltipItem { } exports.CreditCardTooltipItem = CreditCardTooltipItem; -},{}],38:[function(require,module,exports){ +},{}],39:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -10082,7 +10203,7 @@ class IdentityTooltipItem { } exports.IdentityTooltipItem = IdentityTooltipItem; -},{"../Form/formatters.js":27}],39:[function(require,module,exports){ +},{"../Form/formatters.js":28}],40:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -10124,7 +10245,7 @@ class PasswordGenerator { } exports.PasswordGenerator = PasswordGenerator; -},{"../packages/password/index.js":7,"../packages/password/rules.json":11}],40:[function(require,module,exports){ +},{"../packages/password/index.js":8,"../packages/password/rules.json":12}],41:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -10585,7 +10706,7 @@ function createScanner(device, scannerOptions) { }); } -},{"./Form/Form.js":24,"./Form/matching.js":34,"./autofill-utils.js":54,"./constants.js":57,"./deviceApiCalls/__generated__/deviceApiCalls.js":58}],41:[function(require,module,exports){ +},{"./Form/Form.js":25,"./Form/matching.js":35,"./autofill-utils.js":55,"./constants.js":58,"./deviceApiCalls/__generated__/deviceApiCalls.js":59}],42:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -10731,6 +10852,11 @@ class Settings { if (this._runtimeConfiguration) return this._runtimeConfiguration; const runtimeConfig = await this.deviceApi.request(new _deviceApiCalls.GetRuntimeConfigurationCall(null)); this._runtimeConfiguration = runtimeConfig; + + // If the platform sends availableInputTypes here, store them + if (runtimeConfig.availableInputTypes) { + this.setAvailableInputTypes(runtimeConfig.availableInputTypes); + } return this._runtimeConfiguration; } @@ -10746,6 +10872,9 @@ class Settings { if (this.globalConfig.isTopFrame) { return Settings.defaults.availableInputTypes; } + if (this._availableInputTypes) { + return this.availableInputTypes; + } return await this.deviceApi.request(new _deviceApiCalls.GetAvailableInputTypesCall(null)); } catch (e) { if (this.globalConfig.isDDGTestMode) { @@ -10998,7 +11127,7 @@ class Settings { } exports.Settings = Settings; -},{"../packages/device-api/index.js":2,"./autofill-utils.js":54,"./deviceApiCalls/__generated__/deviceApiCalls.js":58,"./deviceApiCalls/__generated__/validators.zod.js":59}],42:[function(require,module,exports){ +},{"../packages/device-api/index.js":2,"./autofill-utils.js":55,"./deviceApiCalls/__generated__/deviceApiCalls.js":59,"./deviceApiCalls/__generated__/validators.zod.js":60}],43:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11065,7 +11194,7 @@ class ThirdPartyProvider { this.device.scanner.forms.forEach(form => form.recategorizeAllInputs()); } } catch (e) { - if (this.device.globalConfig.isDDGTestMode) { + if (this.device.isTestMode()) { console.log('isDDGTestMode: providerStatusUpdated error: ❌', e); } } @@ -11080,7 +11209,7 @@ class ThirdPartyProvider { } setTimeout(() => this._pollForUpdatesToCredentialsProvider(), 2000); } catch (e) { - if (this.device.globalConfig.isDDGTestMode) { + if (this.device.isTestMode()) { console.log('isDDGTestMode: _pollForUpdatesToCredentialsProvider: ❌', e); } } @@ -11088,7 +11217,7 @@ class ThirdPartyProvider { } exports.ThirdPartyProvider = ThirdPartyProvider; -},{"../packages/device-api/index.js":2,"./Form/matching.js":34,"./deviceApiCalls/__generated__/deviceApiCalls.js":58,"./deviceApiCalls/__generated__/validators.zod.js":59}],43:[function(require,module,exports){ +},{"../packages/device-api/index.js":2,"./Form/matching.js":35,"./deviceApiCalls/__generated__/deviceApiCalls.js":59,"./deviceApiCalls/__generated__/validators.zod.js":60}],44:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11129,7 +11258,7 @@ ${this.options.css} } var _default = exports.default = CredentialsImportTooltip; -},{"./HTMLTooltip.js":47}],44:[function(require,module,exports){ +},{"./HTMLTooltip.js":48}],45:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11280,7 +11409,7 @@ ${css} } var _default = exports.default = DataHTMLTooltip; -},{"../InputTypes/Credentials.js":36,"../autofill-utils.js":54,"./HTMLTooltip.js":47}],45:[function(require,module,exports){ +},{"../InputTypes/Credentials.js":37,"../autofill-utils.js":55,"./HTMLTooltip.js":48}],46:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11362,7 +11491,7 @@ ${this.options.css} } var _default = exports.default = EmailHTMLTooltip; -},{"../autofill-utils.js":54,"./HTMLTooltip.js":47}],46:[function(require,module,exports){ +},{"../autofill-utils.js":55,"./HTMLTooltip.js":48}],47:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11415,7 +11544,7 @@ ${this.options.css} } var _default = exports.default = EmailSignupHTMLTooltip; -},{"./HTMLTooltip.js":47}],47:[function(require,module,exports){ +},{"./HTMLTooltip.js":48}],48:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -11803,7 +11932,7 @@ class HTMLTooltip { exports.HTMLTooltip = HTMLTooltip; var _default = exports.default = HTMLTooltip; -},{"../Form/matching.js":34,"../autofill-utils.js":54,"./styles/styles.js":53}],48:[function(require,module,exports){ +},{"../Form/matching.js":35,"../autofill-utils.js":55,"./styles/styles.js":54}],49:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12171,7 +12300,7 @@ class HTMLTooltipUIController extends _UIController.UIController { } exports.HTMLTooltipUIController = HTMLTooltipUIController; -},{"../../Form/inputTypeConfig.js":29,"../../Form/matching.js":34,"../../autofill-utils.js":54,"../CredentialsImportTooltip.js":43,"../DataHTMLTooltip.js":44,"../EmailHTMLTooltip.js":45,"../EmailSignupHTMLTooltip.js":46,"../HTMLTooltip.js":47,"./UIController.js":51}],49:[function(require,module,exports){ +},{"../../Form/inputTypeConfig.js":30,"../../Form/matching.js":35,"../../autofill-utils.js":55,"../CredentialsImportTooltip.js":44,"../DataHTMLTooltip.js":45,"../EmailHTMLTooltip.js":46,"../EmailSignupHTMLTooltip.js":47,"../HTMLTooltip.js":48,"./UIController.js":52}],50:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12273,6 +12402,11 @@ class NativeUIController extends _UIController.UIController { form.activeInput?.focus(); break; } + case 'none': + { + // do nothing + break; + } default: { if (args.device.isTestMode()) { @@ -12333,7 +12467,7 @@ class NativeUIController extends _UIController.UIController { } exports.NativeUIController = NativeUIController; -},{"../../Form/matching.js":34,"../../InputTypes/Credentials.js":36,"../../deviceApiCalls/__generated__/deviceApiCalls.js":58,"./UIController.js":51}],50:[function(require,module,exports){ +},{"../../Form/matching.js":35,"../../InputTypes/Credentials.js":37,"../../deviceApiCalls/__generated__/deviceApiCalls.js":59,"./UIController.js":52}],51:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12570,7 +12704,7 @@ class OverlayUIController extends _UIController.UIController { } exports.OverlayUIController = OverlayUIController; -},{"../../Form/matching.js":34,"./UIController.js":51}],51:[function(require,module,exports){ +},{"../../Form/matching.js":35,"./UIController.js":52}],52:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12654,7 +12788,7 @@ class UIController { } exports.UIController = UIController; -},{}],52:[function(require,module,exports){ +},{}],53:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12671,7 +12805,7 @@ const ddgCcIconBase = exports.ddgCcIconBase = 'data:image/svg+xml;base64,PD94bWw const ddgCcIconFilled = exports.ddgCcIconFilled = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+CiAgICA8cGF0aCBkPSJNNSA5Yy0uNTUyIDAtMSAuNDQ4LTEgMXYyYzAgLjU1Mi40NDggMSAxIDFoM2MuNTUyIDAgMS0uNDQ4IDEtMXYtMmMwLS41NTItLjQ0OC0xLTEtMUg1eiIgZmlsbD0iIzc2NDMxMCIvPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xIDZjMC0yLjIxIDEuNzktNCA0LTRoMTRjMi4yMSAwIDQgMS43OSA0IDR2MTJjMCAyLjIxLTEuNzkgNC00IDRINWMtMi4yMSAwLTQtMS43OS00LTRWNnptNC0yYy0xLjEwNSAwLTIgLjg5NS0yIDJ2OWgxOFY2YzAtMS4xMDUtLjg5NS0yLTItMkg1em0wIDE2Yy0xLjEwNSAwLTItLjg5NS0yLTJoMThjMCAxLjEwNS0uODk1IDItMiAySDV6IiBmaWxsPSIjNzY0MzEwIi8+Cjwvc3ZnPgo='; const ddgIdentityIconBase = exports.ddgIdentityIconBase = `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+CiAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTEyIDIxYzIuMTQzIDAgNC4xMTEtLjc1IDUuNjU3LTItLjYyNi0uNTA2LTEuMzE4LS45MjctMi4wNi0xLjI1LTEuMS0uNDgtMi4yODUtLjczNS0zLjQ4Ni0uNzUtMS4yLS4wMTQtMi4zOTIuMjExLTMuNTA0LjY2NC0uODE3LjMzMy0xLjU4Ljc4My0yLjI2NCAxLjMzNiAxLjU0NiAxLjI1IDMuNTE0IDIgNS42NTcgMnptNC4zOTctNS4wODNjLjk2Ny40MjIgMS44NjYuOTggMi42NzIgMS42NTVDMjAuMjc5IDE2LjAzOSAyMSAxNC4xMDQgMjEgMTJjMC00Ljk3LTQuMDMtOS05LTlzLTkgNC4wMy05IDljMCAyLjEwNC43MjIgNC4wNCAxLjkzMiA1LjU3Mi44NzQtLjczNCAxLjg2LTEuMzI4IDIuOTIxLTEuNzYgMS4zNi0uNTU0IDIuODE2LS44MyA0LjI4My0uODExIDEuNDY3LjAxOCAyLjkxNi4zMyA0LjI2LjkxNnpNMTIgMjNjNi4wNzUgMCAxMS00LjkyNSAxMS0xMVMxOC4wNzUgMSAxMiAxIDEgNS45MjUgMSAxMnM0LjkyNSAxMSAxMSAxMXptMy0xM2MwIDEuNjU3LTEuMzQzIDMtMyAzcy0zLTEuMzQzLTMtMyAxLjM0My0zIDMtMyAzIDEuMzQzIDMgM3ptMiAwYzAgMi43NjEtMi4yMzkgNS01IDVzLTUtMi4yMzktNS01IDIuMjM5LTUgNS01IDUgMi4yMzkgNSA1eiIgZmlsbD0iIzAwMCIvPgo8L3N2Zz4KPHBhdGggeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTEyIDIxYzIuMTQzIDAgNC4xMTEtLjc1IDUuNjU3LTItLjYyNi0uNTA2LTEuMzE4LS45MjctMi4wNi0xLjI1LTEuMS0uNDgtMi4yODUtLjczNS0zLjQ4Ni0uNzUtMS4yLS4wMTQtMi4zOTIuMjExLTMuNTA0LjY2NC0uODE3LjMzMy0xLjU4Ljc4My0yLjI2NCAxLjMzNiAxLjU0NiAxLjI1IDMuNTE0IDIgNS42NTcgMnptNC4zOTctNS4wODNjLjk2Ny40MjIgMS44NjYuOTggMi42NzIgMS42NTVDMjAuMjc5IDE2LjAzOSAyMSAxNC4xMDQgMjEgMTJjMC00Ljk3LTQuMDMtOS05LTlzLTkgNC4wMy05IDljMCAyLjEwNC43MjIgNC4wNCAxLjkzMiA1LjU3Mi44NzQtLjczNCAxLjg2LTEuMzI4IDIuOTIxLTEuNzYgMS4zNi0uNTU0IDIuODE2LS44MyA0LjI4My0uODExIDEuNDY3LjAxOCAyLjkxNi4zMyA0LjI2LjkxNnpNMTIgMjNjNi4wNzUgMCAxMS00LjkyNSAxMS0xMVMxOC4wNzUgMSAxMiAxIDEgNS45MjUgMSAxMnM0LjkyNSAxMSAxMSAxMXptMy0xM2MwIDEuNjU3LTEuMzQzIDMtMyAzcy0zLTEuMzQzLTMtMyAxLjM0My0zIDMtMyAzIDEuMzQzIDMgM3ptMiAwYzAgMi43NjEtMi4yMzkgNS01IDVzLTUtMi4yMzktNS01IDIuMjM5LTUgNS01IDUgMi4yMzkgNSA1eiIgZmlsbD0iIzAwMCIvPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSJub25lIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMiAyMWMyLjE0MyAwIDQuMTExLS43NSA1LjY1Ny0yLS42MjYtLjUwNi0xLjMxOC0uOTI3LTIuMDYtMS4yNS0xLjEtLjQ4LTIuMjg1LS43MzUtMy40ODYtLjc1LTEuMi0uMDE0LTIuMzkyLjIxMS0zLjUwNC42NjQtLjgxNy4zMzMtMS41OC43ODMtMi4yNjQgMS4zMzYgMS41NDYgMS4yNSAzLjUxNCAyIDUuNjU3IDJ6bTQuMzk3LTUuMDgzYy45NjcuNDIyIDEuODY2Ljk4IDIuNjcyIDEuNjU1QzIwLjI3OSAxNi4wMzkgMjEgMTQuMTA0IDIxIDEyYzAtNC45Ny00LjAzLTktOS05cy05IDQuMDMtOSA5YzAgMi4xMDQuNzIyIDQuMDQgMS45MzIgNS41NzIuODc0LS43MzQgMS44Ni0xLjMyOCAyLjkyMS0xLjc2IDEuMzYtLjU1NCAyLjgxNi0uODMgNC4yODMtLjgxMSAxLjQ2Ny4wMTggMi45MTYuMzMgNC4yNi45MTZ6TTEyIDIzYzYuMDc1IDAgMTEtNC45MjUgMTEtMTFTMTguMDc1IDEgMTIgMSAxIDUuOTI1IDEgMTJzNC45MjUgMTEgMTEgMTF6bTMtMTNjMCAxLjY1Ny0xLjM0MyAzLTMgM3MtMy0xLjM0My0zLTMgMS4zNDMtMyAzLTMgMyAxLjM0MyAzIDN6bTIgMGMwIDIuNzYxLTIuMjM5IDUtNSA1cy01LTIuMjM5LTUtNSAyLjIzOS01IDUtNSA1IDIuMjM5IDUgNXoiIGZpbGw9IiMwMDAiLz4KPC9zdmc+Cg==`; -},{}],53:[function(require,module,exports){ +},{}],54:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12680,7 +12814,7 @@ Object.defineProperty(exports, "__esModule", { exports.CSS_STYLES = void 0; const CSS_STYLES = exports.CSS_STYLES = ":root {\n color-scheme: light dark;\n}\n\n.wrapper *, .wrapper *::before, .wrapper *::after {\n box-sizing: border-box;\n}\n.wrapper {\n position: fixed;\n top: 0;\n left: 0;\n padding: 0;\n font-family: 'DDG_ProximaNova', 'Proxima Nova', system-ui, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',\n 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\n -webkit-font-smoothing: antialiased;\n z-index: 2147483647;\n}\n.wrapper--data {\n font-family: 'SF Pro Text', system-ui, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',\n 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\n}\n.wrapper:not(.top-autofill) .tooltip {\n position: absolute;\n width: 300px;\n max-width: calc(100vw - 25px);\n transform: translate(-1000px, -1000px);\n z-index: 2147483647;\n}\n.tooltip--data, #topAutofill {\n background-color: rgba(242, 240, 240, 1);\n -webkit-backdrop-filter: blur(40px);\n backdrop-filter: blur(40px);\n}\n@media (prefers-color-scheme: dark) {\n .tooltip--data, #topAutofill {\n background: rgb(100, 98, 102, .9);\n }\n}\n.tooltip--data {\n padding: 6px;\n font-size: 13px;\n line-height: 14px;\n width: 315px;\n max-height: 290px;\n overflow-y: auto;\n}\n.top-autofill .tooltip--data {\n min-height: 100vh;\n}\n.tooltip--data.tooltip--incontext-signup {\n width: 360px;\n}\n.wrapper:not(.top-autofill) .tooltip--data {\n top: 100%;\n left: 100%;\n border: 0.5px solid rgba(255, 255, 255, 0.2);\n border-radius: 6px;\n box-shadow: 0 10px 20px rgba(0, 0, 0, 0.32);\n}\n@media (prefers-color-scheme: dark) {\n .wrapper:not(.top-autofill) .tooltip--data {\n border: 1px solid rgba(255, 255, 255, 0.2);\n }\n}\n.wrapper:not(.top-autofill) .tooltip--email {\n top: calc(100% + 6px);\n right: calc(100% - 48px);\n padding: 8px;\n border: 1px solid #D0D0D0;\n border-radius: 10px;\n background-color: #FFFFFF;\n font-size: 14px;\n line-height: 1.3;\n color: #333333;\n box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);\n}\n.tooltip--email__caret {\n position: absolute;\n transform: translate(-1000px, -1000px);\n z-index: 2147483647;\n}\n.tooltip--email__caret::before,\n.tooltip--email__caret::after {\n content: \"\";\n width: 0;\n height: 0;\n border-left: 10px solid transparent;\n border-right: 10px solid transparent;\n display: block;\n border-bottom: 8px solid #D0D0D0;\n position: absolute;\n right: -28px;\n}\n.tooltip--email__caret::before {\n border-bottom-color: #D0D0D0;\n top: -1px;\n}\n.tooltip--email__caret::after {\n border-bottom-color: #FFFFFF;\n top: 0px;\n}\n\n/* Buttons */\n.tooltip__button {\n display: flex;\n width: 100%;\n padding: 8px 8px 8px 0px;\n font-family: inherit;\n color: inherit;\n background: transparent;\n border: none;\n border-radius: 6px;\n text-align: left;\n}\n.tooltip__button.currentFocus,\n.wrapper:not(.top-autofill) .tooltip__button:hover {\n background-color: #3969EF;\n color: #FFFFFF;\n}\n\n/* Data autofill tooltip specific */\n.tooltip__button--data {\n position: relative;\n min-height: 48px;\n flex-direction: row;\n justify-content: flex-start;\n font-size: inherit;\n font-weight: 500;\n line-height: 16px;\n text-align: left;\n border-radius: 3px;\n}\n.tooltip--data__item-container {\n max-height: 220px;\n overflow: auto;\n}\n.tooltip__button--data:first-child {\n margin-top: 0;\n}\n.tooltip__button--data:last-child {\n margin-bottom: 0;\n}\n.tooltip__button--data::before {\n content: '';\n flex-shrink: 0;\n display: block;\n width: 32px;\n height: 32px;\n margin: 0 8px;\n background-size: 20px 20px;\n background-repeat: no-repeat;\n background-position: center center;\n}\n#provider_locked::after {\n position: absolute;\n content: '';\n flex-shrink: 0;\n display: block;\n width: 32px;\n height: 32px;\n margin: 0 8px;\n background-size: 11px 13px;\n background-repeat: no-repeat;\n background-position: right bottom;\n}\n.tooltip__button--data.currentFocus:not(.tooltip__button--data--bitwarden)::before,\n.wrapper:not(.top-autofill) .tooltip__button--data:not(.tooltip__button--data--bitwarden):hover::before {\n filter: invert(100%);\n}\n@media (prefers-color-scheme: dark) {\n .tooltip__button--data:not(.tooltip__button--data--bitwarden)::before,\n .tooltip__button--data:not(.tooltip__button--data--bitwarden)::before {\n filter: invert(100%);\n opacity: .9;\n }\n}\n.tooltip__button__text-container {\n margin: auto 0;\n}\n.label {\n display: block;\n font-weight: 400;\n letter-spacing: -0.25px;\n color: rgba(0,0,0,.8);\n font-size: 13px;\n line-height: 1;\n}\n.label + .label {\n margin-top: 2px;\n}\n.label.label--medium {\n font-weight: 500;\n letter-spacing: -0.25px;\n color: rgba(0,0,0,.9);\n}\n.label.label--small {\n font-size: 11px;\n font-weight: 400;\n letter-spacing: 0.06px;\n color: rgba(0,0,0,0.6);\n}\n@media (prefers-color-scheme: dark) {\n .tooltip--data .label {\n color: #ffffff;\n }\n .tooltip--data .label--medium {\n color: #ffffff;\n }\n .tooltip--data .label--small {\n color: #cdcdcd;\n }\n}\n.tooltip__button.currentFocus .label,\n.wrapper:not(.top-autofill) .tooltip__button:hover .label {\n color: #FFFFFF;\n}\n\n.tooltip__button--manage {\n font-size: 13px;\n padding: 5px 9px;\n border-radius: 3px;\n margin: 0;\n}\n\n/* Icons */\n.tooltip__button--data--credentials::before,\n.tooltip__button--data--credentials__current::before {\n background-size: 28px 28px;\n background-image: url('data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8cGF0aCBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNS4zMzQgNi42NjdhMiAyIDAgMSAwIDAgNCAyIDIgMCAwIDAgMC00Wm0tLjY2NyAyYS42NjcuNjY3IDAgMSAxIDEuMzMzIDAgLjY2Ny42NjcgMCAwIDEtMS4zMzMgMFoiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMTQuNjY3IDRhNS4zMzMgNS4zMzMgMCAwIDAtNS4xODggNi41NzhsLTUuMjg0IDUuMjg0YS42NjcuNjY3IDAgMCAwLS4xOTUuNDcxdjNjMCAuMzY5LjI5OC42NjcuNjY3LjY2N2gyLjY2NmMuNzM3IDAgMS4zMzQtLjU5NyAxLjMzNC0xLjMzM1YxOGguNjY2Yy43MzcgMCAxLjMzNC0uNTk3IDEuMzM0LTEuMzMzdi0xLjMzNEgxMmMuMTc3IDAgLjM0Ni0uMDcuNDcxLS4xOTVsLjY4OC0uNjg4QTUuMzMzIDUuMzMzIDAgMSAwIDE0LjY2NyA0Wm0tNCA1LjMzM2E0IDQgMCAxIDEgMi41NTUgMy43MzIuNjY3LjY2NyAwIDAgMC0uNzEzLjE1bC0uNzg1Ljc4NUgxMGEuNjY3LjY2NyAwIDAgMC0uNjY3LjY2N3YySDhhLjY2Ny42NjcgMCAwIDAtLjY2Ny42NjZ2MS4zMzRoLTJ2LTIuMDU4bDUuMzY1LTUuMzY0YS42NjcuNjY3IDAgMCAwIC4xNjMtLjY3NyAzLjk5NiAzLjk5NiAwIDAgMS0uMTk0LTEuMjM1WiIgY2xpcC1ydWxlPSJldmVub2RkIi8+Cjwvc3ZnPgo=');\n}\n.tooltip__button--data--credentials__new::before {\n background-size: 28px 28px;\n background-image: url('data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8cGF0aCBmaWxsPSIjMDAwIiBkPSJNOC4wNDcgNC42MjVDNy45MzcgNC4xMjUgNy44NjIgNCA3LjUgNGMtLjM2MiAwLS40MzguMTI1LS41NDcuNjI1LS4wNjguMzEtLjE3NyAxLjMzOC0uMjUxIDIuMDc3LS43MzguMDc0LTEuNzY3LjE4My0yLjA3Ny4yNTEtLjUuMTEtLjYyNS4xODQtLjYyNS41NDcgMCAuMzYyLjEyNS40MzcuNjI1LjU0Ny4zMS4wNjcgMS4zMzYuMTc3IDIuMDc0LjI1LjA3My43NjcuMTg1IDEuODQyLjI1NCAyLjA3OC4xMS4zNzUuMTg1LjYyNS41NDcuNjI1LjM2MiAwIC40MzgtLjEyNS41NDctLjYyNS4wNjgtLjMxLjE3Ny0xLjMzNi4yNS0yLjA3NC43NjctLjA3MyAxLjg0Mi0uMTg1IDIuMDc4LS4yNTQuMzc1LS4xMS42MjUtLjE4NS42MjUtLjU0NyAwLS4zNjMtLjEyNS0uNDM4LS42MjUtLjU0Ny0uMzEtLjA2OC0xLjMzOS0uMTc3LTIuMDc3LS4yNTEtLjA3NC0uNzM5LS4xODMtMS43NjctLjI1MS0yLjA3N1oiLz4KICAgIDxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik0xNC42ODEgNS4xOTljLS43NjYgMC0xLjQ4Mi4yMS0yLjA5My41NzhhLjYzNi42MzYgMCAwIDEtLjY1NS0xLjA5IDUuMzQgNS4zNCAwIDEgMSAxLjMwMiA5LjcyMmwtLjc3NS43NzZhLjYzNi42MzYgMCAwIDEtLjQ1LjE4NmgtMS4zOTh2MS42NWMwIC40OTMtLjQuODkzLS44OTMuODkzSDguNTc4djEuMTQxYzAgLjQ5NC0uNC44OTMtLjg5NC44OTNINC42MzZBLjYzNi42MzYgMCAwIDEgNCAxOS4zMTNWMTYuMjZjMC0uMTY5LjA2Ny0uMzMuMTg2LS40NWw1LjU2Mi01LjU2MmEuNjM2LjYzNiAwIDEgMSAuOS45bC01LjM3NiA1LjM3NXYyLjE1M2gyLjAzNHYtMS4zOTljMC0uMzUuMjg1LS42MzYuNjM2LS42MzZIOS4zNHYtMS45MDdjMC0uMzUxLjI4NC0uNjM2LjYzNS0uNjM2aDEuNzcxbC44NjQtLjg2M2EuNjM2LjYzNiAwIDAgMSAuNjY4LS4xNDcgNC4wNjkgNC4wNjkgMCAxIDAgMS40MDItNy44OVoiLz4KICAgIDxwYXRoIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTEzLjYyNSA4LjQ5OWExLjg3NSAxLjg3NSAwIDEgMSAzLjc1IDAgMS44NzUgMS44NzUgMCAwIDEtMy43NSAwWm0xLjg3NS0uNjI1YS42MjUuNjI1IDAgMSAwIDAgMS4yNS42MjUuNjI1IDAgMCAwIDAtMS4yNVoiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZD0iTTQuNjI1IDEyLjEyNWEuNjI1LjYyNSAwIDEgMCAwLTEuMjUuNjI1LjYyNSAwIDAgMCAwIDEuMjVaIi8+Cjwvc3ZnPgo=');\n}\n.tooltip__button--data--creditCards::before {\n background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+CiAgICA8cGF0aCBkPSJNNSA5Yy0uNTUyIDAtMSAuNDQ4LTEgMXYyYzAgLjU1Mi40NDggMSAxIDFoM2MuNTUyIDAgMS0uNDQ4IDEtMXYtMmMwLS41NTItLjQ0OC0xLTEtMUg1eiIgZmlsbD0iIzAwMCIvPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xIDZjMC0yLjIxIDEuNzktNCA0LTRoMTRjMi4yMSAwIDQgMS43OSA0IDR2MTJjMCAyLjIxLTEuNzkgNC00IDRINWMtMi4yMSAwLTQtMS43OS00LTRWNnptNC0yYy0xLjEwNSAwLTIgLjg5NS0yIDJ2OWgxOFY2YzAtMS4xMDUtLjg5NS0yLTItMkg1em0wIDE2Yy0xLjEwNSAwLTItLjg5NS0yLTJoMThjMCAxLjEwNS0uODk1IDItMiAySDV6IiBmaWxsPSIjMDAwIi8+Cjwvc3ZnPgo=');\n}\n.tooltip__button--data--identities::before {\n background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+CiAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTEyIDIxYzIuMTQzIDAgNC4xMTEtLjc1IDUuNjU3LTItLjYyNi0uNTA2LTEuMzE4LS45MjctMi4wNi0xLjI1LTEuMS0uNDgtMi4yODUtLjczNS0zLjQ4Ni0uNzUtMS4yLS4wMTQtMi4zOTIuMjExLTMuNTA0LjY2NC0uODE3LjMzMy0xLjU4Ljc4My0yLjI2NCAxLjMzNiAxLjU0NiAxLjI1IDMuNTE0IDIgNS42NTcgMnptNC4zOTctNS4wODNjLjk2Ny40MjIgMS44NjYuOTggMi42NzIgMS42NTVDMjAuMjc5IDE2LjAzOSAyMSAxNC4xMDQgMjEgMTJjMC00Ljk3LTQuMDMtOS05LTlzLTkgNC4wMy05IDljMCAyLjEwNC43MjIgNC4wNCAxLjkzMiA1LjU3Mi44NzQtLjczNCAxLjg2LTEuMzI4IDIuOTIxLTEuNzYgMS4zNi0uNTU0IDIuODE2LS44MyA0LjI4My0uODExIDEuNDY3LjAxOCAyLjkxNi4zMyA0LjI2LjkxNnpNMTIgMjNjNi4wNzUgMCAxMS00LjkyNSAxMS0xMVMxOC4wNzUgMSAxMiAxIDEgNS45MjUgMSAxMnM0LjkyNSAxMSAxMSAxMXptMy0xM2MwIDEuNjU3LTEuMzQzIDMtMyAzcy0zLTEuMzQzLTMtMyAxLjM0My0zIDMtMyAzIDEuMzQzIDMgM3ptMiAwYzAgMi43NjEtMi4yMzkgNS01IDVzLTUtMi4yMzktNS01IDIuMjM5LTUgNS01IDUgMi4yMzkgNSA1eiIgZmlsbD0iIzAwMCIvPgo8L3N2Zz4=');\n}\n.tooltip__button--data--credentials.tooltip__button--data--bitwarden::before,\n.tooltip__button--data--credentials__current.tooltip__button--data--bitwarden::before {\n background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iOCIgZmlsbD0iIzE3NUREQyIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE4LjU2OTYgNS40MzM1NUMxOC41MDg0IDUuMzc0NDIgMTguNDM0NyA1LjMyNzYzIDE4LjM1MzEgNS4yOTYxMUMxOC4yNzE1IDUuMjY0NiAxOC4xODM3IDUuMjQ5MDQgMTguMDk1MyA1LjI1MDQxSDUuOTIxOTFDNS44MzMyNiA1LjI0NzI5IDUuNzQ0OTMgNS4yNjIwNSA1LjY2MzA0IDUuMjkzNjdDNS41ODExNSA1LjMyNTI5IDUuNTA3NjUgNS4zNzMwMiA1LjQ0NzYyIDUuNDMzNTVDNS4zMjE3IDUuNTUwMTMgNS4yNTA2NSA1LjcwODE1IDUuMjUgNS44NzMxVjEzLjM4MjFDNS4yNTMzNiAxMy45NTM1IDUuMzc0MDggMTQuNTE5MSA1LjYwNTcyIDE1LjA0ODdDNS44MTkzMSAxNS41NzI4IDYuMTEyMDcgMTYuMDY2MSA2LjQ3NTI0IDE2LjUxMzlDNi44NDIgMTYuOTY4MyA3LjI1OTI5IDE3LjM4NTcgNy43MjAyNSAxNy43NTkzQzguMTQwNTMgMTguMTI1NiA4LjU4OTcxIDE4LjQ2MjMgOS4wNjQwNyAxOC43NjY2QzkuNDU5MzEgMTkuMDIzIDkuOTEzODMgMTkuMjc5NCAxMC4zNDg2IDE5LjUxNzVDMTAuNzgzNCAxOS43NTU2IDExLjA5OTYgMTkuOTIwNCAxMS4yNzc0IDE5Ljk5MzdDMTEuNDU1MyAyMC4wNjY5IDExLjYxMzQgMjAuMTQwMiAxMS43MTIyIDIwLjE5NTFDMTEuNzk5MiAyMC4yMzEzIDExLjg5MzUgMjAuMjUgMTEuOTg4OCAyMC4yNUMxMi4wODQyIDIwLjI1IDEyLjE3ODUgMjAuMjMxMyAxMi4yNjU1IDIwLjE5NTFDMTIuNDIxMiAyMC4xMzYzIDEyLjU3MjkgMjAuMDY5IDEyLjcyIDE5Ljk5MzdDMTIuNzcxMSAxOS45Njc0IDEyLjgzMzUgMTkuOTM2NiAxMi45MDY5IDE5LjkwMDRDMTMuMDg5MSAxOS44MTA1IDEzLjMzODggMTkuNjg3MiAxMy42NDg5IDE5LjUxNzVDMTQuMDgzNiAxOS4yNzk0IDE0LjUxODQgMTkuMDIzIDE0LjkzMzQgMTguNzY2NkMxNS40MDQgMTguNDU3NyAxNS44NTI4IDE4LjEyMTIgMTYuMjc3MiAxNy43NTkzQzE2LjczMzEgMTcuMzgwOSAxNy4xNDk5IDE2Ljk2NCAxNy41MjIyIDE2LjUxMzlDMTcuODc4IDE2LjA2MTcgMTguMTcwMiAxNS41NjkzIDE4LjM5MTcgMTUuMDQ4N0MxOC42MjM0IDE0LjUxOTEgMTguNzQ0MSAxMy45NTM1IDE4Ljc0NzQgMTMuMzgyMVY1Ljg3MzFDMTguNzU1NyA1Ljc5MjE0IDE4Ljc0MzkgNS43MTA1IDE4LjcxMzEgNS42MzQzNUMxOC42ODIzIDUuNTU4MiAxOC42MzMyIDUuNDg5NTQgMTguNTY5NiA1LjQzMzU1Wk0xNy4wMDg0IDEzLjQ1NTNDMTcuMDA4NCAxNi4xODQyIDEyLjAwODYgMTguNTI4NSAxMi4wMDg2IDE4LjUyODVWNi44NjIwOUgxNy4wMDg0VjEzLjQ1NTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K');\n}\n#provider_locked:after {\n background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTEiIGhlaWdodD0iMTMiIHZpZXdCb3g9IjAgMCAxMSAxMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEgNy42MDA1N1Y3LjYwMjVWOS41MjI1QzEgMTAuMDgwMSAxLjIyMTUxIDEwLjYxNDkgMS42MTU4MSAxMS4wMDkyQzIuMDEwMSAxMS40MDM1IDIuNTQ0ODggMTEuNjI1IDMuMTAyNSAxMS42MjVINy4yNzI1QzcuNTQ4NjEgMTEuNjI1IDcuODIyMDEgMTEuNTcwNiA4LjA3NzA5IDExLjQ2NUM4LjMzMjE4IDExLjM1OTMgOC41NjM5NiAxMS4yMDQ0IDguNzU5MTkgMTEuMDA5MkM4Ljk1NDQzIDEwLjgxNCA5LjEwOTMgMTAuNTgyMiA5LjIxNDk2IDEwLjMyNzFDOS4zMjA2MiAxMC4wNzIgOS4zNzUgOS43OTg2MSA5LjM3NSA5LjUyMjVMOS4zNzUgNy42MDI1TDkuMzc1IDcuNjAwNTdDOS4zNzQxNSA3LjE2MTMxIDkuMjM1NzQgNi43MzMzNSA4Ljk3OTIyIDYuMzc2NzhDOC44NzY4MyA2LjIzNDQ2IDguNzU3NjggNi4xMDYzNyA4LjYyNSA1Ljk5NDg5VjUuMTg3NUM4LjYyNSA0LjI3NTgyIDguMjYyODQgMy40MDE0OCA3LjYxODE4IDIuNzU2ODJDNi45NzM1MiAyLjExMjE2IDYuMDk5MTggMS43NSA1LjE4NzUgMS43NUM0LjI3NTgyIDEuNzUgMy40MDE0OCAyLjExMjE2IDIuNzU2ODIgMi43NTY4MkMyLjExMjE2IDMuNDAxNDggMS43NSA0LjI3NTgyIDEuNzUgNS4xODc1VjUuOTk0ODlDMS42MTczMiA2LjEwNjM3IDEuNDk4MTcgNi4yMzQ0NiAxLjM5NTc4IDYuMzc2NzhDMS4xMzkyNiA2LjczMzM1IDEuMDAwODUgNy4xNjEzMSAxIDcuNjAwNTdaTTQuOTY4NyA0Ljk2ODdDNS4wMjY5NCA0LjkxMDQ3IDUuMTA1MzIgNC44NzY5OSA1LjE4NzUgNC44NzUwN0M1LjI2OTY4IDQuODc2OTkgNS4zNDgwNiA0LjkxMDQ3IDUuNDA2MyA0Ljk2ODdDNS40NjU0MiA1LjAyNzgzIDUuNDk5MDQgNS4xMDc3NCA1LjUgNS4xOTEzVjUuNUg0Ljg3NVY1LjE5MTNDNC44NzU5NiA1LjEwNzc0IDQuOTA5NTggNS4wMjc4MyA0Ljk2ODcgNC45Njg3WiIgZmlsbD0iIzIyMjIyMiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=');\n}\n\nhr {\n display: block;\n margin: 5px 9px;\n border: none; /* reset the border */\n border-top: 1px solid rgba(0,0,0,.1);\n}\n\nhr:first-child {\n display: none;\n}\n\n@media (prefers-color-scheme: dark) {\n hr {\n border-top: 1px solid rgba(255,255,255,.2);\n }\n}\n\n#privateAddress {\n align-items: flex-start;\n}\n#personalAddress::before,\n#privateAddress::before,\n#incontextSignup::before,\n#personalAddress.currentFocus::before,\n#personalAddress:hover::before,\n#privateAddress.currentFocus::before,\n#privateAddress:hover::before {\n filter: none;\n /* This is the same icon as `daxBase64` in `src/Form/logo-svg.js` */\n background-image: url('data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiB2aWV3Qm94PSIwIDAgMTI4IDEyOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTY0IDEyOGMzNS4zNDYgMCA2NC0yOC42NTQgNjQtNjRzLTI4LjY1NC02NC02NC02NC02NCAyOC42NTQtNjQgNjQgMjguNjU0IDY0IDY0IDY0eiIgZmlsbD0iI2RlNTgzMyIgZmlsbC1ydWxlPSJldmVub2RkIi8+CiAgICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im03MyAxMTEuNzVjMC0uNS4xMjMtLjYxNC0xLjQ2Ni0zLjc4Mi00LjIyNC04LjQ1OS04LjQ3LTIwLjM4NC02LjU0LTI4LjA3NS4zNTMtMS4zOTctMy45NzgtNTEuNzQ0LTcuMDQtNTMuMzY1LTMuNDAyLTEuODEzLTcuNTg4LTQuNjktMTEuNDE4LTUuMzMtMS45NDMtLjMxLTQuNDktLjE2NC02LjQ4Mi4xMDUtLjM1My4wNDctLjM2OC42ODMtLjAzLjc5OCAxLjMwOC40NDMgMi44OTUgMS4yMTIgMy44MyAyLjM3NS4xNzguMjItLjA2LjU2Ni0uMzQyLjU3Ny0uODgyLjAzMi0yLjQ4Mi40MDItNC41OTMgMi4xOTUtLjI0NC4yMDctLjA0MS41OTIuMjczLjUzIDQuNTM2LS44OTcgOS4xNy0uNDU1IDExLjkgMi4wMjcuMTc3LjE2LjA4NC40NS0uMTQ3LjUxMi0yMy42OTQgNi40NC0xOS4wMDMgMjcuMDUtMTIuNjk2IDUyLjM0NCA1LjYxOSAyMi41MyA3LjczMyAyOS43OTIgOC40IDMyLjAwNGEuNzE4LjcxOCAwIDAgMCAuNDIzLjQ2N2M4LjE1NiAzLjI0OCAyNS45MjggMy4zOTIgMjUuOTI4LTIuMTMyeiIgZmlsbD0iI2RkZCIgZmlsbC1ydWxlPSJldmVub2RkIi8+CiAgICA8cGF0aCBkPSJtNzYuMjUgMTE2LjVjLTIuODc1IDEuMTI1LTguNSAxLjYyNS0xMS43NSAxLjYyNS00Ljc2NCAwLTExLjYyNS0uNzUtMTQuMTI1LTEuODc1LTEuNTQ0LTQuNzUxLTYuMTY0LTE5LjQ4LTEwLjcyNy0zOC4xODVsLS40NDctMS44MjctLjAwNC0uMDE1Yy01LjQyNC0yMi4xNTctOS44NTUtNDAuMjUzIDE0LjQyNy00NS45MzguMjIyLS4wNTIuMzMtLjMxNy4xODQtLjQ5Mi0yLjc4Ni0zLjMwNS04LjAwNS00LjM4OC0xNC42MDUtMi4xMTEtLjI3LjA5My0uNTA2LS4xOC0uMzM3LS40MTIgMS4yOTQtMS43ODMgMy44MjMtMy4xNTUgNS4wNzEtMy43NTYuMjU4LS4xMjQuMjQyLS41MDItLjAzLS41ODhhMjcuODc3IDI3Ljg3NyAwIDAgMCAtMy43NzItLjljLS4zNy0uMDU5LS40MDMtLjY5My0uMDMyLS43NDMgOS4zNTYtMS4yNTkgMTkuMTI1IDEuNTUgMjQuMDI4IDcuNzI2YS4zMjYuMzI2IDAgMCAwIC4xODYuMTE0YzE3Ljk1MiAzLjg1NiAxOS4yMzggMzIuMjM1IDE3LjE3IDMzLjUyOC0uNDA4LjI1NS0xLjcxNS4xMDgtMy40MzgtLjA4NS02Ljk4Ni0uNzgxLTIwLjgxOC0yLjMyOS05LjQwMiAxOC45NDguMTEzLjIxLS4wMzYuNDg4LS4yNzIuNTI1LTYuNDM4IDEgMS44MTIgMjEuMTczIDcuODc1IDM0LjQ2MXoiIGZpbGw9IiNmZmYiLz4KICAgIDxwYXRoIGQ9Im04NC4yOCA5MC42OThjLTEuMzY3LS42MzMtNi42MjEgMy4xMzUtMTAuMTEgNi4wMjgtLjcyOC0xLjAzMS0yLjEwMy0xLjc4LTUuMjAzLTEuMjQyLTIuNzEzLjQ3Mi00LjIxMSAxLjEyNi00Ljg4IDIuMjU0LTQuMjgzLTEuNjIzLTExLjQ4OC00LjEzLTEzLjIyOS0xLjcxLTEuOTAyIDIuNjQ2LjQ3NiAxNS4xNjEgMy4wMDMgMTYuNzg2IDEuMzIuODQ5IDcuNjMtMy4yMDggMTAuOTI2LTYuMDA1LjUzMi43NDkgMS4zODggMS4xNzggMy4xNDggMS4xMzcgMi42NjItLjA2MiA2Ljk3OS0uNjgxIDcuNjQ5LTEuOTIxLjA0LS4wNzUuMDc1LS4xNjQuMTA1LS4yNjYgMy4zODggMS4yNjYgOS4zNSAyLjYwNiAxMC42ODIgMi40MDYgMy40Ny0uNTIxLS40ODQtMTYuNzIzLTIuMDktMTcuNDY3eiIgZmlsbD0iIzNjYTgyYiIvPgogICAgPHBhdGggZD0ibTc0LjQ5IDk3LjA5N2MuMTQ0LjI1Ni4yNi41MjYuMzU4LjguNDgzIDEuMzUyIDEuMjcgNS42NDguNjc0IDYuNzA5LS41OTUgMS4wNjItNC40NTkgMS41NzQtNi44NDMgMS42MTVzLTIuOTItLjgzMS0zLjQwMy0yLjE4MWMtLjM4Ny0xLjA4MS0uNTc3LTMuNjIxLS41NzItNS4wNzUtLjA5OC0yLjE1OC42OS0yLjkxNiA0LjMzNC0zLjUwNiAyLjY5Ni0uNDM2IDQuMTIxLjA3MSA0Ljk0NC45NCAzLjgyOC0yLjg1NyAxMC4yMTUtNi44ODkgMTAuODM4LTYuMTUyIDMuMTA2IDMuNjc0IDMuNDk5IDEyLjQyIDIuODI2IDE1LjkzOS0uMjIgMS4xNTEtMTAuNTA1LTEuMTM5LTEwLjUwNS0yLjM4IDAtNS4xNTItMS4zMzctNi41NjUtMi42NS02Ljcxem0tMjIuNTMtMS42MDljLjg0My0xLjMzMyA3LjY3NC4zMjUgMTEuNDI0IDEuOTkzIDAgMC0uNzcgMy40OTEuNDU2IDcuNjA0LjM1OSAxLjIwMy04LjYyNyA2LjU1OC05LjggNS42MzctMS4zNTUtMS4wNjUtMy44NS0xMi40MzItMi4wOC0xNS4yMzR6IiBmaWxsPSIjNGNiYTNjIi8+CiAgICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im01NS4yNjkgNjguNDA2Yy41NTMtMi40MDMgMy4xMjctNi45MzIgMTIuMzIxLTYuODIyIDQuNjQ4LS4wMTkgMTAuNDIyLS4wMDIgMTQuMjUtLjQzNmE1MS4zMTIgNTEuMzEyIDAgMCAwIDEyLjcyNi0zLjA5NWMzLjk4LTEuNTE5IDUuMzkyLTEuMTggNS44ODctLjI3Mi41NDQuOTk5LS4wOTcgMi43MjItMS40ODggNC4zMDktMi42NTYgMy4wMy03LjQzMSA1LjM4LTE1Ljg2NSA2LjA3Ni04LjQzMy42OTgtMTQuMDItMS41NjUtMTYuNDI1IDIuMTE4LTEuMDM4IDEuNTg5LS4yMzYgNS4zMzMgNy45MiA2LjUxMiAxMS4wMiAxLjU5IDIwLjA3Mi0xLjkxNyAyMS4xOS4yMDEgMS4xMTkgMi4xMTgtNS4zMjMgNi40MjgtMTYuMzYyIDYuNTE4cy0xNy45MzQtMy44NjUtMjAuMzc5LTUuODNjLTMuMTAyLTIuNDk1LTQuNDktNi4xMzMtMy43NzUtOS4yNzl6IiBmaWxsPSIjZmMzIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KICAgIDxnIGZpbGw9IiMxNDMwN2UiIG9wYWNpdHk9Ii44Ij4KICAgICAgPHBhdGggZD0ibTY5LjMyNyA0Mi4xMjdjLjYxNi0xLjAwOCAxLjk4MS0xLjc4NiA0LjIxNi0xLjc4NiAyLjIzNCAwIDMuMjg1Ljg4OSA0LjAxMyAxLjg4LjE0OC4yMDItLjA3Ni40NC0uMzA2LjM0YTU5Ljg2OSA1OS44NjkgMCAwIDEgLS4xNjgtLjA3M2MtLjgxNy0uMzU3LTEuODItLjc5NS0zLjU0LS44Mi0xLjgzOC0uMDI2LTIuOTk3LjQzNS0zLjcyNy44MzEtLjI0Ni4xMzQtLjYzNC0uMTMzLS40ODgtLjM3MnptLTI1LjE1NyAxLjI5YzIuMTctLjkwNyAzLjg3Ni0uNzkgNS4wODEtLjUwNC4yNTQuMDYuNDMtLjIxMy4yMjctLjM3Ny0uOTM1LS43NTUtMy4wMy0xLjY5Mi01Ljc2LS42NzQtMi40MzcuOTA5LTMuNTg1IDIuNzk2LTMuNTkyIDQuMDM4LS4wMDIuMjkyLjYuMzE3Ljc1Ni4wNy40Mi0uNjcgMS4xMi0xLjY0NiAzLjI4OS0yLjU1M3oiLz4KICAgICAgPHBhdGggY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtNzUuNDQgNTUuOTJhMy40NyAzLjQ3IDAgMCAxIC0zLjQ3NC0zLjQ2MiAzLjQ3IDMuNDcgMCAwIDEgMy40NzUtMy40NiAzLjQ3IDMuNDcgMCAwIDEgMy40NzQgMy40NiAzLjQ3IDMuNDcgMCAwIDEgLTMuNDc1IDMuNDYyem0yLjQ0Ny00LjYwOGEuODk5Ljg5OSAwIDAgMCAtMS43OTkgMGMwIC40OTQuNDA1Ljg5NS45Ljg5NS40OTkgMCAuOS0uNC45LS44OTV6bS0yNS40NjQgMy41NDJhNC4wNDIgNC4wNDIgMCAwIDEgLTQuMDQ5IDQuMDM3IDQuMDQ1IDQuMDQ1IDAgMCAxIC00LjA1LTQuMDM3IDQuMDQ1IDQuMDQ1IDAgMCAxIDQuMDUtNC4wMzcgNC4wNDUgNC4wNDUgMCAwIDEgNC4wNSA0LjAzN3ptLTEuMTkzLTEuMzM4YTEuMDUgMS4wNSAwIDAgMCAtMi4wOTcgMCAxLjA0OCAxLjA0OCAwIDAgMCAyLjA5NyAweiIgZmlsbC1ydWxlPSJldmVub2RkIi8+CiAgICA8L2c+CiAgICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im02NCAxMTcuNzVjMjkuNjg1IDAgNTMuNzUtMjQuMDY1IDUzLjc1LTUzLjc1cy0yNC4wNjUtNTMuNzUtNTMuNzUtNTMuNzUtNTMuNzUgMjQuMDY1LTUzLjc1IDUzLjc1IDI0LjA2NSA1My43NSA1My43NSA1My43NXptMCA1YzMyLjQ0NyAwIDU4Ljc1LTI2LjMwMyA1OC43NS01OC43NXMtMjYuMzAzLTU4Ljc1LTU4Ljc1LTU4Ljc1LTU4Ljc1IDI2LjMwMy01OC43NSA1OC43NSAyNi4zMDMgNTguNzUgNTguNzUgNTguNzV6IiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KPC9zdmc+');\n}\n\n/* Email tooltip specific */\n.tooltip__button--email {\n flex-direction: column;\n justify-content: center;\n align-items: flex-start;\n font-size: 14px;\n padding: 4px 8px;\n}\n.tooltip__button--email__primary-text {\n font-weight: bold;\n}\n.tooltip__button--email__secondary-text {\n font-size: 12px;\n}\n\n/* Email Protection signup notice */\n:not(.top-autofill) .tooltip--email-signup {\n text-align: left;\n color: #222222;\n padding: 16px 20px;\n width: 380px;\n}\n\n.tooltip--email-signup h1 {\n font-weight: 700;\n font-size: 16px;\n line-height: 1.5;\n margin: 0;\n}\n\n.tooltip--email-signup p {\n font-weight: 400;\n font-size: 14px;\n line-height: 1.4;\n}\n\n.notice-controls {\n display: flex;\n}\n\n.tooltip--email-signup .notice-controls > * {\n border-radius: 8px;\n border: 0;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-style: normal;\n font-weight: bold;\n padding: 8px 12px;\n text-decoration: none;\n}\n\n.notice-controls .ghost {\n margin-left: 1rem;\n}\n\n.tooltip--email-signup a.primary {\n background: #3969EF;\n color: #fff;\n}\n\n.tooltip--email-signup a.primary:hover,\n.tooltip--email-signup a.primary:focus {\n background: #2b55ca;\n}\n\n.tooltip--email-signup a.primary:active {\n background: #1e42a4;\n}\n\n.tooltip--email-signup button.ghost {\n background: transparent;\n color: #3969EF;\n}\n\n.tooltip--email-signup button.ghost:hover,\n.tooltip--email-signup button.ghost:focus {\n background-color: rgba(0, 0, 0, 0.06);\n color: #2b55ca;\n}\n\n.tooltip--email-signup button.ghost:active {\n background-color: rgba(0, 0, 0, 0.12);\n color: #1e42a4;\n}\n\n.tooltip--email-signup button.close-tooltip {\n background-color: transparent;\n background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTMiIHZpZXdCb3g9IjAgMCAxMiAxMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0wLjI5Mjg5NCAwLjY1NjkwN0MwLjY4MzQxOCAwLjI2NjM4MyAxLjMxNjU4IDAuMjY2MzgzIDEuNzA3MTEgMC42NTY5MDdMNiA0Ljk0OThMMTAuMjkyOSAwLjY1NjkwN0MxMC42ODM0IDAuMjY2MzgzIDExLjMxNjYgMC4yNjYzODMgMTEuNzA3MSAwLjY1NjkwN0MxMi4wOTc2IDEuMDQ3NDMgMTIuMDk3NiAxLjY4MDYgMTEuNzA3MSAyLjA3MTEyTDcuNDE0MjEgNi4zNjQwMUwxMS43MDcxIDEwLjY1NjlDMTIuMDk3NiAxMS4wNDc0IDEyLjA5NzYgMTEuNjgwNiAxMS43MDcxIDEyLjA3MTFDMTEuMzE2NiAxMi40NjE2IDEwLjY4MzQgMTIuNDYxNiAxMC4yOTI5IDEyLjA3MTFMNiA3Ljc3ODIzTDEuNzA3MTEgMTIuMDcxMUMxLjMxNjU4IDEyLjQ2MTYgMC42ODM0MTcgMTIuNDYxNiAwLjI5Mjg5MyAxMi4wNzExQy0wLjA5NzYzMTEgMTEuNjgwNiAtMC4wOTc2MzExIDExLjA0NzQgMC4yOTI4OTMgMTAuNjU2OUw0LjU4NTc5IDYuMzY0MDFMMC4yOTI4OTQgMi4wNzExMkMtMC4wOTc2MzA2IDEuNjgwNiAtMC4wOTc2MzA2IDEuMDQ3NDMgMC4yOTI4OTQgMC42NTY5MDdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg0Ii8+Cjwvc3ZnPgo=);\n background-position: center center;\n background-repeat: no-repeat;\n border: 0;\n cursor: pointer;\n padding: 16px;\n position: absolute;\n right: 12px;\n top: 12px;\n}\n\n/* Import promotion prompt icon style */\n\n.tooltip__button--credentials-import::before {\n content: \"\";\n background-image: url(data:image/svg+xml;base64,Cjxzdmcgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03Ljk3MjY5IDkuMDEzN0M3Ljk3MjY5IDUuMTQwMTQgMTEuMTEyOCAyIDE0Ljk4NjQgMkMxOC44NTk5IDIgMjIuMDAwMSA1LjE0MDE0IDIyLjAwMDEgOS4wMTM3QzIyLjAwMDEgOS44MDM5NyAyMS44Njk0IDEwLjU2MzcgMjEuNjI4NCAxMS4yNzI1QzIxLjQ2MDEgMTEuNzY3NiAyMC44NjU2IDExLjkxMzUgMjAuMzk3NyAxMS42OEMxOS45MjIxIDExLjQ0MjggMTkuNjc3OSAxMC44NjQ0IDE5LjgxOTUgMTAuMzUyQzE5LjkzNzIgOS45MjU5OSAyMC4wMDAxIDkuNDc3MTkgMjAuMDAwMSA5LjAxMzdDMjAuMDAwMSA2LjI0NDcxIDE3Ljc1NTQgNCAxNC45ODY0IDRDMTIuMjE3NCA0IDkuOTcyNjkgNi4yNDQ3MSA5Ljk3MjY5IDkuMDEzN0M5Ljk3MjY5IDkuNTU1NCAxMC4wNTgyIDEwLjA3NTIgMTAuMjE1OCAxMC41NjEzQzEwLjMzMTcgMTAuOTE4OCAxMC4yMzc0IDExLjMxMTEgOS45NzE2NSAxMS41NzY4TDMuOTk5OTQgMTcuNTQ4NVYyMC4wMDAyTDYuNDk5OTggMjAuMDAwMlYxOC4zMDAyQzYuNDk5OTggMTcuNzQ3OSA2Ljk0NzcgMTcuMzAwMiA3LjQ5OTk4IDE3LjMwMDJIOS4yMDAwMVYxNS42MDAxQzkuMjAwMDEgMTUuMDQ3OCA5LjY0NzczIDE0LjYwMDEgMTAuMiAxNC42MDAxSDEwLjVDMTEuMjEwNiAxNC41ODcgMTEuNDI1MiAxNS4zMzY5IDExLjMxNTYgMTUuNzMzMUMxMS4xNTY1IDE2LjMwODUgMTEgMTcuMDAxIDExIDE3LjVDMTEgMTcuNzI4NCAxMS4wMTE4IDE3Ljk1NCAxMS4wMzQ4IDE4LjE3NjNDMTAuNzM3OSAxOC44Mzg3IDEwLjA3MjkgMTkuMzAwMiA5LjMgMTkuMzAwMkg4LjQ5OTk4VjIwLjEwMDJDOC40OTk5OCAyMS4xNDk1IDcuNjQ5MzMgMjIuMDAwMiA2LjU5OTk5IDIyLjAwMDJMMi45OTk5NSAyMi4wMDAyQzIuNzM0NzMgMjIuMDAwMiAyLjQ4MDM3IDIxLjg5NDkgMi4yOTI4NCAyMS43MDczQzIuMTA1MyAyMS41MTk4IDEuOTk5OTQgMjEuMjY1NCAxLjk5OTk0IDIxLjAwMDJWMTcuMTM0M0MxLjk5OTk0IDE2Ljg2OTEgMi4xMDUzIDE2LjYxNDcgMi4yOTI4MyAxNi40MjcyTDguMTQ2ODkgMTAuNTczMUM4LjAzMjc5IDEwLjA3MDkgNy45NzI2OSA5LjU0ODgxIDcuOTcyNjkgOS4wMTM3WiIgZmlsbD0iYmxhY2siIC8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTMgOC40OTk4OUMxMyA3LjExOTE4IDE0LjExOTMgNS45OTk4OSAxNS41IDUuOTk5ODlDMTYuODgwNyA1Ljk5OTg5IDE4IDcuMTE5MTggMTggOC40OTk4OUMxOCA5Ljg4MDYgMTYuODgwNyAxMC45OTk5IDE1LjUgMTAuOTk5OUMxNC4xMTkzIDEwLjk5OTkgMTMgOS44ODA2IDEzIDguNDk5ODlaTTE1LjUgNy45OTk4OUMxNS4yMjM5IDcuOTk5ODkgMTUgOC4yMjM3NSAxNSA4LjQ5OTg5QzE1IDguNzc2MDQgMTUuMjIzOSA4Ljk5OTg5IDE1LjUgOC45OTk4OUMxNS43NzYyIDguOTk5ODkgMTYgOC43NzYwNCAxNiA4LjQ5OTg5QzE2IDguMjIzNzUgMTUuNzc2MiA3Ljk5OTg5IDE1LjUgNy45OTk4OVoiIGZpbGw9ImJsYWNrIiAvPgo8cGF0aCBkPSJNMTcgMTVMMTQuNSAxNy41TDE3IDIwIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMjEuNSAxNy41TDE2IDE3LjUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=);\n background-repeat: no-repeat;\n}\n"; -},{}],54:[function(require,module,exports){ +},{}],55:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -13326,7 +13460,7 @@ function findEnclosedElements(root, selector) { return shadowElements; } -},{"./Form/matching.js":34,"./constants.js":57,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],55:[function(require,module,exports){ +},{"./Form/matching.js":35,"./constants.js":58,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],56:[function(require,module,exports){ "use strict"; require("./requestIdleCallback.js"); @@ -13357,7 +13491,7 @@ var _autofillUtils = require("./autofill-utils.js"); } })(); -},{"./DeviceInterface.js":13,"./autofill-utils.js":54,"./requestIdleCallback.js":94}],56:[function(require,module,exports){ +},{"./DeviceInterface.js":14,"./autofill-utils.js":55,"./requestIdleCallback.js":95}],57:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -13374,6 +13508,10 @@ const DDG_DOMAIN_REGEX = exports.DDG_DOMAIN_REGEX = new RegExp(/^https:\/\/(([a- * @returns {GlobalConfig} */ function createGlobalConfig(overrides) { + /** + * Defines whether it's one of our desktop apps + * @type {boolean} + */ let isApp = false; let isTopFrame = false; let supportsTopFrame = false; @@ -13443,7 +13581,7 @@ function createGlobalConfig(overrides) { return config; } -},{}],57:[function(require,module,exports){ +},{}],58:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -13460,13 +13598,13 @@ const constants = exports.constants = { MAX_FORM_RESCANS: 50 }; -},{}],58:[function(require,module,exports){ +},{}],59:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.StoreFormDataCall = exports.StartEmailProtectionSignupCall = exports.StartCredentialsImportFlowCall = exports.ShowInContextEmailProtectionSignupPromptCall = exports.SetSizeCall = exports.SetIncontextSignupPermanentlyDismissedAtCall = exports.SendJSPixelCall = exports.SelectedDetailCall = exports.OpenManagePasswordsCall = exports.OpenManageIdentitiesCall = exports.OpenManageCreditCardsCall = exports.GetRuntimeConfigurationCall = exports.GetIncontextSignupDismissedAtCall = exports.GetAvailableInputTypesCall = exports.GetAutofillInitDataCall = exports.GetAutofillDataCall = exports.GetAutofillCredentialsCall = exports.EmailProtectionStoreUserDataCall = exports.EmailProtectionRemoveUserDataCall = exports.EmailProtectionRefreshPrivateAddressCall = exports.EmailProtectionGetUserDataCall = exports.EmailProtectionGetIsLoggedInCall = exports.EmailProtectionGetCapabilitiesCall = exports.EmailProtectionGetAddressesCall = exports.CloseEmailProtectionTabCall = exports.CloseAutofillParentCall = exports.CheckCredentialsProviderStatusCall = exports.AskToUnlockProviderCall = exports.AddDebugFlagCall = void 0; +exports.StoreFormDataCall = exports.StartEmailProtectionSignupCall = exports.StartCredentialsImportFlowCall = exports.ShowInContextEmailProtectionSignupPromptCall = exports.SetSizeCall = exports.SetIncontextSignupPermanentlyDismissedAtCall = exports.SendJSPixelCall = exports.SelectedDetailCall = exports.OpenManagePasswordsCall = exports.OpenManageIdentitiesCall = exports.OpenManageCreditCardsCall = exports.GetRuntimeConfigurationCall = exports.GetIncontextSignupDismissedAtCall = exports.GetAvailableInputTypesCall = exports.GetAutofillInitDataCall = exports.GetAutofillDataCall = exports.GetAutofillCredentialsCall = exports.EmailProtectionStoreUserDataCall = exports.EmailProtectionRemoveUserDataCall = exports.EmailProtectionRefreshPrivateAddressCall = exports.EmailProtectionGetUserDataCall = exports.EmailProtectionGetIsLoggedInCall = exports.EmailProtectionGetCapabilitiesCall = exports.EmailProtectionGetAliasCall = exports.EmailProtectionGetAddressesCall = exports.CloseEmailProtectionTabCall = exports.CloseAutofillParentCall = exports.CheckCredentialsProviderStatusCall = exports.AskToUnlockProviderCall = exports.AddDebugFlagCall = void 0; var _validatorsZod = require("./validators.zod.js"); var _deviceApi = require("../../../packages/device-api"); /* DO NOT EDIT, this file was generated by scripts/api-call-generator.js */ @@ -13628,9 +13766,19 @@ class StartCredentialsImportFlowCall extends _deviceApi.DeviceApiCall { method = "startCredentialsImportFlow"; } /** - * @extends {DeviceApiCall} + * @extends {DeviceApiCall} */ exports.StartCredentialsImportFlowCall = StartCredentialsImportFlowCall; +class EmailProtectionGetAliasCall extends _deviceApi.DeviceApiCall { + method = "emailProtectionGetAlias"; + id = "emailProtectionGetAliasResponse"; + paramsValidator = _validatorsZod.emailProtectionGetAliasParamsSchema; + resultValidator = _validatorsZod.emailProtectionGetAliasResultSchema; +} +/** + * @extends {DeviceApiCall} + */ +exports.EmailProtectionGetAliasCall = EmailProtectionGetAliasCall; class EmailProtectionStoreUserDataCall extends _deviceApi.DeviceApiCall { method = "emailProtectionStoreUserData"; id = "emailProtectionStoreUserDataResponse"; @@ -13713,13 +13861,13 @@ class ShowInContextEmailProtectionSignupPromptCall extends _deviceApi.DeviceApiC } exports.ShowInContextEmailProtectionSignupPromptCall = ShowInContextEmailProtectionSignupPromptCall; -},{"../../../packages/device-api":2,"./validators.zod.js":59}],59:[function(require,module,exports){ +},{"../../../packages/device-api":2,"./validators.zod.js":60}],60:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.userPreferencesSchema = exports.triggerContextSchema = exports.storeFormDataSchema = exports.showInContextEmailProtectionSignupPromptSchema = exports.setSizeParamsSchema = exports.setIncontextSignupPermanentlyDismissedAtSchema = exports.sendJSPixelParamsSchema = exports.selectedDetailParamsSchema = exports.runtimeConfigurationSchema = exports.providerStatusUpdatedSchema = exports.outgoingCredentialsSchema = exports.getRuntimeConfigurationResponseSchema = exports.getIncontextSignupDismissedAtSchema = exports.getAvailableInputTypesResultSchema = exports.getAutofillInitDataResponseSchema = exports.getAutofillDataResponseSchema = exports.getAutofillDataRequestSchema = exports.getAutofillCredentialsResultSchema = exports.getAutofillCredentialsParamsSchema = exports.getAliasResultSchema = exports.getAliasParamsSchema = exports.genericErrorSchema = exports.generatedPasswordSchema = exports.emailProtectionStoreUserDataParamsSchema = exports.emailProtectionRefreshPrivateAddressResultSchema = exports.emailProtectionGetUserDataResultSchema = exports.emailProtectionGetIsLoggedInResultSchema = exports.emailProtectionGetCapabilitiesResultSchema = exports.emailProtectionGetAddressesResultSchema = exports.credentialsSchema = exports.contentScopeSchema = exports.checkCredentialsProviderStatusResultSchema = exports.availableInputTypesSchema = exports.availableInputTypes1Schema = exports.autofillSettingsSchema = exports.autofillFeatureTogglesSchema = exports.askToUnlockProviderResultSchema = exports.apiSchema = exports.addDebugFlagParamsSchema = void 0; +exports.userPreferencesSchema = exports.triggerContextSchema = exports.storeFormDataSchema = exports.showInContextEmailProtectionSignupPromptSchema = exports.setSizeParamsSchema = exports.setIncontextSignupPermanentlyDismissedAtSchema = exports.sendJSPixelParamsSchema = exports.selectedDetailParamsSchema = exports.runtimeConfigurationSchema = exports.providerStatusUpdatedSchema = exports.outgoingCredentialsSchema = exports.getRuntimeConfigurationResponseSchema = exports.getIncontextSignupDismissedAtSchema = exports.getAvailableInputTypesResultSchema = exports.getAutofillInitDataResponseSchema = exports.getAutofillDataResponseSchema = exports.getAutofillDataRequestSchema = exports.getAutofillCredentialsResultSchema = exports.getAutofillCredentialsParamsSchema = exports.getAliasResultSchema = exports.getAliasParamsSchema = exports.genericErrorSchema = exports.generatedPasswordSchema = exports.emailProtectionStoreUserDataParamsSchema = exports.emailProtectionRefreshPrivateAddressResultSchema = exports.emailProtectionGetUserDataResultSchema = exports.emailProtectionGetIsLoggedInResultSchema = exports.emailProtectionGetCapabilitiesResultSchema = exports.emailProtectionGetAliasResultSchema = exports.emailProtectionGetAliasParamsSchema = exports.emailProtectionGetAddressesResultSchema = exports.credentialsSchema = exports.contentScopeSchema = exports.checkCredentialsProviderStatusResultSchema = exports.availableInputTypesSchema = exports.availableInputTypes1Schema = exports.autofillSettingsSchema = exports.autofillFeatureTogglesSchema = exports.askToUnlockProviderResultSchema = exports.apiSchema = exports.addDebugFlagParamsSchema = void 0; const sendJSPixelParamsSchema = exports.sendJSPixelParamsSchema = null; const addDebugFlagParamsSchema = exports.addDebugFlagParamsSchema = null; const getAutofillCredentialsParamsSchema = exports.getAutofillCredentialsParamsSchema = null; @@ -13729,6 +13877,7 @@ const setIncontextSignupPermanentlyDismissedAtSchema = exports.setIncontextSignu const getIncontextSignupDismissedAtSchema = exports.getIncontextSignupDismissedAtSchema = null; const getAliasParamsSchema = exports.getAliasParamsSchema = null; const getAliasResultSchema = exports.getAliasResultSchema = null; +const emailProtectionGetAliasParamsSchema = exports.emailProtectionGetAliasParamsSchema = null; const emailProtectionStoreUserDataParamsSchema = exports.emailProtectionStoreUserDataParamsSchema = null; const showInContextEmailProtectionSignupPromptSchema = exports.showInContextEmailProtectionSignupPromptSchema = null; const generatedPasswordSchema = exports.generatedPasswordSchema = null; @@ -13737,9 +13886,10 @@ const credentialsSchema = exports.credentialsSchema = null; const genericErrorSchema = exports.genericErrorSchema = null; const contentScopeSchema = exports.contentScopeSchema = null; const userPreferencesSchema = exports.userPreferencesSchema = null; -const outgoingCredentialsSchema = exports.outgoingCredentialsSchema = null; const availableInputTypesSchema = exports.availableInputTypesSchema = null; +const outgoingCredentialsSchema = exports.outgoingCredentialsSchema = null; const availableInputTypes1Schema = exports.availableInputTypes1Schema = null; +const providerStatusUpdatedSchema = exports.providerStatusUpdatedSchema = null; const autofillFeatureTogglesSchema = exports.autofillFeatureTogglesSchema = null; const getAutofillDataRequestSchema = exports.getAutofillDataRequestSchema = null; const getAutofillDataResponseSchema = exports.getAutofillDataResponseSchema = null; @@ -13747,20 +13897,20 @@ const storeFormDataSchema = exports.storeFormDataSchema = null; const getAvailableInputTypesResultSchema = exports.getAvailableInputTypesResultSchema = null; const getAutofillInitDataResponseSchema = exports.getAutofillInitDataResponseSchema = null; const getAutofillCredentialsResultSchema = exports.getAutofillCredentialsResultSchema = null; +const askToUnlockProviderResultSchema = exports.askToUnlockProviderResultSchema = null; +const checkCredentialsProviderStatusResultSchema = exports.checkCredentialsProviderStatusResultSchema = null; const autofillSettingsSchema = exports.autofillSettingsSchema = null; +const emailProtectionGetAliasResultSchema = exports.emailProtectionGetAliasResultSchema = null; const emailProtectionGetIsLoggedInResultSchema = exports.emailProtectionGetIsLoggedInResultSchema = null; const emailProtectionGetUserDataResultSchema = exports.emailProtectionGetUserDataResultSchema = null; const emailProtectionGetCapabilitiesResultSchema = exports.emailProtectionGetCapabilitiesResultSchema = null; const emailProtectionGetAddressesResultSchema = exports.emailProtectionGetAddressesResultSchema = null; const emailProtectionRefreshPrivateAddressResultSchema = exports.emailProtectionRefreshPrivateAddressResultSchema = null; const runtimeConfigurationSchema = exports.runtimeConfigurationSchema = null; -const providerStatusUpdatedSchema = exports.providerStatusUpdatedSchema = null; const getRuntimeConfigurationResponseSchema = exports.getRuntimeConfigurationResponseSchema = null; -const askToUnlockProviderResultSchema = exports.askToUnlockProviderResultSchema = null; -const checkCredentialsProviderStatusResultSchema = exports.checkCredentialsProviderStatusResultSchema = null; const apiSchema = exports.apiSchema = null; -},{}],60:[function(require,module,exports){ +},{}],61:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -13786,7 +13936,7 @@ class GetAlias extends _index.DeviceApiCall { } exports.GetAlias = GetAlias; -},{"../../packages/device-api/index.js":2,"./__generated__/validators.zod.js":59}],61:[function(require,module,exports){ +},{"../../packages/device-api/index.js":2,"./__generated__/validators.zod.js":60}],62:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -13794,7 +13944,8 @@ Object.defineProperty(exports, "__esModule", { }); exports.AndroidTransport = void 0; var _index = require("../../../packages/device-api/index.js"); -var _deviceApiCalls = require("../__generated__/deviceApiCalls.js"); +var _messaging = require("../../../packages/messaging/messaging.js"); +var _android = require("../../../packages/messaging/android.js"); class AndroidTransport extends _index.DeviceApiTransport { /** @type {GlobalConfig} */ config; @@ -13803,133 +13954,39 @@ class AndroidTransport extends _index.DeviceApiTransport { constructor(globalConfig) { super(); this.config = globalConfig; - if (this.config.isDDGTestMode) { - if (typeof window.BrowserAutofill?.getAutofillData !== 'function') { - console.warn('window.BrowserAutofill.getAutofillData missing'); - } - if (typeof window.BrowserAutofill?.storeFormData !== 'function') { - console.warn('window.BrowserAutofill.storeFormData missing'); - } - } + const messageHandlerNames = ['EmailProtectionStoreUserData', 'EmailProtectionRemoveUserData', 'EmailProtectionGetUserData', 'EmailProtectionGetCapabilities', 'EmailProtectionGetAlias', 'SetIncontextSignupPermanentlyDismissedAt', 'StartEmailProtectionSignup', 'CloseEmailProtectionTab', 'ShowInContextEmailProtectionSignupPrompt', 'StoreFormData', 'GetIncontextSignupDismissedAt', 'GetRuntimeConfiguration', 'GetAutofillData']; + const androidMessagingConfig = new _android.AndroidMessagingConfig({ + messageHandlerNames + }); + this.messaging = new _messaging.Messaging(androidMessagingConfig); } /** * @param {import("../../../packages/device-api").DeviceApiCall} deviceApiCall * @returns {Promise} */ async send(deviceApiCall) { - if (deviceApiCall instanceof _deviceApiCalls.GetRuntimeConfigurationCall) { - return androidSpecificRuntimeConfiguration(this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.GetAvailableInputTypesCall) { - return androidSpecificAvailableInputTypes(this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.GetIncontextSignupDismissedAtCall) { - window.BrowserAutofill.getIncontextSignupDismissedAt(JSON.stringify(deviceApiCall.params)); - return waitForResponse(deviceApiCall.id, this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.SetIncontextSignupPermanentlyDismissedAtCall) { - return window.BrowserAutofill.setIncontextSignupPermanentlyDismissedAt(JSON.stringify(deviceApiCall.params)); - } - if (deviceApiCall instanceof _deviceApiCalls.StartEmailProtectionSignupCall) { - return window.BrowserAutofill.startEmailProtectionSignup(JSON.stringify(deviceApiCall.params)); - } - if (deviceApiCall instanceof _deviceApiCalls.CloseEmailProtectionTabCall) { - return window.BrowserAutofill.closeEmailProtectionTab(JSON.stringify(deviceApiCall.params)); - } - if (deviceApiCall instanceof _deviceApiCalls.ShowInContextEmailProtectionSignupPromptCall) { - window.BrowserAutofill.showInContextEmailProtectionSignupPrompt(JSON.stringify(deviceApiCall.params)); - return waitForResponse(deviceApiCall.id, this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.GetAutofillDataCall) { - window.BrowserAutofill.getAutofillData(JSON.stringify(deviceApiCall.params)); - return waitForResponse(deviceApiCall.id, this.config); - } - if (deviceApiCall instanceof _deviceApiCalls.StoreFormDataCall) { - return window.BrowserAutofill.storeFormData(JSON.stringify(deviceApiCall.params)); - } - throw new Error('android: not implemented: ' + deviceApiCall.method); - } -} - -/** - * @param {string} expectedResponse - the name/id of the response - * @param {GlobalConfig} config - * @returns {Promise<*>} - */ -exports.AndroidTransport = AndroidTransport; -function waitForResponse(expectedResponse, config) { - return new Promise(resolve => { - const handler = e => { - if (!config.isDDGTestMode) { - if (e.origin !== '') { - return; - } - } - if (!e.data) { - return; - } - if (typeof e.data !== 'string') { - if (config.isDDGTestMode) { - console.log('❌ event.data was not a string. Expected a string so that it can be JSON parsed'); - } - return; + try { + // if the call has an `id`, it means that it expects a response + if (deviceApiCall.id) { + return await this.messaging.request(deviceApiCall.method, deviceApiCall.params || undefined); + } else { + return this.messaging.notify(deviceApiCall.method, deviceApiCall.params || undefined); } - try { - let data = JSON.parse(e.data); - if (data.type === expectedResponse) { - window.removeEventListener('message', handler); - return resolve(data); - } - if (config.isDDGTestMode) { - console.log(`❌ event.data.type was '${data.type}', which didnt match '${expectedResponse}'`, JSON.stringify(data)); - } - } catch (e) { - window.removeEventListener('message', handler); - if (config.isDDGTestMode) { - console.log('❌ Could not JSON.parse the response'); + } catch (e) { + if (e instanceof _messaging.MissingHandler) { + if (this.config.isDDGTestMode) { + console.log('MissingAndroidHandler error for:', deviceApiCall.method); } + throw new Error('unimplemented handler: ' + deviceApiCall.method); + } else { + throw e; } - }; - window.addEventListener('message', handler); - }); -} - -/** - * @param {GlobalConfig} globalConfig - * @returns {{success: import('../__generated__/validators-ts').RuntimeConfiguration}} - */ -function androidSpecificRuntimeConfiguration(globalConfig) { - if (!globalConfig.userPreferences) { - throw new Error('globalConfig.userPreferences not supported yet on Android'); - } - return { - success: { - // @ts-ignore - contentScope: globalConfig.contentScope, - // @ts-ignore - userPreferences: globalConfig.userPreferences, - // @ts-ignore - userUnprotectedDomains: globalConfig.userUnprotectedDomains, - // @ts-ignore - availableInputTypes: globalConfig.availableInputTypes } - }; -} - -/** - * @param {GlobalConfig} globalConfig - * @returns {{success: import('../__generated__/validators-ts').AvailableInputTypes}} - */ -function androidSpecificAvailableInputTypes(globalConfig) { - if (!globalConfig.availableInputTypes) { - throw new Error('globalConfig.availableInputTypes not supported yet on Android'); } - return { - success: globalConfig.availableInputTypes - }; } +exports.AndroidTransport = AndroidTransport; -},{"../../../packages/device-api/index.js":2,"../__generated__/deviceApiCalls.js":58}],62:[function(require,module,exports){ +},{"../../../packages/device-api/index.js":2,"../../../packages/messaging/android.js":5,"../../../packages/messaging/messaging.js":6}],63:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -13972,7 +14029,7 @@ class AppleTransport extends _index.DeviceApiTransport { } exports.AppleTransport = AppleTransport; -},{"../../../packages/device-api/index.js":2,"../../../packages/messaging/messaging.js":5}],63:[function(require,module,exports){ +},{"../../../packages/device-api/index.js":2,"../../../packages/messaging/messaging.js":6}],64:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14126,7 +14183,7 @@ async function extensionSpecificSetIncontextSignupPermanentlyDismissedAtCall(par }); } -},{"../../../packages/device-api/index.js":2,"../../Settings.js":41,"../../autofill-utils.js":54,"../__generated__/deviceApiCalls.js":58}],64:[function(require,module,exports){ +},{"../../../packages/device-api/index.js":2,"../../Settings.js":42,"../../autofill-utils.js":55,"../__generated__/deviceApiCalls.js":59}],65:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14170,7 +14227,7 @@ function createTransport(globalConfig) { return new _extensionTransport.ExtensionTransport(globalConfig); } -},{"./android.transport.js":61,"./apple.transport.js":62,"./extension.transport.js":63,"./windows.transport.js":65}],65:[function(require,module,exports){ +},{"./android.transport.js":62,"./apple.transport.js":63,"./extension.transport.js":64,"./windows.transport.js":66}],66:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -14255,7 +14312,7 @@ function waitForWindowsResponse(responseId, options) { }); } -},{"../../../packages/device-api/index.js":2}],66:[function(require,module,exports){ +},{"../../../packages/device-api/index.js":2}],67:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -14356,7 +14413,7 @@ module.exports={ } } -},{}],67:[function(require,module,exports){ +},{}],68:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -14457,7 +14514,7 @@ module.exports={ } } -},{}],68:[function(require,module,exports){ +},{}],69:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -14558,7 +14615,7 @@ module.exports={ } } -},{}],69:[function(require,module,exports){ +},{}],70:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -14659,7 +14716,7 @@ module.exports={ } } -},{}],70:[function(require,module,exports){ +},{}],71:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -14760,7 +14817,7 @@ module.exports={ } } -},{}],71:[function(require,module,exports){ +},{}],72:[function(require,module,exports){ module.exports={ "smartling": { "string_format": "icu", @@ -14862,7 +14919,7 @@ module.exports={ } } -},{}],72:[function(require,module,exports){ +},{}],73:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -14963,7 +15020,7 @@ module.exports={ } } -},{}],73:[function(require,module,exports){ +},{}],74:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15064,7 +15121,7 @@ module.exports={ } } -},{}],74:[function(require,module,exports){ +},{}],75:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15165,7 +15222,7 @@ module.exports={ } } -},{}],75:[function(require,module,exports){ +},{}],76:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15266,7 +15323,7 @@ module.exports={ } } -},{}],76:[function(require,module,exports){ +},{}],77:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15367,7 +15424,7 @@ module.exports={ } } -},{}],77:[function(require,module,exports){ +},{}],78:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15468,7 +15525,7 @@ module.exports={ } } -},{}],78:[function(require,module,exports){ +},{}],79:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15569,7 +15626,7 @@ module.exports={ } } -},{}],79:[function(require,module,exports){ +},{}],80:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15670,7 +15727,7 @@ module.exports={ } } -},{}],80:[function(require,module,exports){ +},{}],81:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15771,7 +15828,7 @@ module.exports={ } } -},{}],81:[function(require,module,exports){ +},{}],82:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15872,7 +15929,7 @@ module.exports={ } } -},{}],82:[function(require,module,exports){ +},{}],83:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -15973,7 +16030,7 @@ module.exports={ } } -},{}],83:[function(require,module,exports){ +},{}],84:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -16074,7 +16131,7 @@ module.exports={ } } -},{}],84:[function(require,module,exports){ +},{}],85:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -16175,7 +16232,7 @@ module.exports={ } } -},{}],85:[function(require,module,exports){ +},{}],86:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -16276,7 +16333,7 @@ module.exports={ } } -},{}],86:[function(require,module,exports){ +},{}],87:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -16377,7 +16434,7 @@ module.exports={ } } -},{}],87:[function(require,module,exports){ +},{}],88:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -16478,7 +16535,7 @@ module.exports={ } } -},{}],88:[function(require,module,exports){ +},{}],89:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -16579,7 +16636,7 @@ module.exports={ } } -},{}],89:[function(require,module,exports){ +},{}],90:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -16667,7 +16724,7 @@ function translateImpl(library, namespacedId, opts) { return out; } -},{"./translations.js":92}],90:[function(require,module,exports){ +},{"./translations.js":93}],91:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -16768,7 +16825,7 @@ module.exports={ } } -},{}],91:[function(require,module,exports){ +},{}],92:[function(require,module,exports){ module.exports={ "smartling" : { "string_format" : "icu", @@ -16869,7 +16926,7 @@ module.exports={ } } -},{}],92:[function(require,module,exports){ +},{}],93:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -16988,7 +17045,7 @@ var _default = exports.default = { } }; -},{"./bg/autofill.json":66,"./cs/autofill.json":67,"./da/autofill.json":68,"./de/autofill.json":69,"./el/autofill.json":70,"./en/autofill.json":71,"./es/autofill.json":72,"./et/autofill.json":73,"./fi/autofill.json":74,"./fr/autofill.json":75,"./hr/autofill.json":76,"./hu/autofill.json":77,"./it/autofill.json":78,"./lt/autofill.json":79,"./lv/autofill.json":80,"./nb/autofill.json":81,"./nl/autofill.json":82,"./pl/autofill.json":83,"./pt/autofill.json":84,"./ro/autofill.json":85,"./ru/autofill.json":86,"./sk/autofill.json":87,"./sl/autofill.json":88,"./sv/autofill.json":90,"./tr/autofill.json":91,"./xa/autofill.json":93}],93:[function(require,module,exports){ +},{"./bg/autofill.json":67,"./cs/autofill.json":68,"./da/autofill.json":69,"./de/autofill.json":70,"./el/autofill.json":71,"./en/autofill.json":72,"./es/autofill.json":73,"./et/autofill.json":74,"./fi/autofill.json":75,"./fr/autofill.json":76,"./hr/autofill.json":77,"./hu/autofill.json":78,"./it/autofill.json":79,"./lt/autofill.json":80,"./lv/autofill.json":81,"./nb/autofill.json":82,"./nl/autofill.json":83,"./pl/autofill.json":84,"./pt/autofill.json":85,"./ro/autofill.json":86,"./ru/autofill.json":87,"./sk/autofill.json":88,"./sl/autofill.json":89,"./sv/autofill.json":91,"./tr/autofill.json":92,"./xa/autofill.json":94}],94:[function(require,module,exports){ module.exports={ "smartling": { "string_format": "icu", @@ -17081,7 +17138,7 @@ module.exports={ "note": "Button that prevents the DuckDuckGo email protection signup prompt from appearing again." } } -},{}],94:[function(require,module,exports){ +},{}],95:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -17124,4 +17181,4 @@ window.cancelIdleCallback = window.cancelIdleCallback || function (id) { }; var _default = exports.default = {}; -},{}]},{},[55]); +},{}]},{},[56]); diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js index 522fd66f58b4..939df52f5f64 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js @@ -7899,6 +7899,10 @@ if (this.getFeatureSettingEnabled('screenLock')) { this.screenLockFix(); } + + if (this.getFeatureSettingEnabled('modifyLocalStorage')) { + this.modifyLocalStorage(); + } } /** Shim Web Share API in Android WebView */ @@ -8313,6 +8317,22 @@ } } + /** + * Support for modifying localStorage entries + */ + modifyLocalStorage () { + /** @type {import('../types//webcompat-settings').WebCompatSettings['modifyLocalStorage']} */ + const settings = this.getFeatureSetting('modifyLocalStorage'); + + if (!settings || !settings.changes) return + + settings.changes.forEach((change) => { + if (change.action === 'delete') { + localStorage.removeItem(change.key); + } + }); + } + /** * Support for proxying `window.webkit.messageHandlers` */ @@ -14043,8 +14063,6 @@ return } - console.log('did get initial setup ', initialSetup); - if (!initialSetup) { console.error('cannot continue without user settings'); return @@ -14387,7 +14405,7 @@ const locale = args?.locale || args?.language || 'en'; const env = new Environment({ - debug: true, + debug: args.debug, injectName: "android", platform: this.platform, locale diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css index de2906b8f108..f0299a0ba328 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css @@ -48,7 +48,7 @@ body[data-display=app] { padding: 16px; } -/* pages/duckplayer/app/components/Fallback.module.css */ +/* shared/components/Fallback/Fallback.module.css */ .Fallback_fallback { height: 100%; width: 100%; diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js index 59dac3b2756d..7345cb0b5600 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js @@ -1135,7 +1135,7 @@ } catch (e3) { console.error("could not access handlers for %s, falling back to mock interface", opts.injectName); } - const fallback = new TestTransportConfig({ + const fallback = opts.mockTransport?.() || new TestTransportConfig({ /** * @param {import('@duckduckgo/messaging').NotificationMessage} msg */ @@ -2258,12 +2258,12 @@ return q2(UserValuesContext).setEnabled; } - // pages/duckplayer/app/components/Fallback.module.css + // shared/components/Fallback/Fallback.module.css var Fallback_default = { fallback: "Fallback_fallback" }; - // pages/duckplayer/app/components/Fallback.jsx + // shared/components/Fallback/Fallback.jsx function Fallback({ showDetails }) { return /* @__PURE__ */ y("div", { class: Fallback_default.fallback }, /* @__PURE__ */ y("div", null, /* @__PURE__ */ y("p", null, "Something went wrong!"), showDetails && /* @__PURE__ */ y("p", null, "Please check logs for a message called ", /* @__PURE__ */ y("code", null, "reportPageException")))); } diff --git a/node_modules/@duckduckgo/privacy-dashboard/build/app/img/refresh-assets/Phishing-128.svg b/node_modules/@duckduckgo/privacy-dashboard/build/app/img/refresh-assets/Phishing-128.svg new file mode 100644 index 000000000000..bac34c4036c7 --- /dev/null +++ b/node_modules/@duckduckgo/privacy-dashboard/build/app/img/refresh-assets/Phishing-128.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/android.css b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/android.css index 05346486ef14..b21ec65a2e2a 100644 --- a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/android.css +++ b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/android.css @@ -1,793 +1,3 @@ -.mdc-elevation-overlay { - position: absolute; - border-radius: inherit; - pointer-events: none; - opacity: 0; - /* @alternate */ - opacity: var(--mdc-elevation-overlay-opacity, 0); - transition: opacity 280ms cubic-bezier(0.4, 0, 0.2, 1); - background-color: #fff; - /* @alternate */ - background-color: var(--mdc-elevation-overlay-color, #fff); -} - -.mdc-switch { - align-items: center; - background: none; - border: none; - cursor: pointer; - display: inline-flex; - flex-shrink: 0; - margin: 0; - outline: none; - overflow: visible; - padding: 0; - position: relative; -} -.mdc-switch:disabled { - cursor: default; - pointer-events: none; -} - -.mdc-switch__track { - overflow: hidden; - position: relative; - width: 100%; -} -.mdc-switch__track::before, .mdc-switch__track::after { - border: 1px solid transparent; - border-radius: inherit; - box-sizing: border-box; - content: ""; - height: 100%; - /* @noflip */ /*rtl:ignore*/ - left: 0; - position: absolute; - width: 100%; -} -@media screen and (forced-colors: active) { - .mdc-switch__track::before, .mdc-switch__track::after { - border-color: currentColor; - } -} -.mdc-switch__track::before { - transition: transform 75ms 0ms cubic-bezier(0, 0, 0.2, 1); - transform: translateX(0); -} -.mdc-switch__track::after { - transition: transform 75ms 0ms cubic-bezier(0.4, 0, 0.6, 1); - transform: translateX(-100%); -} -[dir=rtl] .mdc-switch__track::after, .mdc-switch__track[dir=rtl]::after { - /*rtl:begin:ignore*/ - transform: translateX(100%); - /*rtl:end:ignore*/ -} - -.mdc-switch--selected .mdc-switch__track::before { - transition: transform 75ms 0ms cubic-bezier(0.4, 0, 0.6, 1); - transform: translateX(100%); -} -[dir=rtl] .mdc-switch--selected .mdc-switch__track::before, .mdc-switch--selected .mdc-switch__track[dir=rtl]::before { - /*rtl:begin:ignore*/ - transform: translateX(-100%); - /*rtl:end:ignore*/ -} - -.mdc-switch--selected .mdc-switch__track::after { - transition: transform 75ms 0ms cubic-bezier(0, 0, 0.2, 1); - transform: translateX(0); -} - -.mdc-switch__handle-track { - height: 100%; - pointer-events: none; - position: absolute; - top: 0; - transition: transform 75ms 0ms cubic-bezier(0.4, 0, 0.2, 1); - /* @noflip */ /*rtl:ignore*/ - left: 0; - /* @noflip */ /*rtl:ignore*/ - right: auto; - transform: translateX(0); -} -[dir=rtl] .mdc-switch__handle-track, .mdc-switch__handle-track[dir=rtl] { - /*rtl:begin:ignore*/ - /* @noflip */ /*rtl:ignore*/ - left: auto; - /* @noflip */ /*rtl:ignore*/ - right: 0; - /*rtl:end:ignore*/ -} - -.mdc-switch--selected .mdc-switch__handle-track { - transform: translateX(100%); -} -[dir=rtl] .mdc-switch--selected .mdc-switch__handle-track, .mdc-switch--selected .mdc-switch__handle-track[dir=rtl] { - /*rtl:begin:ignore*/ - transform: translateX(-100%); - /*rtl:end:ignore*/ -} - -.mdc-switch__handle { - display: flex; - pointer-events: auto; - position: absolute; - top: 50%; - transform: translateY(-50%); - /* @noflip */ /*rtl:ignore*/ - left: 0; - /* @noflip */ /*rtl:ignore*/ - right: auto; -} -[dir=rtl] .mdc-switch__handle, .mdc-switch__handle[dir=rtl] { - /*rtl:begin:ignore*/ - /* @noflip */ /*rtl:ignore*/ - left: auto; - /* @noflip */ /*rtl:ignore*/ - right: 0; - /*rtl:end:ignore*/ -} - -.mdc-switch__handle::before, .mdc-switch__handle::after { - border: 1px solid transparent; - border-radius: inherit; - box-sizing: border-box; - content: ""; - width: 100%; - height: 100%; - /* @noflip */ /*rtl:ignore*/ - left: 0; - position: absolute; - top: 0; - transition: background-color 75ms 0ms cubic-bezier(0.4, 0, 0.2, 1), border-color 75ms 0ms cubic-bezier(0.4, 0, 0.2, 1); - z-index: -1; -} -@media screen and (forced-colors: active) { - .mdc-switch__handle::before, .mdc-switch__handle::after { - border-color: currentColor; - } -} - -.mdc-switch__shadow { - border-radius: inherit; - bottom: 0; - /* @noflip */ /*rtl:ignore*/ - left: 0; - position: absolute; - /* @noflip */ /*rtl:ignore*/ - right: 0; - top: 0; -} - -.mdc-elevation-overlay { - bottom: 0; - /* @noflip */ /*rtl:ignore*/ - left: 0; - /* @noflip */ /*rtl:ignore*/ - right: 0; - top: 0; -} - -.mdc-switch__ripple { - /* @noflip */ /*rtl:ignore*/ - left: 50%; - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - z-index: -1; -} -.mdc-switch:disabled .mdc-switch__ripple { - display: none; -} - -.mdc-switch__icons { - height: 100%; - position: relative; - width: 100%; - z-index: 1; -} - -.mdc-switch__icon { - bottom: 0; - /* @noflip */ /*rtl:ignore*/ - left: 0; - margin: auto; - position: absolute; - /* @noflip */ /*rtl:ignore*/ - right: 0; - top: 0; - opacity: 0; - transition: opacity 30ms 0ms cubic-bezier(0.4, 0, 1, 1); -} - -.mdc-switch--selected .mdc-switch__icon--on, -.mdc-switch--unselected .mdc-switch__icon--off { - opacity: 1; - transition: opacity 45ms 30ms cubic-bezier(0, 0, 0.2, 1); -} - -.mdc-switch { - --mdc-ripple-fg-size: 0; - --mdc-ripple-left: 0; - --mdc-ripple-top: 0; - --mdc-ripple-fg-scale: 1; - --mdc-ripple-fg-translate-end: 0; - --mdc-ripple-fg-translate-start: 0; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); - will-change: transform, opacity; -} -@keyframes mdc-ripple-fg-radius-in { - from { - animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transform: translate(var(--mdc-ripple-fg-translate-start, 0)) scale(1); - } - to { - transform: translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1)); - } -} -@keyframes mdc-ripple-fg-opacity-in { - from { - animation-timing-function: linear; - opacity: 0; - } - to { - opacity: var(--mdc-ripple-fg-opacity, 0); - } -} -@keyframes mdc-ripple-fg-opacity-out { - from { - animation-timing-function: linear; - opacity: var(--mdc-ripple-fg-opacity, 0); - } - to { - opacity: 0; - } -} -.mdc-switch .mdc-switch__ripple::before, -.mdc-switch .mdc-switch__ripple::after { - position: absolute; - border-radius: 50%; - opacity: 0; - pointer-events: none; - content: ""; -} -.mdc-switch .mdc-switch__ripple::before { - transition: opacity 15ms linear, background-color 15ms linear; - z-index: 1; - /* @alternate */ - z-index: var(--mdc-ripple-z-index, 1); -} -.mdc-switch .mdc-switch__ripple::after { - z-index: 0; - /* @alternate */ - z-index: var(--mdc-ripple-z-index, 0); -} -.mdc-switch.mdc-ripple-upgraded .mdc-switch__ripple::before { - transform: scale(var(--mdc-ripple-fg-scale, 1)); -} -.mdc-switch.mdc-ripple-upgraded .mdc-switch__ripple::after { - top: 0; - /* @noflip */ /*rtl:ignore*/ - left: 0; - transform: scale(0); - transform-origin: center center; -} -.mdc-switch.mdc-ripple-upgraded--unbounded .mdc-switch__ripple::after { - top: var(--mdc-ripple-top, 0); - /* @noflip */ /*rtl:ignore*/ - left: var(--mdc-ripple-left, 0); -} -.mdc-switch.mdc-ripple-upgraded--foreground-activation .mdc-switch__ripple::after { - animation: mdc-ripple-fg-radius-in 225ms forwards, mdc-ripple-fg-opacity-in 75ms forwards; -} -.mdc-switch.mdc-ripple-upgraded--foreground-deactivation .mdc-switch__ripple::after { - animation: mdc-ripple-fg-opacity-out 150ms; - transform: translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1)); -} -.mdc-switch .mdc-switch__ripple::before, -.mdc-switch .mdc-switch__ripple::after { - top: calc(50% - 50%); - /* @noflip */ /*rtl:ignore*/ - left: calc(50% - 50%); - width: 100%; - height: 100%; -} -.mdc-switch.mdc-ripple-upgraded .mdc-switch__ripple::before, -.mdc-switch.mdc-ripple-upgraded .mdc-switch__ripple::after { - top: var(--mdc-ripple-top, calc(50% - 50%)); - /* @noflip */ /*rtl:ignore*/ - left: var(--mdc-ripple-left, calc(50% - 50%)); - width: var(--mdc-ripple-fg-size, 100%); - height: var(--mdc-ripple-fg-size, 100%); -} -.mdc-switch.mdc-ripple-upgraded .mdc-switch__ripple::after { - width: var(--mdc-ripple-fg-size, 100%); - height: var(--mdc-ripple-fg-size, 100%); -} -.mdc-switch .mdc-switch__focus-ring-wrapper { - width: 100%; - position: absolute; - top: 50%; - /* @noflip */ /*rtl:ignore*/ - left: 50%; - /* @noflip */ /*rtl:ignore*/ - transform: translate(-50%, -50%); -} -.mdc-switch.mdc-ripple-upgraded--background-focused .mdc-switch__focus-ring, .mdc-switch:not(.mdc-ripple-upgraded):focus .mdc-switch__focus-ring { - pointer-events: none; - border: 2px solid transparent; - border-radius: 6px; - box-sizing: content-box; - position: absolute; - top: 50%; - /* @noflip */ /*rtl:ignore*/ - left: 50%; - /* @noflip */ /*rtl:ignore*/ - transform: translate(-50%, -50%); - height: calc( - 100% + 4px - ); - width: calc( - 100% + 4px - ); -} -@media screen and (forced-colors: active) { - .mdc-switch.mdc-ripple-upgraded--background-focused .mdc-switch__focus-ring, .mdc-switch:not(.mdc-ripple-upgraded):focus .mdc-switch__focus-ring { - border-color: CanvasText; - } -} -.mdc-switch.mdc-ripple-upgraded--background-focused .mdc-switch__focus-ring::after, .mdc-switch:not(.mdc-ripple-upgraded):focus .mdc-switch__focus-ring::after { - content: ""; - border: 2px solid transparent; - border-radius: 8px; - display: block; - position: absolute; - top: 50%; - /* @noflip */ /*rtl:ignore*/ - left: 50%; - /* @noflip */ /*rtl:ignore*/ - transform: translate(-50%, -50%); - height: calc(100% + 4px); - width: calc(100% + 4px); -} -@media screen and (forced-colors: active) { - .mdc-switch.mdc-ripple-upgraded--background-focused .mdc-switch__focus-ring::after, .mdc-switch:not(.mdc-ripple-upgraded):focus .mdc-switch__focus-ring::after { - border-color: CanvasText; - } -} - -.mdc-switch { - width: 36px; - /* @alternate */ - width: var(--mdc-switch-track-width, 36px); -} -.mdc-switch.mdc-switch--selected:enabled .mdc-switch__handle::after { - background: #3969ef; - /* @alternate */ - background: var(--mdc-switch-selected-handle-color, var(--mdc-theme-primary, #3969ef)); -} - -.mdc-switch.mdc-switch--selected:enabled:hover:not(:focus):not(:active) .mdc-switch__handle::after { - background: #394978; - /* @alternate */ - background: var(--mdc-switch-selected-hover-handle-color, #394978); -} - -.mdc-switch.mdc-switch--selected:enabled:focus:not(:active) .mdc-switch__handle::after { - background: #394978; - /* @alternate */ - background: var(--mdc-switch-selected-focus-handle-color, #394978); -} - -.mdc-switch.mdc-switch--selected:enabled:active .mdc-switch__handle::after { - background: #394978; - /* @alternate */ - background: var(--mdc-switch-selected-pressed-handle-color, #394978); -} - -.mdc-switch.mdc-switch--selected:disabled .mdc-switch__handle::after { - background: #424242; - /* @alternate */ - background: var(--mdc-switch-disabled-selected-handle-color, #424242); -} - -.mdc-switch.mdc-switch--unselected:enabled .mdc-switch__handle::after { - background: #616161; - /* @alternate */ - background: var(--mdc-switch-unselected-handle-color, #616161); -} - -.mdc-switch.mdc-switch--unselected:enabled:hover:not(:focus):not(:active) .mdc-switch__handle::after { - background: #212121; - /* @alternate */ - background: var(--mdc-switch-unselected-hover-handle-color, #212121); -} - -.mdc-switch.mdc-switch--unselected:enabled:focus:not(:active) .mdc-switch__handle::after { - background: #212121; - /* @alternate */ - background: var(--mdc-switch-unselected-focus-handle-color, #212121); -} - -.mdc-switch.mdc-switch--unselected:enabled:active .mdc-switch__handle::after { - background: #212121; - /* @alternate */ - background: var(--mdc-switch-unselected-pressed-handle-color, #212121); -} - -.mdc-switch.mdc-switch--unselected:disabled .mdc-switch__handle::after { - background: #424242; - /* @alternate */ - background: var(--mdc-switch-disabled-unselected-handle-color, #424242); -} - -.mdc-switch .mdc-switch__handle::before { - background: #fff; - /* @alternate */ - background: var(--mdc-switch-handle-surface-color, var(--mdc-theme-surface, #fff)); -} -.mdc-switch:enabled .mdc-switch__shadow { - --mdc-elevation-box-shadow-for-gss: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12); - box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12); - /* @alternate */ - box-shadow: var(--mdc-switch-handle-elevation, var(--mdc-elevation-box-shadow-for-gss)); -} -.mdc-switch:disabled .mdc-switch__shadow { - --mdc-elevation-box-shadow-for-gss: 0px 0px 0px 0px rgba(0, 0, 0, 0.2), 0px 0px 0px 0px rgba(0, 0, 0, 0.14), 0px 0px 0px 0px rgba(0, 0, 0, 0.12); - box-shadow: 0px 0px 0px 0px rgba(0, 0, 0, 0.2), 0px 0px 0px 0px rgba(0, 0, 0, 0.14), 0px 0px 0px 0px rgba(0, 0, 0, 0.12); - /* @alternate */ - box-shadow: var(--mdc-switch-disabled-handle-elevation, var(--mdc-elevation-box-shadow-for-gss)); -} -.mdc-switch .mdc-switch__focus-ring-wrapper, -.mdc-switch .mdc-switch__handle { - height: 20px; - /* @alternate */ - height: var(--mdc-switch-handle-height, 20px); -} -.mdc-switch:disabled .mdc-switch__handle::after { - opacity: 0.38; - /* @alternate */ - opacity: var(--mdc-switch-disabled-handle-opacity, 0.38); -} - -.mdc-switch .mdc-switch__handle { - border-radius: 10px; - /* @alternate */ - border-radius: var(--mdc-switch-handle-shape, 10px); -} -.mdc-switch .mdc-switch__handle { - width: 20px; - /* @alternate */ - width: var(--mdc-switch-handle-width, 20px); -} -.mdc-switch .mdc-switch__handle-track { - width: calc(100% - 20px); - /* @alternate */ - width: calc(100% - var(--mdc-switch-handle-width, 20px)); -} -.mdc-switch.mdc-switch--selected:enabled .mdc-switch__icon { - fill: #fff; - /* @alternate */ - fill: var(--mdc-switch-selected-icon-color, var(--mdc-theme-on-primary, #fff)); -} - -.mdc-switch.mdc-switch--selected:disabled .mdc-switch__icon { - fill: #fff; - /* @alternate */ - fill: var(--mdc-switch-disabled-selected-icon-color, var(--mdc-theme-on-primary, #fff)); -} - -.mdc-switch.mdc-switch--unselected:enabled .mdc-switch__icon { - fill: #fff; - /* @alternate */ - fill: var(--mdc-switch-unselected-icon-color, var(--mdc-theme-on-primary, #fff)); -} - -.mdc-switch.mdc-switch--unselected:disabled .mdc-switch__icon { - fill: #fff; - /* @alternate */ - fill: var(--mdc-switch-disabled-unselected-icon-color, var(--mdc-theme-on-primary, #fff)); -} - -.mdc-switch.mdc-switch--selected:disabled .mdc-switch__icons { - opacity: 0.38; - /* @alternate */ - opacity: var(--mdc-switch-disabled-selected-icon-opacity, 0.38); -} - -.mdc-switch.mdc-switch--unselected:disabled .mdc-switch__icons { - opacity: 0.38; - /* @alternate */ - opacity: var(--mdc-switch-disabled-unselected-icon-opacity, 0.38); -} - -.mdc-switch.mdc-switch--selected .mdc-switch__icon { - width: 18px; - /* @alternate */ - width: var(--mdc-switch-selected-icon-size, 18px); - height: 18px; - /* @alternate */ - height: var(--mdc-switch-selected-icon-size, 18px); -} - -.mdc-switch.mdc-switch--unselected .mdc-switch__icon { - width: 18px; - /* @alternate */ - width: var(--mdc-switch-unselected-icon-size, 18px); - height: 18px; - /* @alternate */ - height: var(--mdc-switch-unselected-icon-size, 18px); -} - -.mdc-switch.mdc-switch--selected:enabled:hover:not(:focus) .mdc-switch__ripple::before, -.mdc-switch.mdc-switch--selected:enabled:hover:not(:focus) .mdc-switch__ripple::after { - background-color: #3969ef; - /* @alternate */ - background-color: var(--mdc-switch-selected-hover-state-layer-color, var(--mdc-theme-primary, #3969ef)); -} - -.mdc-switch.mdc-switch--selected:enabled:focus .mdc-switch__ripple::before, -.mdc-switch.mdc-switch--selected:enabled:focus .mdc-switch__ripple::after { - background-color: #3969ef; - /* @alternate */ - background-color: var(--mdc-switch-selected-focus-state-layer-color, var(--mdc-theme-primary, #3969ef)); -} - -.mdc-switch.mdc-switch--selected:enabled:active .mdc-switch__ripple::before, -.mdc-switch.mdc-switch--selected:enabled:active .mdc-switch__ripple::after { - background-color: #3969ef; - /* @alternate */ - background-color: var(--mdc-switch-selected-pressed-state-layer-color, var(--mdc-theme-primary, #3969ef)); -} - -.mdc-switch.mdc-switch--unselected:enabled:hover:not(:focus) .mdc-switch__ripple::before, -.mdc-switch.mdc-switch--unselected:enabled:hover:not(:focus) .mdc-switch__ripple::after { - background-color: #424242; - /* @alternate */ - background-color: var(--mdc-switch-unselected-hover-state-layer-color, #424242); -} - -.mdc-switch.mdc-switch--unselected:enabled:focus .mdc-switch__ripple::before, -.mdc-switch.mdc-switch--unselected:enabled:focus .mdc-switch__ripple::after { - background-color: #424242; - /* @alternate */ - background-color: var(--mdc-switch-unselected-focus-state-layer-color, #424242); -} - -.mdc-switch.mdc-switch--unselected:enabled:active .mdc-switch__ripple::before, -.mdc-switch.mdc-switch--unselected:enabled:active .mdc-switch__ripple::after { - background-color: #424242; - /* @alternate */ - background-color: var(--mdc-switch-unselected-pressed-state-layer-color, #424242); -} - -.mdc-switch.mdc-switch--selected:enabled:hover:not(:focus):hover .mdc-switch__ripple::before, .mdc-switch.mdc-switch--selected:enabled:hover:not(:focus).mdc-ripple-surface--hover .mdc-switch__ripple::before { - opacity: 0.04; - /* @alternate */ - opacity: var(--mdc-switch-selected-hover-state-layer-opacity, 0.04); -} - -.mdc-switch.mdc-switch--selected:enabled:focus.mdc-ripple-upgraded--background-focused .mdc-switch__ripple::before, .mdc-switch.mdc-switch--selected:enabled:focus:not(.mdc-ripple-upgraded):focus .mdc-switch__ripple::before { - transition-duration: 75ms; - opacity: 0.12; - /* @alternate */ - opacity: var(--mdc-switch-selected-focus-state-layer-opacity, 0.12); -} - -.mdc-switch.mdc-switch--selected:enabled:active:not(.mdc-ripple-upgraded) .mdc-switch__ripple::after { - transition: opacity 150ms linear; -} -.mdc-switch.mdc-switch--selected:enabled:active:not(.mdc-ripple-upgraded):active .mdc-switch__ripple::after { - transition-duration: 75ms; - opacity: 0.1; - /* @alternate */ - opacity: var(--mdc-switch-selected-pressed-state-layer-opacity, 0.1); -} -.mdc-switch.mdc-switch--selected:enabled:active.mdc-ripple-upgraded { - --mdc-ripple-fg-opacity: var(--mdc-switch-selected-pressed-state-layer-opacity, 0.1); -} - -.mdc-switch.mdc-switch--unselected:enabled:hover:not(:focus):hover .mdc-switch__ripple::before, .mdc-switch.mdc-switch--unselected:enabled:hover:not(:focus).mdc-ripple-surface--hover .mdc-switch__ripple::before { - opacity: 0.04; - /* @alternate */ - opacity: var(--mdc-switch-unselected-hover-state-layer-opacity, 0.04); -} - -.mdc-switch.mdc-switch--unselected:enabled:focus.mdc-ripple-upgraded--background-focused .mdc-switch__ripple::before, .mdc-switch.mdc-switch--unselected:enabled:focus:not(.mdc-ripple-upgraded):focus .mdc-switch__ripple::before { - transition-duration: 75ms; - opacity: 0.12; - /* @alternate */ - opacity: var(--mdc-switch-unselected-focus-state-layer-opacity, 0.12); -} - -.mdc-switch.mdc-switch--unselected:enabled:active:not(.mdc-ripple-upgraded) .mdc-switch__ripple::after { - transition: opacity 150ms linear; -} -.mdc-switch.mdc-switch--unselected:enabled:active:not(.mdc-ripple-upgraded):active .mdc-switch__ripple::after { - transition-duration: 75ms; - opacity: 0.1; - /* @alternate */ - opacity: var(--mdc-switch-unselected-pressed-state-layer-opacity, 0.1); -} -.mdc-switch.mdc-switch--unselected:enabled:active.mdc-ripple-upgraded { - --mdc-ripple-fg-opacity: var(--mdc-switch-unselected-pressed-state-layer-opacity, 0.1); -} - -.mdc-switch .mdc-switch__ripple { - height: 48px; - /* @alternate */ - height: var(--mdc-switch-state-layer-size, 48px); - width: 48px; - /* @alternate */ - width: var(--mdc-switch-state-layer-size, 48px); -} -.mdc-switch .mdc-switch__track { - height: 14px; - /* @alternate */ - height: var(--mdc-switch-track-height, 14px); -} -.mdc-switch:disabled .mdc-switch__track { - opacity: 0.12; - /* @alternate */ - opacity: var(--mdc-switch-disabled-track-opacity, 0.12); -} - -.mdc-switch:enabled .mdc-switch__track::after { - background: #cedafb; - /* @alternate */ - background: var(--mdc-switch-selected-track-color, #cedafb); -} - -.mdc-switch:enabled:hover:not(:focus):not(:active) .mdc-switch__track::after { - background: #cedafb; - /* @alternate */ - background: var(--mdc-switch-selected-hover-track-color, #cedafb); -} - -.mdc-switch:enabled:focus:not(:active) .mdc-switch__track::after { - background: #cedafb; - /* @alternate */ - background: var(--mdc-switch-selected-focus-track-color, #cedafb); -} - -.mdc-switch:enabled:active .mdc-switch__track::after { - background: #cedafb; - /* @alternate */ - background: var(--mdc-switch-selected-pressed-track-color, #cedafb); -} - -.mdc-switch:disabled .mdc-switch__track::after { - background: #424242; - /* @alternate */ - background: var(--mdc-switch-disabled-selected-track-color, #424242); -} - -.mdc-switch:enabled .mdc-switch__track::before { - background: #e0e0e0; - /* @alternate */ - background: var(--mdc-switch-unselected-track-color, #e0e0e0); -} - -.mdc-switch:enabled:hover:not(:focus):not(:active) .mdc-switch__track::before { - background: #e0e0e0; - /* @alternate */ - background: var(--mdc-switch-unselected-hover-track-color, #e0e0e0); -} - -.mdc-switch:enabled:focus:not(:active) .mdc-switch__track::before { - background: #e0e0e0; - /* @alternate */ - background: var(--mdc-switch-unselected-focus-track-color, #e0e0e0); -} - -.mdc-switch:enabled:active .mdc-switch__track::before { - background: #e0e0e0; - /* @alternate */ - background: var(--mdc-switch-unselected-pressed-track-color, #e0e0e0); -} - -.mdc-switch:disabled .mdc-switch__track::before { - background: #424242; - /* @alternate */ - background: var(--mdc-switch-disabled-unselected-track-color, #424242); -} - -.mdc-switch .mdc-switch__track { - border-radius: 7px; - /* @alternate */ - border-radius: var(--mdc-switch-track-shape, 7px); -} - -@media screen and (forced-colors: active), (-ms-high-contrast: active) { - .mdc-switch:enabled .mdc-switch__shadow { - /* @alternate */ - } - .mdc-switch:disabled .mdc-switch__shadow { - /* @alternate */ - } - .mdc-switch:disabled .mdc-switch__handle::after { - opacity: 1; - /* @alternate */ - opacity: var(--mdc-switch-disabled-handle-opacity, 1); - } - .mdc-switch.mdc-switch--selected:enabled .mdc-switch__icon { - fill: ButtonText; - /* @alternate */ - fill: var(--mdc-switch-selected-icon-color, ButtonText); - } - .mdc-switch.mdc-switch--selected:disabled .mdc-switch__icon { - fill: GrayText; - /* @alternate */ - fill: var(--mdc-switch-disabled-selected-icon-color, GrayText); - } - .mdc-switch.mdc-switch--unselected:enabled .mdc-switch__icon { - fill: ButtonText; - /* @alternate */ - fill: var(--mdc-switch-unselected-icon-color, ButtonText); - } - .mdc-switch.mdc-switch--unselected:disabled .mdc-switch__icon { - fill: GrayText; - /* @alternate */ - fill: var(--mdc-switch-disabled-unselected-icon-color, GrayText); - } - .mdc-switch.mdc-switch--selected:disabled .mdc-switch__icons { - opacity: 1; - /* @alternate */ - opacity: var(--mdc-switch-disabled-selected-icon-opacity, 1); - } - .mdc-switch.mdc-switch--unselected:disabled .mdc-switch__icons { - opacity: 1; - /* @alternate */ - opacity: var(--mdc-switch-disabled-unselected-icon-opacity, 1); - } - .mdc-switch:disabled .mdc-switch__track { - opacity: 1; - /* @alternate */ - opacity: var(--mdc-switch-disabled-track-opacity, 1); - } -} -.mdc-switch { - --mdc-switch-selected-handle-color: #3969ef; - --mdc-switch-selected-hover-handle-color: #3969ef; - --mdc-switch-selected-pressed-handle-color: #3969ef; - --mdc-switch-selected-focus-handle-color: #3969ef; - --mdc-switch-unselected-handle-color: white; - --mdc-switch-unselected-hover-handle-color: white; - --mdc-switch-unselected-pressed-handle-color: white; - --mdc-switch-unselected-focus-handle-color: white; - --mdc-switch-selected-track-color: rgba(57, 105, 239, 0.38); - --mdc-switch-selected-hover-track-color: rgba(57, 105, 239, 0.38); - --mdc-switch-selected-focus-track-color: rgba(57, 105, 239, 0.38); - --mdc-switch-selected-pressed-track-color: rgba(57, 105, 239, 0.38); - --mdc-switch-unselected-track-color: rgba(102, 102, 102, 0.3); - --mdc-switch-unselected-hover-track-color: rgba(102, 102, 102, 0.3); - --mdc-switch-unselected-focus-track-color: rgba(102, 102, 102, 0.3); - --mdc-switch-unselected-pressed-track-color: rgba(102, 102, 102, 0.3); - --mdc-switch-selected-hover-state-layer-opacity: 0; - --mdc-switch-unselected-hover-state-layer-opacity: 0; -} -.body--theme-dark .mdc-switch { - --mdc-switch-selected-handle-color: #7295f6; - --mdc-switch-selected-hover-handle-color: #7295f6; - --mdc-switch-selected-pressed-handle-color: #7295f6; - --mdc-switch-selected-focus-handle-color: #7295f6; - --mdc-switch-unselected-handle-color: white; - --mdc-switch-unselected-hover-handle-color: white; - --mdc-switch-unselected-pressed-handle-color: white; - --mdc-switch-unselected-focus-handle-color: white; - --mdc-switch-selected-track-color: rgba(114, 149, 246, 0.38); - --mdc-switch-selected-hover-track-color: rgba(114, 149, 246, 0.38); - --mdc-switch-selected-focus-track-color: rgba(114, 149, 246, 0.38); - --mdc-switch-selected-pressed-track-color: rgba(114, 149, 246, 0.38); - --mdc-switch-unselected-track-color: rgba(102, 102, 102, 0.3); - --mdc-switch-unselected-hover-track-color: rgba(102, 102, 102, 0.3); - --mdc-switch-unselected-focus-track-color: rgba(102, 102, 102, 0.3); - --mdc-switch-unselected-pressed-track-color: rgba(102, 102, 102, 0.3); - --mdc-switch-selected-hover-state-layer-opacity: 0; - --mdc-switch-unselected-hover-state-layer-opacity: 0; -} - .material-design-ripple { --mdc-ripple-fg-size: 0; --mdc-ripple-left: 0; diff --git a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/base.css b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/base.css index c08755c4f650..63218e797039 100644 --- a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/base.css +++ b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/base.css @@ -640,6 +640,10 @@ a.link-action--rounded { padding-top: 14px; padding-bottom: 14px; } +.environment--ios .padding-y--reduced, .environment--android .padding-y--reduced { + padding-top: 8.5px; + padding-bottom: 8.5px; +} .padding-bottom-half { padding-bottom: 10px; diff --git a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/popup.css b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/popup.css index d7fc7dc05529..9c7a8c1e23f8 100644 --- a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/popup.css +++ b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/css/popup.css @@ -326,8 +326,18 @@ body.environment--ios { --track-color: #21c000; } body.environment--android { - --toggle-width: 36px; - --toggle-height: 20px; + --toggle-width: 51px; + --toggle-height: 31px; + --toggle-spinner-size: 31px; + --handle-size: 24px; + --handle-offset: 4px; + --handle-shadow: none; + --track-color: #3969ef; + --track-color-off: rgba(136, 136, 136, 0.5); +} +body.environment--android.body--theme-dark { + --track-color: #7295f6; + --track-color-off: rgba(204, 204, 204, 0.5); } body.environment--windows { --toggle-width: 40px; @@ -639,10 +649,14 @@ body.environment--windows { .main-nav__row { position: relative; /* - The next 3 blocks are all about removing dividers on hover. It was - done this way to prevent any heights changing, or any lines bleeding - into the hover styles. I'm confident there's a better way, however :) - */ + The following avoids height shifts when hovering over nav rows. + + Each row has an :after element that provides the separator line. When hovering a row, we need to change the color of any adjacent separators (i.e. the current row and the row above it). + + We do that by targeting the current row directly, then use a :has selector to also target the previous sibling of the current row. + + We also use the :not selector to exclude any rows marked as no-hover. + */ } .main-nav__row:after { content: " "; @@ -654,13 +668,7 @@ body.environment--windows { bottom: -0.5px; left: 16px; } -[data-hover="0"] .main-nav__row:nth-child(1):after { - background-color: var(--page-bg); -} -[data-hover="1"] .main-nav__row:nth-child(1):after, [data-hover="1"] .main-nav__row:nth-child(2):after { - background-color: var(--page-bg); -} -[data-hover="2"] .main-nav__row:nth-child(2):after, [data-hover="2"] .main-nav__row:nth-child(3):after { +.main-nav__row:not(.no-hover):hover:after, .main-nav__row:has(+ :not(.no-hover):hover):after { background-color: var(--page-bg); } .main-nav__row:last-child { @@ -972,6 +980,11 @@ body.environment--windows { background-image: url("../../img/refresh-assets/Cookies-Hidden-128.svg"); } +.hero-icon--phishing, +.hero-icon--connection-phishing { + background-image: url("../../img/refresh-assets/Phishing-128.svg"); +} + .hero-icon--chat { background-image: url("../../img/refresh-assets/chat-private-128.svg"); } @@ -1045,7 +1058,7 @@ body.environment--macos, body.environment--browser, body.environment--windows, b .top-nav { text-align: center; position: fixed; - z-index: 5; + z-index: 1; width: 100%; height: var(--nav-height); background: var(--page-bg); @@ -1139,10 +1152,12 @@ body.environment--macos, body.environment--browser, body.environment--windows, b .top-nav__title { position: absolute; height: var(--nav-height); - line-height: var(--nav-height); + line-height: 1.1; color: var(--color-text-primary); left: 50%; transform: translateX(-50%); + display: flex; + align-items: center; } .environment--ios .top-nav__title { font-size: 17px; @@ -1151,6 +1166,15 @@ body.environment--macos, body.environment--browser, body.environment--windows, b .environment--ios .top-nav__title:active { opacity: 0.5; } +.environment--android .top-nav__title { + transform: unset; + left: 60px; + font-size: 20px; + font-weight: 500; +} +.environment--android .top-nav__title:active { + opacity: 0.5; +} .status-list--right .status-list__item { padding-left: 0; @@ -3290,6 +3314,72 @@ body.environment--macos, body.environment--browser, body.environment--windows, b line-height: 20px; } +/* Android-specific styles */ +.environment--android { + --btn-accent-bg: #3969ef; + --btn-accent-bg-hover: #1e42a4; + --btn-accent-bg-active: #1e42a4; + --btn-accent-color: #fff; + --form-select-bg: none; + --form-select-color: rgba(0, 0, 0, 0.6); + --form-select-border-color: rgba(0, 0, 0, 0.3); + --form-select-chevron: url("../../img/refresh-assets/chevron.svg"); + /** + * prevent the empty element from taking any screen height + */ +} +.environment--android ddg-android-breakage-dialog { + position: absolute; +} +.environment--android .breakage-form .form__submit { + height: calc(var(--size-unit) * 3); +} +.environment--android .breakage-form .breakage-form__input--dropdown { + position: relative; +} +.environment--android .breakage-form .breakage-form__input--dropdown select { + appearance: none; + background: var(--form-select-bg); + border: 1px solid var(--form-select-border-color); + border-radius: var(--size-unit-half); + color: var(--form-select-color); + font-size: var(--size-unit); + padding: var(--size-unit); +} +.environment--android .breakage-form .breakage-form__input--dropdown::after { + content: ""; + position: absolute; + right: var(--size-unit); + top: calc(1.25 * var(--size-unit)); + background-image: var(--form-select-chevron); + width: 8px; + height: 14px; + transform: rotate(90deg); + opacity: 0.75; +} +.environment--android .breakage-form textarea { + border-radius: var(--size-unit-half); + border-color: var(--form-select-border-color); + font-size: var(--size-unit); + line-height: 1.25; + height: calc(var(--size-unit) * 7); + padding: var(--size-unit); + background: none; +} +.environment--android .breakage-form .breakage-form__content.padding-x-double { + padding-left: var(--size-unit); + padding-right: var(--size-unit); +} +.environment--android.body--theme-dark { + --btn-accent-bg: #7295f6; + --btn-accent-bg-hover: #3969ef; + --btn-accent-bg-active: #3969ef; + --btn-accent-color: rgba(0, 0, 0, 0.84); + --form-select-color: rgba(255, 255, 255, 0.9); + --form-select-border-color: rgba(255, 255, 255, 0.3); + --form-select-chevron: url("../../img/refresh-assets/chevron--light.svg"); +} + .email-alias { margin-bottom: var(--size-unit); } @@ -3613,8 +3703,8 @@ body.environment--macos, body.environment--browser, body.environment--windows, b line-height: 20px; } .environment--android .protection-toggle__row { - padding: 14px 16px; - font-size: 16px; + padding: 8.5px 16px; + font-size: 15px; font-style: normal; font-weight: 400; line-height: 20px; @@ -3796,7 +3886,7 @@ body.environment--macos, body.environment--browser, body.environment--windows, b } .scrollable { - height: 300px; + height: 280px; overflow-y: scroll; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.1); diff --git a/node_modules/@duckduckgo/privacy-dashboard/build/app/public/js/android-breakage-dialog.js b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/js/android-breakage-dialog.js new file mode 100644 index 000000000000..e4eb73c0570d --- /dev/null +++ b/node_modules/@duckduckgo/privacy-dashboard/build/app/public/js/android-breakage-dialog.js @@ -0,0 +1,5042 @@ +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; + }; + + // node_modules/@lit/reactive-element/css-tag.js + var t = globalThis; + var e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype; + var s = Symbol(); + var o = /* @__PURE__ */ new WeakMap(); + var n = class { + constructor(t4, e6, o5) { + if (this._$cssResult$ = true, o5 !== s) + throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + this.cssText = t4, this.t = e6; + } + get styleSheet() { + let t4 = this.o; + const s2 = this.t; + if (e && void 0 === t4) { + const e6 = void 0 !== s2 && 1 === s2.length; + e6 && (t4 = o.get(s2)), void 0 === t4 && ((this.o = t4 = new CSSStyleSheet()).replaceSync(this.cssText), e6 && o.set(s2, t4)); + } + return t4; + } + toString() { + return this.cssText; + } + }; + var r = (t4) => new n("string" == typeof t4 ? t4 : t4 + "", void 0, s); + var i = (t4, ...e6) => { + const o5 = 1 === t4.length ? t4[0] : e6.reduce((e7, s2, o6) => e7 + ((t5) => { + if (true === t5._$cssResult$) + return t5.cssText; + if ("number" == typeof t5) + return t5; + throw Error("Value passed to 'css' function must be a 'css' function result: " + t5 + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); + })(s2) + t4[o6 + 1], t4[0]); + return new n(o5, t4, s); + }; + var S = (s2, o5) => { + if (e) + s2.adoptedStyleSheets = o5.map((t4) => t4 instanceof CSSStyleSheet ? t4 : t4.styleSheet); + else + for (const e6 of o5) { + const o6 = document.createElement("style"), n5 = t.litNonce; + void 0 !== n5 && o6.setAttribute("nonce", n5), o6.textContent = e6.cssText, s2.appendChild(o6); + } + }; + var c = e ? (t4) => t4 : (t4) => t4 instanceof CSSStyleSheet ? ((t5) => { + let e6 = ""; + for (const s2 of t5.cssRules) + e6 += s2.cssText; + return r(e6); + })(t4) : t4; + + // node_modules/@lit/reactive-element/reactive-element.js + var { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object; + var a = globalThis; + var c2 = a.trustedTypes; + var l = c2 ? c2.emptyScript : ""; + var p = a.reactiveElementPolyfillSupport; + var d = (t4, s2) => t4; + var u = { toAttribute(t4, s2) { + switch (s2) { + case Boolean: + t4 = t4 ? l : null; + break; + case Object: + case Array: + t4 = null == t4 ? t4 : JSON.stringify(t4); + } + return t4; + }, fromAttribute(t4, s2) { + let i4 = t4; + switch (s2) { + case Boolean: + i4 = null !== t4; + break; + case Number: + i4 = null === t4 ? null : Number(t4); + break; + case Object: + case Array: + try { + i4 = JSON.parse(t4); + } catch (t5) { + i4 = null; + } + } + return i4; + } }; + var f = (t4, s2) => !i2(t4, s2); + var y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f }; + Symbol.metadata ??= Symbol("metadata"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); + var b = class extends HTMLElement { + static addInitializer(t4) { + this._$Ei(), (this.l ??= []).push(t4); + } + static get observedAttributes() { + return this.finalize(), this._$Eh && [...this._$Eh.keys()]; + } + static createProperty(t4, s2 = y) { + if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t4, s2), !s2.noAccessor) { + const i4 = Symbol(), r5 = this.getPropertyDescriptor(t4, i4, s2); + void 0 !== r5 && e2(this.prototype, t4, r5); + } + } + static getPropertyDescriptor(t4, s2, i4) { + const { get: e6, set: h4 } = r2(this.prototype, t4) ?? { get() { + return this[s2]; + }, set(t5) { + this[s2] = t5; + } }; + return { get() { + return e6?.call(this); + }, set(s3) { + const r5 = e6?.call(this); + h4.call(this, s3), this.requestUpdate(t4, r5, i4); + }, configurable: true, enumerable: true }; + } + static getPropertyOptions(t4) { + return this.elementProperties.get(t4) ?? y; + } + static _$Ei() { + if (this.hasOwnProperty(d("elementProperties"))) + return; + const t4 = n2(this); + t4.finalize(), void 0 !== t4.l && (this.l = [...t4.l]), this.elementProperties = new Map(t4.elementProperties); + } + static finalize() { + if (this.hasOwnProperty(d("finalized"))) + return; + if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d("properties"))) { + const t5 = this.properties, s2 = [...h(t5), ...o2(t5)]; + for (const i4 of s2) + this.createProperty(i4, t5[i4]); + } + const t4 = this[Symbol.metadata]; + if (null !== t4) { + const s2 = litPropertyMetadata.get(t4); + if (void 0 !== s2) + for (const [t5, i4] of s2) + this.elementProperties.set(t5, i4); + } + this._$Eh = /* @__PURE__ */ new Map(); + for (const [t5, s2] of this.elementProperties) { + const i4 = this._$Eu(t5, s2); + void 0 !== i4 && this._$Eh.set(i4, t5); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(s2) { + const i4 = []; + if (Array.isArray(s2)) { + const e6 = new Set(s2.flat(1 / 0).reverse()); + for (const s3 of e6) + i4.unshift(c(s3)); + } else + void 0 !== s2 && i4.push(c(s2)); + return i4; + } + static _$Eu(t4, s2) { + const i4 = s2.attribute; + return false === i4 ? void 0 : "string" == typeof i4 ? i4 : "string" == typeof t4 ? t4.toLowerCase() : void 0; + } + constructor() { + super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev(); + } + _$Ev() { + this._$ES = new Promise((t4) => this.enableUpdating = t4), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t4) => t4(this)); + } + addController(t4) { + (this._$EO ??= /* @__PURE__ */ new Set()).add(t4), void 0 !== this.renderRoot && this.isConnected && t4.hostConnected?.(); + } + removeController(t4) { + this._$EO?.delete(t4); + } + _$E_() { + const t4 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties; + for (const i4 of s2.keys()) + this.hasOwnProperty(i4) && (t4.set(i4, this[i4]), delete this[i4]); + t4.size > 0 && (this._$Ep = t4); + } + createRenderRoot() { + const t4 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return S(t4, this.constructor.elementStyles), t4; + } + connectedCallback() { + this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t4) => t4.hostConnected?.()); + } + enableUpdating(t4) { + } + disconnectedCallback() { + this._$EO?.forEach((t4) => t4.hostDisconnected?.()); + } + attributeChangedCallback(t4, s2, i4) { + this._$AK(t4, i4); + } + _$EC(t4, s2) { + const i4 = this.constructor.elementProperties.get(t4), e6 = this.constructor._$Eu(t4, i4); + if (void 0 !== e6 && true === i4.reflect) { + const r5 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type); + this._$Em = t4, null == r5 ? this.removeAttribute(e6) : this.setAttribute(e6, r5), this._$Em = null; + } + } + _$AK(t4, s2) { + const i4 = this.constructor, e6 = i4._$Eh.get(t4); + if (void 0 !== e6 && this._$Em !== e6) { + const t5 = i4.getPropertyOptions(e6), r5 = "function" == typeof t5.converter ? { fromAttribute: t5.converter } : void 0 !== t5.converter?.fromAttribute ? t5.converter : u; + this._$Em = e6, this[e6] = r5.fromAttribute(s2, t5.type), this._$Em = null; + } + } + requestUpdate(t4, s2, i4) { + if (void 0 !== t4) { + if (i4 ??= this.constructor.getPropertyOptions(t4), !(i4.hasChanged ?? f)(this[t4], s2)) + return; + this.P(t4, s2, i4); + } + false === this.isUpdatePending && (this._$ES = this._$ET()); + } + P(t4, s2, i4) { + this._$AL.has(t4) || this._$AL.set(t4, s2), true === i4.reflect && this._$Em !== t4 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t4); + } + async _$ET() { + this.isUpdatePending = true; + try { + await this._$ES; + } catch (t5) { + Promise.reject(t5); + } + const t4 = this.scheduleUpdate(); + return null != t4 && await t4, !this.isUpdatePending; + } + scheduleUpdate() { + return this.performUpdate(); + } + performUpdate() { + if (!this.isUpdatePending) + return; + if (!this.hasUpdated) { + if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { + for (const [t6, s3] of this._$Ep) + this[t6] = s3; + this._$Ep = void 0; + } + const t5 = this.constructor.elementProperties; + if (t5.size > 0) + for (const [s3, i4] of t5) + true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4); + } + let t4 = false; + const s2 = this._$AL; + try { + t4 = this.shouldUpdate(s2), t4 ? (this.willUpdate(s2), this._$EO?.forEach((t5) => t5.hostUpdate?.()), this.update(s2)) : this._$EU(); + } catch (s3) { + throw t4 = false, this._$EU(), s3; + } + t4 && this._$AE(s2); + } + willUpdate(t4) { + } + _$AE(t4) { + this._$EO?.forEach((t5) => t5.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t4)), this.updated(t4); + } + _$EU() { + this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false; + } + get updateComplete() { + return this.getUpdateComplete(); + } + getUpdateComplete() { + return this._$ES; + } + shouldUpdate(t4) { + return true; + } + update(t4) { + this._$Ej &&= this._$Ej.forEach((t5) => this._$EC(t5, this[t5])), this._$EU(); + } + updated(t4) { + } + firstUpdated(t4) { + } + }; + b.elementStyles = [], b.shadowRootOptions = { mode: "open" }, b[d("elementProperties")] = /* @__PURE__ */ new Map(), b[d("finalized")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push("2.0.4"); + + // node_modules/lit-html/lit-html.js + var n3 = globalThis; + var c3 = n3.trustedTypes; + var h2 = c3 ? c3.createPolicy("lit-html", { createHTML: (t4) => t4 }) : void 0; + var f2 = "$lit$"; + var v = `lit$${Math.random().toFixed(9).slice(2)}$`; + var m = "?" + v; + var _ = `<${m}>`; + var w = document; + var lt = () => w.createComment(""); + var st = (t4) => null === t4 || "object" != typeof t4 && "function" != typeof t4; + var g = Array.isArray; + var $ = (t4) => g(t4) || "function" == typeof t4?.[Symbol.iterator]; + var x = "[ \n\f\r]"; + var T = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g; + var E = /-->/g; + var k = />/g; + var O = RegExp(`>|${x}(?:([^\\s"'>=/]+)(${x}*=${x}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"); + var S2 = /'/g; + var j = /"/g; + var M = /^(?:script|style|textarea|title)$/i; + var P = (t4) => (i4, ...s2) => ({ _$litType$: t4, strings: i4, values: s2 }); + var ke = P(1); + var Oe = P(2); + var Se = P(3); + var R = Symbol.for("lit-noChange"); + var D = Symbol.for("lit-nothing"); + var V = /* @__PURE__ */ new WeakMap(); + var I = w.createTreeWalker(w, 129); + function N(t4, i4) { + if (!g(t4) || !t4.hasOwnProperty("raw")) + throw Error("invalid template strings array"); + return void 0 !== h2 ? h2.createHTML(i4) : i4; + } + var U = (t4, i4) => { + const s2 = t4.length - 1, e6 = []; + let h4, o5 = 2 === i4 ? "" : 3 === i4 ? "" : "", n5 = T; + for (let i5 = 0; i5 < s2; i5++) { + const s3 = t4[i5]; + let r5, l2, c4 = -1, a2 = 0; + for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) + a2 = n5.lastIndex, n5 === T ? "!--" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp("" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r5 = l2[1], n5 = void 0 === l2[3] ? O : '"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0); + const u2 = n5 === O && t4[i5 + 1].startsWith("/>") ? " " : ""; + o5 += n5 === T ? s3 + _ : c4 >= 0 ? (e6.push(r5), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2); + } + return [N(t4, o5 + (t4[s2] || "") + (2 === i4 ? "" : 3 === i4 ? "" : "")), e6]; + }; + var B = class _B { + constructor({ strings: t4, _$litType$: i4 }, s2) { + let e6; + this.parts = []; + let h4 = 0, o5 = 0; + const n5 = t4.length - 1, r5 = this.parts, [l2, a2] = U(t4, i4); + if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) { + const t5 = this.el.content.firstChild; + t5.replaceWith(...t5.childNodes); + } + for (; null !== (e6 = I.nextNode()) && r5.length < n5; ) { + if (1 === e6.nodeType) { + if (e6.hasAttributes()) + for (const t5 of e6.getAttributeNames()) + if (t5.endsWith(f2)) { + const i5 = a2[o5++], s3 = e6.getAttribute(t5).split(v), n6 = /([.?@])?(.*)/.exec(i5); + r5.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: "." === n6[1] ? Y : "?" === n6[1] ? Z : "@" === n6[1] ? q : G }), e6.removeAttribute(t5); + } else + t5.startsWith(v) && (r5.push({ type: 6, index: h4 }), e6.removeAttribute(t5)); + if (M.test(e6.tagName)) { + const t5 = e6.textContent.split(v), i5 = t5.length - 1; + if (i5 > 0) { + e6.textContent = c3 ? c3.emptyScript : ""; + for (let s3 = 0; s3 < i5; s3++) + e6.append(t5[s3], lt()), I.nextNode(), r5.push({ type: 2, index: ++h4 }); + e6.append(t5[i5], lt()); + } + } + } else if (8 === e6.nodeType) + if (e6.data === m) + r5.push({ type: 2, index: h4 }); + else { + let t5 = -1; + for (; -1 !== (t5 = e6.data.indexOf(v, t5 + 1)); ) + r5.push({ type: 7, index: h4 }), t5 += v.length - 1; + } + h4++; + } + } + static createElement(t4, i4) { + const s2 = w.createElement("template"); + return s2.innerHTML = t4, s2; + } + }; + function z(t4, i4, s2 = t4, e6) { + if (i4 === R) + return i4; + let h4 = void 0 !== e6 ? s2.o?.[e6] : s2.l; + const o5 = st(i4) ? void 0 : i4._$litDirective$; + return h4?.constructor !== o5 && (h4?._$AO?.(false), void 0 === o5 ? h4 = void 0 : (h4 = new o5(t4), h4._$AT(t4, s2, e6)), void 0 !== e6 ? (s2.o ??= [])[e6] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t4, h4._$AS(t4, i4.values), h4, e6)), i4; + } + var F = class { + constructor(t4, i4) { + this._$AV = [], this._$AN = void 0, this._$AD = t4, this._$AM = i4; + } + get parentNode() { + return this._$AM.parentNode; + } + get _$AU() { + return this._$AM._$AU; + } + u(t4) { + const { el: { content: i4 }, parts: s2 } = this._$AD, e6 = (t4?.creationScope ?? w).importNode(i4, true); + I.currentNode = e6; + let h4 = I.nextNode(), o5 = 0, n5 = 0, r5 = s2[0]; + for (; void 0 !== r5; ) { + if (o5 === r5.index) { + let i5; + 2 === r5.type ? i5 = new et(h4, h4.nextSibling, this, t4) : 1 === r5.type ? i5 = new r5.ctor(h4, r5.name, r5.strings, this, t4) : 6 === r5.type && (i5 = new K(h4, this, t4)), this._$AV.push(i5), r5 = s2[++n5]; + } + o5 !== r5?.index && (h4 = I.nextNode(), o5++); + } + return I.currentNode = w, e6; + } + p(t4) { + let i4 = 0; + for (const s2 of this._$AV) + void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t4, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t4[i4])), i4++; + } + }; + var et = class _et { + get _$AU() { + return this._$AM?._$AU ?? this.v; + } + constructor(t4, i4, s2, e6) { + this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t4, this._$AB = i4, this._$AM = s2, this.options = e6, this.v = e6?.isConnected ?? true; + } + get parentNode() { + let t4 = this._$AA.parentNode; + const i4 = this._$AM; + return void 0 !== i4 && 11 === t4?.nodeType && (t4 = i4.parentNode), t4; + } + get startNode() { + return this._$AA; + } + get endNode() { + return this._$AB; + } + _$AI(t4, i4 = this) { + t4 = z(this, t4, i4), st(t4) ? t4 === D || null == t4 || "" === t4 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t4 !== this._$AH && t4 !== R && this._(t4) : void 0 !== t4._$litType$ ? this.$(t4) : void 0 !== t4.nodeType ? this.T(t4) : $(t4) ? this.k(t4) : this._(t4); + } + O(t4) { + return this._$AA.parentNode.insertBefore(t4, this._$AB); + } + T(t4) { + this._$AH !== t4 && (this._$AR(), this._$AH = this.O(t4)); + } + _(t4) { + this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t4 : this.T(w.createTextNode(t4)), this._$AH = t4; + } + $(t4) { + const { values: i4, _$litType$: s2 } = t4, e6 = "number" == typeof s2 ? this._$AC(t4) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2); + if (this._$AH?._$AD === e6) + this._$AH.p(i4); + else { + const t5 = new F(e6, this), s3 = t5.u(this.options); + t5.p(i4), this.T(s3), this._$AH = t5; + } + } + _$AC(t4) { + let i4 = V.get(t4.strings); + return void 0 === i4 && V.set(t4.strings, i4 = new B(t4)), i4; + } + k(t4) { + g(this._$AH) || (this._$AH = [], this._$AR()); + const i4 = this._$AH; + let s2, e6 = 0; + for (const h4 of t4) + e6 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e6], s2._$AI(h4), e6++; + e6 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e6), i4.length = e6); + } + _$AR(t4 = this._$AA.nextSibling, i4) { + for (this._$AP?.(false, true, i4); t4 && t4 !== this._$AB; ) { + const i5 = t4.nextSibling; + t4.remove(), t4 = i5; + } + } + setConnected(t4) { + void 0 === this._$AM && (this.v = t4, this._$AP?.(t4)); + } + }; + var G = class { + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t4, i4, s2, e6, h4) { + this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t4, this.name = i4, this._$AM = e6, this.options = h4, s2.length > 2 || "" !== s2[0] || "" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D; + } + _$AI(t4, i4 = this, s2, e6) { + const h4 = this.strings; + let o5 = false; + if (void 0 === h4) + t4 = z(this, t4, i4, 0), o5 = !st(t4) || t4 !== this._$AH && t4 !== R, o5 && (this._$AH = t4); + else { + const e7 = t4; + let n5, r5; + for (t4 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) + r5 = z(this, e7[s2 + n5], i4, n5), r5 === R && (r5 = this._$AH[n5]), o5 ||= !st(r5) || r5 !== this._$AH[n5], r5 === D ? t4 = D : t4 !== D && (t4 += (r5 ?? "") + h4[n5 + 1]), this._$AH[n5] = r5; + } + o5 && !e6 && this.j(t4); + } + j(t4) { + t4 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t4 ?? ""); + } + }; + var Y = class extends G { + constructor() { + super(...arguments), this.type = 3; + } + j(t4) { + this.element[this.name] = t4 === D ? void 0 : t4; + } + }; + var Z = class extends G { + constructor() { + super(...arguments), this.type = 4; + } + j(t4) { + this.element.toggleAttribute(this.name, !!t4 && t4 !== D); + } + }; + var q = class extends G { + constructor(t4, i4, s2, e6, h4) { + super(t4, i4, s2, e6, h4), this.type = 5; + } + _$AI(t4, i4 = this) { + if ((t4 = z(this, t4, i4, 0) ?? D) === R) + return; + const s2 = this._$AH, e6 = t4 === D && s2 !== D || t4.capture !== s2.capture || t4.once !== s2.once || t4.passive !== s2.passive, h4 = t4 !== D && (s2 === D || e6); + e6 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t4), this._$AH = t4; + } + handleEvent(t4) { + "function" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t4) : this._$AH.handleEvent(t4); + } + }; + var K = class { + constructor(t4, i4, s2) { + this.element = t4, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2; + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t4) { + z(this, t4); + } + }; + var si = { M: f2, P: v, A: m, C: 1, L: U, R: F, D: $, V: z, I: et, H: G, N: Z, U: q, B: Y, F: K }; + var Re = n3.litHtmlPolyfillSupport; + Re?.(B, et), (n3.litHtmlVersions ??= []).push("3.2.0"); + var Q = (t4, i4, s2) => { + const e6 = s2?.renderBefore ?? i4; + let h4 = e6._$litPart$; + if (void 0 === h4) { + const t5 = s2?.renderBefore ?? null; + e6._$litPart$ = h4 = new et(i4.insertBefore(lt(), t5), t5, void 0, s2 ?? {}); + } + return h4._$AI(t4), h4; + }; + + // node_modules/lit-element/lit-element.js + var h3 = class extends b { + constructor() { + super(...arguments), this.renderOptions = { host: this }, this.o = void 0; + } + createRenderRoot() { + const t4 = super.createRenderRoot(); + return this.renderOptions.renderBefore ??= t4.firstChild, t4; + } + update(t4) { + const e6 = this.render(); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t4), this.o = Q(e6, this.renderRoot, this.renderOptions); + } + connectedCallback() { + super.connectedCallback(), this.o?.setConnected(true); + } + disconnectedCallback() { + super.disconnectedCallback(), this.o?.setConnected(false); + } + render() { + return R; + } + }; + h3._$litElement$ = true, h3["finalized"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 }); + var f3 = globalThis.litElementPolyfillSupport; + f3?.({ LitElement: h3 }); + (globalThis.litElementVersions ??= []).push("4.1.0"); + + // node_modules/lit-html/is-server.js + var co = false; + + // node_modules/lit-html/directives/map.js + function* oo(o5, f4) { + if (void 0 !== o5) { + let i4 = 0; + for (const t4 of o5) + yield f4(t4, i4++); + } + } + + // node_modules/tslib/tslib.es6.js + function __decorate(decorators, target, key, desc) { + var c4 = arguments.length, r5 = c4 < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d2; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") + r5 = Reflect.decorate(decorators, target, key, desc); + else + for (var i4 = decorators.length - 1; i4 >= 0; i4--) + if (d2 = decorators[i4]) + r5 = (c4 < 3 ? d2(r5) : c4 > 3 ? d2(target, key, r5) : d2(target, key)) || r5; + return c4 > 3 && r5 && Object.defineProperty(target, key, r5), r5; + } + + // node_modules/@lit/reactive-element/decorators/custom-element.js + var t2 = (t4) => (e6, o5) => { + void 0 !== o5 ? o5.addInitializer(() => { + customElements.define(t4, e6); + }) : customElements.define(t4, e6); + }; + + // node_modules/@lit/reactive-element/decorators/property.js + var o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f }; + var r3 = (t4 = o3, e6, r5) => { + const { kind: n5, metadata: i4 } = r5; + let s2 = globalThis.litPropertyMetadata.get(i4); + if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r5.name, t4), "accessor" === n5) { + const { name: o5 } = r5; + return { set(r6) { + const n6 = e6.get.call(this); + e6.set.call(this, r6), this.requestUpdate(o5, n6, t4); + }, init(e7) { + return void 0 !== e7 && this.P(o5, void 0, t4), e7; + } }; + } + if ("setter" === n5) { + const { name: o5 } = r5; + return function(r6) { + const n6 = this[o5]; + e6.call(this, r6), this.requestUpdate(o5, n6, t4); + }; + } + throw Error("Unsupported decorator location: " + n5); + }; + function n4(t4) { + return (e6, o5) => "object" == typeof o5 ? r3(t4, e6, o5) : ((t5, e7, o6) => { + const r5 = e7.hasOwnProperty(o6); + return e7.constructor.createProperty(o6, r5 ? { ...t5, wrapped: true } : t5), r5 ? Object.getOwnPropertyDescriptor(e7, o6) : void 0; + })(t4, e6, o5); + } + + // node_modules/@lit/reactive-element/decorators/state.js + function r4(r5) { + return n4({ ...r5, state: true, attribute: false }); + } + + // node_modules/@lit/reactive-element/decorators/base.js + var e3 = (e6, t4, c4) => (c4.configurable = true, c4.enumerable = true, Reflect.decorate && "object" != typeof t4 && Object.defineProperty(e6, t4, c4), c4); + + // node_modules/@lit/reactive-element/decorators/query.js + function e4(e6, r5) { + return (n5, s2, i4) => { + const o5 = (t4) => t4.renderRoot?.querySelector(e6) ?? null; + if (r5) { + const { get: e7, set: r6 } = "object" == typeof s2 ? n5 : i4 ?? (() => { + const t4 = Symbol(); + return { get() { + return this[t4]; + }, set(e8) { + this[t4] = e8; + } }; + })(); + return e3(n5, s2, { get() { + let t4 = e7.call(this); + return void 0 === t4 && (t4 = o5(this), (null !== t4 || this.hasUpdated) && r6.call(this, t4)), t4; + } }); + } + return e3(n5, s2, { get() { + return o5(this); + } }); + }; + } + + // node_modules/@lit/reactive-element/decorators/query-assigned-elements.js + function o4(o5) { + return (e6, n5) => { + const { slot: r5, selector: s2 } = o5 ?? {}, c4 = "slot" + (r5 ? `[name=${r5}]` : ":not([name])"); + return e3(e6, n5, { get() { + const t4 = this.renderRoot?.querySelector(c4), e7 = t4?.assignedElements(o5) ?? []; + return void 0 === s2 ? e7 : e7.filter((t5) => t5.matches(s2)); + } }); + }; + } + + // node_modules/@material/web/elevation/internal/elevation.js + var Elevation = class extends h3 { + connectedCallback() { + super.connectedCallback(); + this.setAttribute("aria-hidden", "true"); + } + render() { + return ke``; + } + }; + + // node_modules/@material/web/elevation/internal/elevation-styles.js + var styles = i`:host,.shadow,.shadow::before,.shadow::after{border-radius:inherit;inset:0;position:absolute;transition-duration:inherit;transition-property:inherit;transition-timing-function:inherit}:host{display:flex;pointer-events:none;transition-property:box-shadow,opacity}.shadow::before,.shadow::after{content:"";transition-property:box-shadow,opacity;--_level: var(--md-elevation-level, 0);--_shadow-color: var(--md-elevation-shadow-color, var(--md-sys-color-shadow, #000))}.shadow::before{box-shadow:0px calc(1px*(clamp(0,var(--_level),1) + clamp(0,var(--_level) - 3,1) + 2*clamp(0,var(--_level) - 4,1))) calc(1px*(2*clamp(0,var(--_level),1) + clamp(0,var(--_level) - 2,1) + clamp(0,var(--_level) - 4,1))) 0px var(--_shadow-color);opacity:.3}.shadow::after{box-shadow:0px calc(1px*(clamp(0,var(--_level),1) + clamp(0,var(--_level) - 1,1) + 2*clamp(0,var(--_level) - 2,3))) calc(1px*(3*clamp(0,var(--_level),2) + 2*clamp(0,var(--_level) - 2,3))) calc(1px*(clamp(0,var(--_level),4) + 2*clamp(0,var(--_level) - 4,1))) var(--_shadow-color);opacity:.15} +`; + + // node_modules/@material/web/elevation/elevation.js + var MdElevation = class MdElevation2 extends Elevation { + }; + MdElevation.styles = [styles]; + MdElevation = __decorate([ + t2("md-elevation") + ], MdElevation); + + // node_modules/@material/web/internal/controller/attachable-controller.js + var ATTACHABLE_CONTROLLER = Symbol("attachableController"); + var FOR_ATTRIBUTE_OBSERVER; + if (!co) { + FOR_ATTRIBUTE_OBSERVER = new MutationObserver((records) => { + for (const record of records) { + record.target[ATTACHABLE_CONTROLLER]?.hostConnected(); + } + }); + } + var AttachableController = class { + get htmlFor() { + return this.host.getAttribute("for"); + } + set htmlFor(htmlFor) { + if (htmlFor === null) { + this.host.removeAttribute("for"); + } else { + this.host.setAttribute("for", htmlFor); + } + } + get control() { + if (this.host.hasAttribute("for")) { + if (!this.htmlFor || !this.host.isConnected) { + return null; + } + return this.host.getRootNode().querySelector(`#${this.htmlFor}`); + } + return this.currentControl || this.host.parentElement; + } + set control(control) { + if (control) { + this.attach(control); + } else { + this.detach(); + } + } + /** + * Creates a new controller for an `Attachable` element. + * + * @param host The `Attachable` element. + * @param onControlChange A callback with two parameters for the previous and + * next control. An `Attachable` element may perform setup or teardown + * logic whenever the control changes. + */ + constructor(host, onControlChange) { + this.host = host; + this.onControlChange = onControlChange; + this.currentControl = null; + host.addController(this); + host[ATTACHABLE_CONTROLLER] = this; + FOR_ATTRIBUTE_OBSERVER?.observe(host, { attributeFilter: ["for"] }); + } + attach(control) { + if (control === this.currentControl) { + return; + } + this.setCurrentControl(control); + this.host.removeAttribute("for"); + } + detach() { + this.setCurrentControl(null); + this.host.setAttribute("for", ""); + } + /** @private */ + hostConnected() { + this.setCurrentControl(this.control); + } + /** @private */ + hostDisconnected() { + this.setCurrentControl(null); + } + setCurrentControl(control) { + this.onControlChange(this.currentControl, control); + this.currentControl = control; + } + }; + + // node_modules/@material/web/focus/internal/focus-ring.js + var EVENTS = ["focusin", "focusout", "pointerdown"]; + var FocusRing = class extends h3 { + constructor() { + super(...arguments); + this.visible = false; + this.inward = false; + this.attachableController = new AttachableController(this, this.onControlChange.bind(this)); + } + get htmlFor() { + return this.attachableController.htmlFor; + } + set htmlFor(htmlFor) { + this.attachableController.htmlFor = htmlFor; + } + get control() { + return this.attachableController.control; + } + set control(control) { + this.attachableController.control = control; + } + attach(control) { + this.attachableController.attach(control); + } + detach() { + this.attachableController.detach(); + } + connectedCallback() { + super.connectedCallback(); + this.setAttribute("aria-hidden", "true"); + } + /** @private */ + handleEvent(event) { + if (event[HANDLED_BY_FOCUS_RING]) { + return; + } + switch (event.type) { + default: + return; + case "focusin": + this.visible = this.control?.matches(":focus-visible") ?? false; + break; + case "focusout": + case "pointerdown": + this.visible = false; + break; + } + event[HANDLED_BY_FOCUS_RING] = true; + } + onControlChange(prev, next) { + if (co) + return; + for (const event of EVENTS) { + prev?.removeEventListener(event, this); + next?.addEventListener(event, this); + } + } + update(changed) { + if (changed.has("visible")) { + this.dispatchEvent(new Event("visibility-changed")); + } + super.update(changed); + } + }; + __decorate([ + n4({ type: Boolean, reflect: true }) + ], FocusRing.prototype, "visible", void 0); + __decorate([ + n4({ type: Boolean, reflect: true }) + ], FocusRing.prototype, "inward", void 0); + var HANDLED_BY_FOCUS_RING = Symbol("handledByFocusRing"); + + // node_modules/@material/web/focus/internal/focus-ring-styles.js + var styles2 = i`:host{animation-delay:0s,calc(var(--md-focus-ring-duration, 600ms)*.25);animation-duration:calc(var(--md-focus-ring-duration, 600ms)*.25),calc(var(--md-focus-ring-duration, 600ms)*.75);animation-timing-function:cubic-bezier(0.2, 0, 0, 1);box-sizing:border-box;color:var(--md-focus-ring-color, var(--md-sys-color-secondary, #625b71));display:none;pointer-events:none;position:absolute}:host([visible]){display:flex}:host(:not([inward])){animation-name:outward-grow,outward-shrink;border-end-end-radius:calc(var(--md-focus-ring-shape-end-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) + var(--md-focus-ring-outward-offset, 2px));border-end-start-radius:calc(var(--md-focus-ring-shape-end-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) + var(--md-focus-ring-outward-offset, 2px));border-start-end-radius:calc(var(--md-focus-ring-shape-start-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) + var(--md-focus-ring-outward-offset, 2px));border-start-start-radius:calc(var(--md-focus-ring-shape-start-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) + var(--md-focus-ring-outward-offset, 2px));inset:calc(-1*var(--md-focus-ring-outward-offset, 2px));outline:var(--md-focus-ring-width, 3px) solid currentColor}:host([inward]){animation-name:inward-grow,inward-shrink;border-end-end-radius:calc(var(--md-focus-ring-shape-end-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(--md-focus-ring-inward-offset, 0px));border-end-start-radius:calc(var(--md-focus-ring-shape-end-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(--md-focus-ring-inward-offset, 0px));border-start-end-radius:calc(var(--md-focus-ring-shape-start-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(--md-focus-ring-inward-offset, 0px));border-start-start-radius:calc(var(--md-focus-ring-shape-start-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(--md-focus-ring-inward-offset, 0px));border:var(--md-focus-ring-width, 3px) solid currentColor;inset:var(--md-focus-ring-inward-offset, 0px)}@keyframes outward-grow{from{outline-width:0}to{outline-width:var(--md-focus-ring-active-width, 8px)}}@keyframes outward-shrink{from{outline-width:var(--md-focus-ring-active-width, 8px)}}@keyframes inward-grow{from{border-width:0}to{border-width:var(--md-focus-ring-active-width, 8px)}}@keyframes inward-shrink{from{border-width:var(--md-focus-ring-active-width, 8px)}}@media(prefers-reduced-motion){:host{animation:none}} +`; + + // node_modules/@material/web/focus/md-focus-ring.js + var MdFocusRing = class MdFocusRing2 extends FocusRing { + }; + MdFocusRing.styles = [styles2]; + MdFocusRing = __decorate([ + t2("md-focus-ring") + ], MdFocusRing); + + // node_modules/lit-html/directive.js + var t3 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 }; + var e5 = (t4) => (...e6) => ({ _$litDirective$: t4, values: e6 }); + var i3 = class { + constructor(t4) { + } + get _$AU() { + return this._$AM._$AU; + } + _$AT(t4, e6, i4) { + this.t = t4, this._$AM = e6, this.i = i4; + } + _$AS(t4, e6) { + return this.update(t4, e6); + } + update(t4, e6) { + return this.render(...e6); + } + }; + + // node_modules/lit-html/directives/class-map.js + var Rt = e5(class extends i3 { + constructor(s2) { + if (super(s2), s2.type !== t3.ATTRIBUTE || "class" !== s2.name || s2.strings?.length > 2) + throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); + } + render(t4) { + return " " + Object.keys(t4).filter((s2) => t4[s2]).join(" ") + " "; + } + update(t4, [s2]) { + if (void 0 === this.st) { + this.st = /* @__PURE__ */ new Set(), void 0 !== t4.strings && (this.nt = new Set(t4.strings.join(" ").split(/\s/).filter((t5) => "" !== t5))); + for (const t5 in s2) + s2[t5] && !this.nt?.has(t5) && this.st.add(t5); + return this.render(s2); + } + const i4 = t4.element.classList; + for (const t5 of this.st) + t5 in s2 || (i4.remove(t5), this.st.delete(t5)); + for (const t5 in s2) { + const r5 = !!s2[t5]; + r5 === this.st.has(t5) || this.nt?.has(t5) || (r5 ? (i4.add(t5), this.st.add(t5)) : (i4.remove(t5), this.st.delete(t5))); + } + return R; + } + }); + + // node_modules/@material/web/internal/motion/animation.js + var EASING = { + STANDARD: "cubic-bezier(0.2, 0, 0, 1)", + STANDARD_ACCELERATE: "cubic-bezier(.3,0,1,1)", + STANDARD_DECELERATE: "cubic-bezier(0,0,0,1)", + EMPHASIZED: "cubic-bezier(.3,0,0,1)", + EMPHASIZED_ACCELERATE: "cubic-bezier(.3,0,.8,.15)", + EMPHASIZED_DECELERATE: "cubic-bezier(.05,.7,.1,1)" + }; + + // node_modules/@material/web/ripple/internal/ripple.js + var PRESS_GROW_MS = 450; + var MINIMUM_PRESS_MS = 225; + var INITIAL_ORIGIN_SCALE = 0.2; + var PADDING = 10; + var SOFT_EDGE_MINIMUM_SIZE = 75; + var SOFT_EDGE_CONTAINER_RATIO = 0.35; + var PRESS_PSEUDO = "::after"; + var ANIMATION_FILL = "forwards"; + var State; + (function(State2) { + State2[State2["INACTIVE"] = 0] = "INACTIVE"; + State2[State2["TOUCH_DELAY"] = 1] = "TOUCH_DELAY"; + State2[State2["HOLDING"] = 2] = "HOLDING"; + State2[State2["WAITING_FOR_CLICK"] = 3] = "WAITING_FOR_CLICK"; + })(State || (State = {})); + var EVENTS2 = [ + "click", + "contextmenu", + "pointercancel", + "pointerdown", + "pointerenter", + "pointerleave", + "pointerup" + ]; + var TOUCH_DELAY_MS = 150; + var FORCED_COLORS = co ? null : window.matchMedia("(forced-colors: active)"); + var Ripple = class extends h3 { + constructor() { + super(...arguments); + this.disabled = false; + this.hovered = false; + this.pressed = false; + this.rippleSize = ""; + this.rippleScale = ""; + this.initialSize = 0; + this.state = State.INACTIVE; + this.checkBoundsAfterContextMenu = false; + this.attachableController = new AttachableController(this, this.onControlChange.bind(this)); + } + get htmlFor() { + return this.attachableController.htmlFor; + } + set htmlFor(htmlFor) { + this.attachableController.htmlFor = htmlFor; + } + get control() { + return this.attachableController.control; + } + set control(control) { + this.attachableController.control = control; + } + attach(control) { + this.attachableController.attach(control); + } + detach() { + this.attachableController.detach(); + } + connectedCallback() { + super.connectedCallback(); + this.setAttribute("aria-hidden", "true"); + } + render() { + const classes = { + "hovered": this.hovered, + "pressed": this.pressed + }; + return ke`
`; + } + update(changedProps) { + if (changedProps.has("disabled") && this.disabled) { + this.hovered = false; + this.pressed = false; + } + super.update(changedProps); + } + /** + * TODO(b/269799771): make private + * @private only public for slider + */ + handlePointerenter(event) { + if (!this.shouldReactToEvent(event)) { + return; + } + this.hovered = true; + } + /** + * TODO(b/269799771): make private + * @private only public for slider + */ + handlePointerleave(event) { + if (!this.shouldReactToEvent(event)) { + return; + } + this.hovered = false; + if (this.state !== State.INACTIVE) { + this.endPressAnimation(); + } + } + handlePointerup(event) { + if (!this.shouldReactToEvent(event)) { + return; + } + if (this.state === State.HOLDING) { + this.state = State.WAITING_FOR_CLICK; + return; + } + if (this.state === State.TOUCH_DELAY) { + this.state = State.WAITING_FOR_CLICK; + this.startPressAnimation(this.rippleStartEvent); + return; + } + } + async handlePointerdown(event) { + if (!this.shouldReactToEvent(event)) { + return; + } + this.rippleStartEvent = event; + if (!this.isTouch(event)) { + this.state = State.WAITING_FOR_CLICK; + this.startPressAnimation(event); + return; + } + if (this.checkBoundsAfterContextMenu && !this.inBounds(event)) { + return; + } + this.checkBoundsAfterContextMenu = false; + this.state = State.TOUCH_DELAY; + await new Promise((resolve) => { + setTimeout(resolve, TOUCH_DELAY_MS); + }); + if (this.state !== State.TOUCH_DELAY) { + return; + } + this.state = State.HOLDING; + this.startPressAnimation(event); + } + handleClick() { + if (this.disabled) { + return; + } + if (this.state === State.WAITING_FOR_CLICK) { + this.endPressAnimation(); + return; + } + if (this.state === State.INACTIVE) { + this.startPressAnimation(); + this.endPressAnimation(); + } + } + handlePointercancel(event) { + if (!this.shouldReactToEvent(event)) { + return; + } + this.endPressAnimation(); + } + handleContextmenu() { + if (this.disabled) { + return; + } + this.checkBoundsAfterContextMenu = true; + this.endPressAnimation(); + } + determineRippleSize() { + const { height, width } = this.getBoundingClientRect(); + const maxDim = Math.max(height, width); + const softEdgeSize = Math.max(SOFT_EDGE_CONTAINER_RATIO * maxDim, SOFT_EDGE_MINIMUM_SIZE); + const initialSize = Math.floor(maxDim * INITIAL_ORIGIN_SCALE); + const hypotenuse = Math.sqrt(width ** 2 + height ** 2); + const maxRadius = hypotenuse + PADDING; + this.initialSize = initialSize; + this.rippleScale = `${(maxRadius + softEdgeSize) / initialSize}`; + this.rippleSize = `${initialSize}px`; + } + getNormalizedPointerEventCoords(pointerEvent) { + const { scrollX, scrollY } = window; + const { left, top } = this.getBoundingClientRect(); + const documentX = scrollX + left; + const documentY = scrollY + top; + const { pageX, pageY } = pointerEvent; + return { x: pageX - documentX, y: pageY - documentY }; + } + getTranslationCoordinates(positionEvent) { + const { height, width } = this.getBoundingClientRect(); + const endPoint = { + x: (width - this.initialSize) / 2, + y: (height - this.initialSize) / 2 + }; + let startPoint; + if (positionEvent instanceof PointerEvent) { + startPoint = this.getNormalizedPointerEventCoords(positionEvent); + } else { + startPoint = { + x: width / 2, + y: height / 2 + }; + } + startPoint = { + x: startPoint.x - this.initialSize / 2, + y: startPoint.y - this.initialSize / 2 + }; + return { startPoint, endPoint }; + } + startPressAnimation(positionEvent) { + if (!this.mdRoot) { + return; + } + this.pressed = true; + this.growAnimation?.cancel(); + this.determineRippleSize(); + const { startPoint, endPoint } = this.getTranslationCoordinates(positionEvent); + const translateStart = `${startPoint.x}px, ${startPoint.y}px`; + const translateEnd = `${endPoint.x}px, ${endPoint.y}px`; + this.growAnimation = this.mdRoot.animate({ + top: [0, 0], + left: [0, 0], + height: [this.rippleSize, this.rippleSize], + width: [this.rippleSize, this.rippleSize], + transform: [ + `translate(${translateStart}) scale(1)`, + `translate(${translateEnd}) scale(${this.rippleScale})` + ] + }, { + pseudoElement: PRESS_PSEUDO, + duration: PRESS_GROW_MS, + easing: EASING.STANDARD, + fill: ANIMATION_FILL + }); + } + async endPressAnimation() { + this.rippleStartEvent = void 0; + this.state = State.INACTIVE; + const animation = this.growAnimation; + let pressAnimationPlayState = Infinity; + if (typeof animation?.currentTime === "number") { + pressAnimationPlayState = animation.currentTime; + } else if (animation?.currentTime) { + pressAnimationPlayState = animation.currentTime.to("ms").value; + } + if (pressAnimationPlayState >= MINIMUM_PRESS_MS) { + this.pressed = false; + return; + } + await new Promise((resolve) => { + setTimeout(resolve, MINIMUM_PRESS_MS - pressAnimationPlayState); + }); + if (this.growAnimation !== animation) { + return; + } + this.pressed = false; + } + /** + * Returns `true` if + * - the ripple element is enabled + * - the pointer is primary for the input type + * - the pointer is the pointer that started the interaction, or will start + * the interaction + * - the pointer is a touch, or the pointer state has the primary button + * held, or the pointer is hovering + */ + shouldReactToEvent(event) { + if (this.disabled || !event.isPrimary) { + return false; + } + if (this.rippleStartEvent && this.rippleStartEvent.pointerId !== event.pointerId) { + return false; + } + if (event.type === "pointerenter" || event.type === "pointerleave") { + return !this.isTouch(event); + } + const isPrimaryButton = event.buttons === 1; + return this.isTouch(event) || isPrimaryButton; + } + /** + * Check if the event is within the bounds of the element. + * + * This is only needed for the "stuck" contextmenu longpress on Chrome. + */ + inBounds({ x: x2, y: y2 }) { + const { top, left, bottom, right } = this.getBoundingClientRect(); + return x2 >= left && x2 <= right && y2 >= top && y2 <= bottom; + } + isTouch({ pointerType }) { + return pointerType === "touch"; + } + /** @private */ + async handleEvent(event) { + if (FORCED_COLORS?.matches) { + return; + } + switch (event.type) { + case "click": + this.handleClick(); + break; + case "contextmenu": + this.handleContextmenu(); + break; + case "pointercancel": + this.handlePointercancel(event); + break; + case "pointerdown": + await this.handlePointerdown(event); + break; + case "pointerenter": + this.handlePointerenter(event); + break; + case "pointerleave": + this.handlePointerleave(event); + break; + case "pointerup": + this.handlePointerup(event); + break; + default: + break; + } + } + onControlChange(prev, next) { + if (co) + return; + for (const event of EVENTS2) { + prev?.removeEventListener(event, this); + next?.addEventListener(event, this); + } + } + }; + __decorate([ + n4({ type: Boolean, reflect: true }) + ], Ripple.prototype, "disabled", void 0); + __decorate([ + r4() + ], Ripple.prototype, "hovered", void 0); + __decorate([ + r4() + ], Ripple.prototype, "pressed", void 0); + __decorate([ + e4(".surface") + ], Ripple.prototype, "mdRoot", void 0); + + // node_modules/@material/web/ripple/internal/ripple-styles.js + var styles3 = i`:host{display:flex;margin:auto;pointer-events:none}:host([disabled]){display:none}@media(forced-colors: active){:host{display:none}}:host,.surface{border-radius:inherit;position:absolute;inset:0;overflow:hidden}.surface{-webkit-tap-highlight-color:rgba(0,0,0,0)}.surface::before,.surface::after{content:"";opacity:0;position:absolute}.surface::before{background-color:var(--md-ripple-hover-color, var(--md-sys-color-on-surface, #1d1b20));inset:0;transition:opacity 15ms linear,background-color 15ms linear}.surface::after{background:radial-gradient(closest-side, var(--md-ripple-pressed-color, var(--md-sys-color-on-surface, #1d1b20)) max(100% - 70px, 65%), transparent 100%);transform-origin:center center;transition:opacity 375ms linear}.hovered::before{background-color:var(--md-ripple-hover-color, var(--md-sys-color-on-surface, #1d1b20));opacity:var(--md-ripple-hover-opacity, 0.08)}.pressed::after{opacity:var(--md-ripple-pressed-opacity, 0.12);transition-duration:105ms} +`; + + // node_modules/@material/web/ripple/ripple.js + var MdRipple = class MdRipple2 extends Ripple { + }; + MdRipple.styles = [styles3]; + MdRipple = __decorate([ + t2("md-ripple") + ], MdRipple); + + // node_modules/@material/web/internal/aria/aria.js + var ARIA_PROPERTIES = [ + "role", + "ariaAtomic", + "ariaAutoComplete", + "ariaBusy", + "ariaChecked", + "ariaColCount", + "ariaColIndex", + "ariaColSpan", + "ariaCurrent", + "ariaDisabled", + "ariaExpanded", + "ariaHasPopup", + "ariaHidden", + "ariaInvalid", + "ariaKeyShortcuts", + "ariaLabel", + "ariaLevel", + "ariaLive", + "ariaModal", + "ariaMultiLine", + "ariaMultiSelectable", + "ariaOrientation", + "ariaPlaceholder", + "ariaPosInSet", + "ariaPressed", + "ariaReadOnly", + "ariaRequired", + "ariaRoleDescription", + "ariaRowCount", + "ariaRowIndex", + "ariaRowSpan", + "ariaSelected", + "ariaSetSize", + "ariaSort", + "ariaValueMax", + "ariaValueMin", + "ariaValueNow", + "ariaValueText" + ]; + var ARIA_ATTRIBUTES = ARIA_PROPERTIES.map(ariaPropertyToAttribute); + function isAriaAttribute(attribute) { + return ARIA_ATTRIBUTES.includes(attribute); + } + function ariaPropertyToAttribute(property) { + return property.replace("aria", "aria-").replace(/Elements?/g, "").toLowerCase(); + } + + // node_modules/@material/web/internal/aria/delegate.js + var privateIgnoreAttributeChangesFor = Symbol("privateIgnoreAttributeChangesFor"); + function mixinDelegatesAria(base) { + var _a2; + if (co) { + return base; + } + class WithDelegatesAriaElement extends base { + constructor() { + super(...arguments); + this[_a2] = /* @__PURE__ */ new Set(); + } + attributeChangedCallback(name, oldValue, newValue) { + if (!isAriaAttribute(name)) { + super.attributeChangedCallback(name, oldValue, newValue); + return; + } + if (this[privateIgnoreAttributeChangesFor].has(name)) { + return; + } + this[privateIgnoreAttributeChangesFor].add(name); + this.removeAttribute(name); + this[privateIgnoreAttributeChangesFor].delete(name); + const dataProperty = ariaAttributeToDataProperty(name); + if (newValue === null) { + delete this.dataset[dataProperty]; + } else { + this.dataset[dataProperty] = newValue; + } + this.requestUpdate(ariaAttributeToDataProperty(name), oldValue); + } + getAttribute(name) { + if (isAriaAttribute(name)) { + return super.getAttribute(ariaAttributeToDataAttribute(name)); + } + return super.getAttribute(name); + } + removeAttribute(name) { + super.removeAttribute(name); + if (isAriaAttribute(name)) { + super.removeAttribute(ariaAttributeToDataAttribute(name)); + this.requestUpdate(); + } + } + } + _a2 = privateIgnoreAttributeChangesFor; + setupDelegatesAriaProperties(WithDelegatesAriaElement); + return WithDelegatesAriaElement; + } + function setupDelegatesAriaProperties(ctor) { + for (const ariaProperty of ARIA_PROPERTIES) { + const ariaAttribute = ariaPropertyToAttribute(ariaProperty); + const dataAttribute = ariaAttributeToDataAttribute(ariaAttribute); + const dataProperty = ariaAttributeToDataProperty(ariaAttribute); + ctor.createProperty(ariaProperty, { + attribute: ariaAttribute, + noAccessor: true + }); + ctor.createProperty(Symbol(dataAttribute), { + attribute: dataAttribute, + noAccessor: true + }); + Object.defineProperty(ctor.prototype, ariaProperty, { + configurable: true, + enumerable: true, + get() { + return this.dataset[dataProperty] ?? null; + }, + set(value) { + const prevValue = this.dataset[dataProperty] ?? null; + if (value === prevValue) { + return; + } + if (value === null) { + delete this.dataset[dataProperty]; + } else { + this.dataset[dataProperty] = value; + } + this.requestUpdate(ariaProperty, prevValue); + } + }); + } + } + function ariaAttributeToDataAttribute(ariaAttribute) { + return `data-${ariaAttribute}`; + } + function ariaAttributeToDataProperty(ariaAttribute) { + return ariaAttribute.replace(/-\w/, (dashLetter) => dashLetter[1].toUpperCase()); + } + + // node_modules/@material/web/labs/behaviors/element-internals.js + var internals = Symbol("internals"); + var privateInternals = Symbol("privateInternals"); + function mixinElementInternals(base) { + class WithElementInternalsElement extends base { + get [internals]() { + if (!this[privateInternals]) { + this[privateInternals] = this.attachInternals(); + } + return this[privateInternals]; + } + } + return WithElementInternalsElement; + } + + // node_modules/@material/web/internal/controller/form-submitter.js + function setupFormSubmitter(ctor) { + if (co) { + return; + } + ctor.addInitializer((instance) => { + const submitter = instance; + submitter.addEventListener("click", async (event) => { + const { type, [internals]: elementInternals } = submitter; + const { form } = elementInternals; + if (!form || type === "button") { + return; + } + await new Promise((resolve) => { + setTimeout(resolve); + }); + if (event.defaultPrevented) { + return; + } + if (type === "reset") { + form.reset(); + return; + } + form.addEventListener("submit", (submitEvent) => { + Object.defineProperty(submitEvent, "submitter", { + configurable: true, + enumerable: true, + get: () => submitter + }); + }, { capture: true, once: true }); + elementInternals.setFormValue(submitter.value); + form.requestSubmit(); + }); + }); + } + + // node_modules/@material/web/internal/events/form-label-activation.js + function dispatchActivationClick(element) { + const event = new MouseEvent("click", { bubbles: true }); + element.dispatchEvent(event); + return event; + } + function isActivationClick(event) { + if (event.currentTarget !== event.target) { + return false; + } + if (event.composedPath()[0] !== event.target) { + return false; + } + if (event.target.disabled) { + return false; + } + return !squelchEvent(event); + } + function squelchEvent(event) { + const squelched = isSquelchingEvents; + if (squelched) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + squelchEventsForMicrotask(); + return squelched; + } + var isSquelchingEvents = false; + async function squelchEventsForMicrotask() { + isSquelchingEvents = true; + await null; + isSquelchingEvents = false; + } + + // node_modules/@material/web/button/internal/button.js + var buttonBaseClass = mixinDelegatesAria(mixinElementInternals(h3)); + var Button = class extends buttonBaseClass { + get name() { + return this.getAttribute("name") ?? ""; + } + set name(name) { + this.setAttribute("name", name); + } + /** + * The associated form element with which this element's value will submit. + */ + get form() { + return this[internals].form; + } + constructor() { + super(); + this.disabled = false; + this.softDisabled = false; + this.href = ""; + this.target = ""; + this.trailingIcon = false; + this.hasIcon = false; + this.type = "submit"; + this.value = ""; + if (!co) { + this.addEventListener("click", this.handleClick.bind(this)); + } + } + focus() { + this.buttonElement?.focus(); + } + blur() { + this.buttonElement?.blur(); + } + render() { + const isRippleDisabled = !this.href && (this.disabled || this.softDisabled); + const buttonOrLink = this.href ? this.renderLink() : this.renderButton(); + const buttonId = this.href ? "link" : "button"; + return ke` + ${this.renderElevationOrOutline?.()} +
+ + + ${buttonOrLink} + `; + } + renderButton() { + const { ariaLabel, ariaHasPopup, ariaExpanded } = this; + return ke``; + } + renderLink() { + const { ariaLabel, ariaHasPopup, ariaExpanded } = this; + return ke`${this.renderContent()} + `; + } + renderContent() { + const icon = ke``; + return ke` + + ${this.trailingIcon ? D : icon} + + ${this.trailingIcon ? icon : D} + `; + } + handleClick(event) { + if (!this.href && this.softDisabled) { + event.stopImmediatePropagation(); + event.preventDefault(); + return; + } + if (!isActivationClick(event) || !this.buttonElement) { + return; + } + this.focus(); + dispatchActivationClick(this.buttonElement); + } + handleSlotChange() { + this.hasIcon = this.assignedIcons.length > 0; + } + }; + (() => { + setupFormSubmitter(Button); + })(); + Button.formAssociated = true; + Button.shadowRootOptions = { + mode: "open", + delegatesFocus: true + }; + __decorate([ + n4({ type: Boolean, reflect: true }) + ], Button.prototype, "disabled", void 0); + __decorate([ + n4({ type: Boolean, attribute: "soft-disabled", reflect: true }) + ], Button.prototype, "softDisabled", void 0); + __decorate([ + n4() + ], Button.prototype, "href", void 0); + __decorate([ + n4() + ], Button.prototype, "target", void 0); + __decorate([ + n4({ type: Boolean, attribute: "trailing-icon", reflect: true }) + ], Button.prototype, "trailingIcon", void 0); + __decorate([ + n4({ type: Boolean, attribute: "has-icon", reflect: true }) + ], Button.prototype, "hasIcon", void 0); + __decorate([ + n4() + ], Button.prototype, "type", void 0); + __decorate([ + n4({ reflect: true }) + ], Button.prototype, "value", void 0); + __decorate([ + e4(".button") + ], Button.prototype, "buttonElement", void 0); + __decorate([ + o4({ slot: "icon", flatten: true }) + ], Button.prototype, "assignedIcons", void 0); + + // node_modules/@material/web/button/internal/filled-button.js + var FilledButton = class extends Button { + renderElevationOrOutline() { + return ke``; + } + }; + + // node_modules/@material/web/button/internal/filled-styles.js + var styles4 = i`:host{--_container-color: var(--md-filled-button-container-color, var(--md-sys-color-primary, #6750a4));--_container-elevation: var(--md-filled-button-container-elevation, 0);--_container-height: var(--md-filled-button-container-height, 40px);--_container-shadow-color: var(--md-filled-button-container-shadow-color, var(--md-sys-color-shadow, #000));--_disabled-container-color: var(--md-filled-button-disabled-container-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-container-elevation: var(--md-filled-button-disabled-container-elevation, 0);--_disabled-container-opacity: var(--md-filled-button-disabled-container-opacity, 0.12);--_disabled-label-text-color: var(--md-filled-button-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-filled-button-disabled-label-text-opacity, 0.38);--_focus-container-elevation: var(--md-filled-button-focus-container-elevation, 0);--_focus-label-text-color: var(--md-filled-button-focus-label-text-color, var(--md-sys-color-on-primary, #fff));--_hover-container-elevation: var(--md-filled-button-hover-container-elevation, 1);--_hover-label-text-color: var(--md-filled-button-hover-label-text-color, var(--md-sys-color-on-primary, #fff));--_hover-state-layer-color: var(--md-filled-button-hover-state-layer-color, var(--md-sys-color-on-primary, #fff));--_hover-state-layer-opacity: var(--md-filled-button-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-filled-button-label-text-color, var(--md-sys-color-on-primary, #fff));--_label-text-font: var(--md-filled-button-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-filled-button-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-filled-button-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-filled-button-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_pressed-container-elevation: var(--md-filled-button-pressed-container-elevation, 0);--_pressed-label-text-color: var(--md-filled-button-pressed-label-text-color, var(--md-sys-color-on-primary, #fff));--_pressed-state-layer-color: var(--md-filled-button-pressed-state-layer-color, var(--md-sys-color-on-primary, #fff));--_pressed-state-layer-opacity: var(--md-filled-button-pressed-state-layer-opacity, 0.12);--_disabled-icon-color: var(--md-filled-button-disabled-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-icon-opacity: var(--md-filled-button-disabled-icon-opacity, 0.38);--_focus-icon-color: var(--md-filled-button-focus-icon-color, var(--md-sys-color-on-primary, #fff));--_hover-icon-color: var(--md-filled-button-hover-icon-color, var(--md-sys-color-on-primary, #fff));--_icon-color: var(--md-filled-button-icon-color, var(--md-sys-color-on-primary, #fff));--_icon-size: var(--md-filled-button-icon-size, 18px);--_pressed-icon-color: var(--md-filled-button-pressed-icon-color, var(--md-sys-color-on-primary, #fff));--_container-shape-start-start: var(--md-filled-button-container-shape-start-start, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-start-end: var(--md-filled-button-container-shape-start-end, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-end: var(--md-filled-button-container-shape-end-end, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-start: var(--md-filled-button-container-shape-end-start, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_leading-space: var(--md-filled-button-leading-space, 24px);--_trailing-space: var(--md-filled-button-trailing-space, 24px);--_with-leading-icon-leading-space: var(--md-filled-button-with-leading-icon-leading-space, 16px);--_with-leading-icon-trailing-space: var(--md-filled-button-with-leading-icon-trailing-space, 24px);--_with-trailing-icon-leading-space: var(--md-filled-button-with-trailing-icon-leading-space, 24px);--_with-trailing-icon-trailing-space: var(--md-filled-button-with-trailing-icon-trailing-space, 16px)} +`; + + // node_modules/@material/web/button/internal/shared-elevation-styles.js + var styles5 = i`md-elevation{transition-duration:280ms}:host(:is([disabled],[soft-disabled])) md-elevation{transition:none}md-elevation{--md-elevation-level: var(--_container-elevation);--md-elevation-shadow-color: var(--_container-shadow-color)}:host(:focus-within) md-elevation{--md-elevation-level: var(--_focus-container-elevation)}:host(:hover) md-elevation{--md-elevation-level: var(--_hover-container-elevation)}:host(:active) md-elevation{--md-elevation-level: var(--_pressed-container-elevation)}:host(:is([disabled],[soft-disabled])) md-elevation{--md-elevation-level: var(--_disabled-container-elevation)} +`; + + // node_modules/@material/web/button/internal/shared-styles.js + var styles6 = i`:host{border-start-start-radius:var(--_container-shape-start-start);border-start-end-radius:var(--_container-shape-start-end);border-end-start-radius:var(--_container-shape-end-start);border-end-end-radius:var(--_container-shape-end-end);box-sizing:border-box;cursor:pointer;display:inline-flex;gap:8px;min-height:var(--_container-height);outline:none;padding-block:calc((var(--_container-height) - max(var(--_label-text-line-height),var(--_icon-size)))/2);padding-inline-start:var(--_leading-space);padding-inline-end:var(--_trailing-space);place-content:center;place-items:center;position:relative;font-family:var(--_label-text-font);font-size:var(--_label-text-size);line-height:var(--_label-text-line-height);font-weight:var(--_label-text-weight);text-overflow:ellipsis;text-wrap:nowrap;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);vertical-align:top;--md-ripple-hover-color: var(--_hover-state-layer-color);--md-ripple-pressed-color: var(--_pressed-state-layer-color);--md-ripple-hover-opacity: var(--_hover-state-layer-opacity);--md-ripple-pressed-opacity: var(--_pressed-state-layer-opacity)}md-focus-ring{--md-focus-ring-shape-start-start: var(--_container-shape-start-start);--md-focus-ring-shape-start-end: var(--_container-shape-start-end);--md-focus-ring-shape-end-end: var(--_container-shape-end-end);--md-focus-ring-shape-end-start: var(--_container-shape-end-start)}:host(:is([disabled],[soft-disabled])){cursor:default;pointer-events:none}.button{border-radius:inherit;cursor:inherit;display:inline-flex;align-items:center;justify-content:center;border:none;outline:none;-webkit-appearance:none;vertical-align:middle;background:rgba(0,0,0,0);text-decoration:none;min-width:calc(64px - var(--_leading-space) - var(--_trailing-space));width:100%;z-index:0;height:100%;font:inherit;color:var(--_label-text-color);padding:0;gap:inherit;text-transform:inherit}.button::-moz-focus-inner{padding:0;border:0}:host(:hover) .button{color:var(--_hover-label-text-color)}:host(:focus-within) .button{color:var(--_focus-label-text-color)}:host(:active) .button{color:var(--_pressed-label-text-color)}.background{background-color:var(--_container-color);border-radius:inherit;inset:0;position:absolute}.label{overflow:hidden}:is(.button,.label,.label slot),.label ::slotted(*){text-overflow:inherit}:host(:is([disabled],[soft-disabled])) .label{color:var(--_disabled-label-text-color);opacity:var(--_disabled-label-text-opacity)}:host(:is([disabled],[soft-disabled])) .background{background-color:var(--_disabled-container-color);opacity:var(--_disabled-container-opacity)}@media(forced-colors: active){.background{border:1px solid CanvasText}:host(:is([disabled],[soft-disabled])){--_disabled-icon-color: GrayText;--_disabled-icon-opacity: 1;--_disabled-container-opacity: 1;--_disabled-label-text-color: GrayText;--_disabled-label-text-opacity: 1}}:host([has-icon]:not([trailing-icon])){padding-inline-start:var(--_with-leading-icon-leading-space);padding-inline-end:var(--_with-leading-icon-trailing-space)}:host([has-icon][trailing-icon]){padding-inline-start:var(--_with-trailing-icon-leading-space);padding-inline-end:var(--_with-trailing-icon-trailing-space)}::slotted([slot=icon]){display:inline-flex;position:relative;writing-mode:horizontal-tb;fill:currentColor;flex-shrink:0;color:var(--_icon-color);font-size:var(--_icon-size);inline-size:var(--_icon-size);block-size:var(--_icon-size)}:host(:hover) ::slotted([slot=icon]){color:var(--_hover-icon-color)}:host(:focus-within) ::slotted([slot=icon]){color:var(--_focus-icon-color)}:host(:active) ::slotted([slot=icon]){color:var(--_pressed-icon-color)}:host(:is([disabled],[soft-disabled])) ::slotted([slot=icon]){color:var(--_disabled-icon-color);opacity:var(--_disabled-icon-opacity)}.touch{position:absolute;top:50%;height:48px;left:0;right:0;transform:translateY(-50%)}:host([touch-target=wrapper]){margin:max(0px,(48px - var(--_container-height))/2) 0}:host([touch-target=none]) .touch{display:none} +`; + + // node_modules/@material/web/button/filled-button.js + var MdFilledButton = class MdFilledButton2 extends FilledButton { + }; + MdFilledButton.styles = [ + styles6, + styles5, + styles4 + ]; + MdFilledButton = __decorate([ + t2("md-filled-button") + ], MdFilledButton); + + // node_modules/@material/web/button/internal/filled-tonal-button.js + var FilledTonalButton = class extends Button { + renderElevationOrOutline() { + return ke``; + } + }; + + // node_modules/@material/web/button/internal/filled-tonal-styles.js + var styles7 = i`:host{--_container-color: var(--md-filled-tonal-button-container-color, var(--md-sys-color-secondary-container, #e8def8));--_container-elevation: var(--md-filled-tonal-button-container-elevation, 0);--_container-height: var(--md-filled-tonal-button-container-height, 40px);--_container-shadow-color: var(--md-filled-tonal-button-container-shadow-color, var(--md-sys-color-shadow, #000));--_disabled-container-color: var(--md-filled-tonal-button-disabled-container-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-container-elevation: var(--md-filled-tonal-button-disabled-container-elevation, 0);--_disabled-container-opacity: var(--md-filled-tonal-button-disabled-container-opacity, 0.12);--_disabled-label-text-color: var(--md-filled-tonal-button-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-filled-tonal-button-disabled-label-text-opacity, 0.38);--_focus-container-elevation: var(--md-filled-tonal-button-focus-container-elevation, 0);--_focus-label-text-color: var(--md-filled-tonal-button-focus-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_hover-container-elevation: var(--md-filled-tonal-button-hover-container-elevation, 1);--_hover-label-text-color: var(--md-filled-tonal-button-hover-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_hover-state-layer-color: var(--md-filled-tonal-button-hover-state-layer-color, var(--md-sys-color-on-secondary-container, #1d192b));--_hover-state-layer-opacity: var(--md-filled-tonal-button-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-filled-tonal-button-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_label-text-font: var(--md-filled-tonal-button-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-filled-tonal-button-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-filled-tonal-button-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-filled-tonal-button-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_pressed-container-elevation: var(--md-filled-tonal-button-pressed-container-elevation, 0);--_pressed-label-text-color: var(--md-filled-tonal-button-pressed-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_pressed-state-layer-color: var(--md-filled-tonal-button-pressed-state-layer-color, var(--md-sys-color-on-secondary-container, #1d192b));--_pressed-state-layer-opacity: var(--md-filled-tonal-button-pressed-state-layer-opacity, 0.12);--_disabled-icon-color: var(--md-filled-tonal-button-disabled-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-icon-opacity: var(--md-filled-tonal-button-disabled-icon-opacity, 0.38);--_focus-icon-color: var(--md-filled-tonal-button-focus-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_hover-icon-color: var(--md-filled-tonal-button-hover-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_icon-color: var(--md-filled-tonal-button-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_icon-size: var(--md-filled-tonal-button-icon-size, 18px);--_pressed-icon-color: var(--md-filled-tonal-button-pressed-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_container-shape-start-start: var(--md-filled-tonal-button-container-shape-start-start, var(--md-filled-tonal-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-start-end: var(--md-filled-tonal-button-container-shape-start-end, var(--md-filled-tonal-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-end: var(--md-filled-tonal-button-container-shape-end-end, var(--md-filled-tonal-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-start: var(--md-filled-tonal-button-container-shape-end-start, var(--md-filled-tonal-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_leading-space: var(--md-filled-tonal-button-leading-space, 24px);--_trailing-space: var(--md-filled-tonal-button-trailing-space, 24px);--_with-leading-icon-leading-space: var(--md-filled-tonal-button-with-leading-icon-leading-space, 16px);--_with-leading-icon-trailing-space: var(--md-filled-tonal-button-with-leading-icon-trailing-space, 24px);--_with-trailing-icon-leading-space: var(--md-filled-tonal-button-with-trailing-icon-leading-space, 24px);--_with-trailing-icon-trailing-space: var(--md-filled-tonal-button-with-trailing-icon-trailing-space, 16px)} +`; + + // node_modules/@material/web/button/filled-tonal-button.js + var MdFilledTonalButton = class MdFilledTonalButton2 extends FilledTonalButton { + }; + MdFilledTonalButton.styles = [ + styles6, + styles5, + styles7 + ]; + MdFilledTonalButton = __decorate([ + t2("md-filled-tonal-button") + ], MdFilledTonalButton); + + // node_modules/@material/web/button/internal/text-button.js + var TextButton = class extends Button { + }; + + // node_modules/@material/web/button/internal/text-styles.js + var styles8 = i`:host{--_container-height: var(--md-text-button-container-height, 40px);--_disabled-label-text-color: var(--md-text-button-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-text-button-disabled-label-text-opacity, 0.38);--_focus-label-text-color: var(--md-text-button-focus-label-text-color, var(--md-sys-color-primary, #6750a4));--_hover-label-text-color: var(--md-text-button-hover-label-text-color, var(--md-sys-color-primary, #6750a4));--_hover-state-layer-color: var(--md-text-button-hover-state-layer-color, var(--md-sys-color-primary, #6750a4));--_hover-state-layer-opacity: var(--md-text-button-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-text-button-label-text-color, var(--md-sys-color-primary, #6750a4));--_label-text-font: var(--md-text-button-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-text-button-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-text-button-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-text-button-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_pressed-label-text-color: var(--md-text-button-pressed-label-text-color, var(--md-sys-color-primary, #6750a4));--_pressed-state-layer-color: var(--md-text-button-pressed-state-layer-color, var(--md-sys-color-primary, #6750a4));--_pressed-state-layer-opacity: var(--md-text-button-pressed-state-layer-opacity, 0.12);--_disabled-icon-color: var(--md-text-button-disabled-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-icon-opacity: var(--md-text-button-disabled-icon-opacity, 0.38);--_focus-icon-color: var(--md-text-button-focus-icon-color, var(--md-sys-color-primary, #6750a4));--_hover-icon-color: var(--md-text-button-hover-icon-color, var(--md-sys-color-primary, #6750a4));--_icon-color: var(--md-text-button-icon-color, var(--md-sys-color-primary, #6750a4));--_icon-size: var(--md-text-button-icon-size, 18px);--_pressed-icon-color: var(--md-text-button-pressed-icon-color, var(--md-sys-color-primary, #6750a4));--_container-shape-start-start: var(--md-text-button-container-shape-start-start, var(--md-text-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-start-end: var(--md-text-button-container-shape-start-end, var(--md-text-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-end: var(--md-text-button-container-shape-end-end, var(--md-text-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-start: var(--md-text-button-container-shape-end-start, var(--md-text-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_leading-space: var(--md-text-button-leading-space, 12px);--_trailing-space: var(--md-text-button-trailing-space, 12px);--_with-leading-icon-leading-space: var(--md-text-button-with-leading-icon-leading-space, 12px);--_with-leading-icon-trailing-space: var(--md-text-button-with-leading-icon-trailing-space, 16px);--_with-trailing-icon-leading-space: var(--md-text-button-with-trailing-icon-leading-space, 16px);--_with-trailing-icon-trailing-space: var(--md-text-button-with-trailing-icon-trailing-space, 12px);--_container-color: none;--_disabled-container-color: none;--_disabled-container-opacity: 0} +`; + + // node_modules/@material/web/button/text-button.js + var MdTextButton = class MdTextButton2 extends TextButton { + }; + MdTextButton.styles = [styles6, styles8]; + MdTextButton = __decorate([ + t2("md-text-button") + ], MdTextButton); + + // node_modules/@material/web/divider/internal/divider.js + var Divider = class extends h3 { + constructor() { + super(...arguments); + this.inset = false; + this.insetStart = false; + this.insetEnd = false; + } + }; + __decorate([ + n4({ type: Boolean, reflect: true }) + ], Divider.prototype, "inset", void 0); + __decorate([ + n4({ type: Boolean, reflect: true, attribute: "inset-start" }) + ], Divider.prototype, "insetStart", void 0); + __decorate([ + n4({ type: Boolean, reflect: true, attribute: "inset-end" }) + ], Divider.prototype, "insetEnd", void 0); + + // node_modules/@material/web/divider/internal/divider-styles.js + var styles9 = i`:host{box-sizing:border-box;color:var(--md-divider-color, var(--md-sys-color-outline-variant, #cac4d0));display:flex;height:var(--md-divider-thickness, 1px);width:100%}:host([inset]),:host([inset-start]){padding-inline-start:16px}:host([inset]),:host([inset-end]){padding-inline-end:16px}:host::before{background:currentColor;content:"";height:100%;width:100%}@media(forced-colors: active){:host::before{background:CanvasText}} +`; + + // node_modules/@material/web/divider/divider.js + var MdDivider = class MdDivider2 extends Divider { + }; + MdDivider.styles = [styles9]; + MdDivider = __decorate([ + t2("md-divider") + ], MdDivider); + + // node_modules/@material/web/internal/events/redispatch-event.js + function redispatchEvent(element, event) { + if (event.bubbles && (!element.shadowRoot || event.composed)) { + event.stopPropagation(); + } + const copy = Reflect.construct(event.constructor, [event.type, event]); + const dispatched = element.dispatchEvent(copy); + if (!dispatched) { + event.preventDefault(); + } + return dispatched; + } + + // node_modules/@material/web/dialog/internal/animations.js + var DIALOG_DEFAULT_OPEN_ANIMATION = { + dialog: [ + [ + // Dialog slide down + [{ "transform": "translateY(-50px)" }, { "transform": "translateY(0)" }], + { duration: 500, easing: EASING.EMPHASIZED } + ] + ], + scrim: [ + [ + // Scrim fade in + [{ "opacity": 0 }, { "opacity": 0.32 }], + { duration: 500, easing: "linear" } + ] + ], + container: [ + [ + // Container fade in + [{ "opacity": 0 }, { "opacity": 1 }], + { duration: 50, easing: "linear", pseudoElement: "::before" } + ], + [ + // Container grow + // Note: current spec says to grow from 0dp->100% and shrink from + // 100%->35%. We change this to 35%->100% to simplify the animation that + // is supposed to clip content as it grows. From 0dp it's possible to see + // text/actions appear before the container has fully grown. + [{ "height": "35%" }, { "height": "100%" }], + { duration: 500, easing: EASING.EMPHASIZED, pseudoElement: "::before" } + ] + ], + headline: [ + [ + // Headline fade in + [{ "opacity": 0 }, { "opacity": 0, offset: 0.2 }, { "opacity": 1 }], + { duration: 250, easing: "linear", fill: "forwards" } + ] + ], + content: [ + [ + // Content fade in + [{ "opacity": 0 }, { "opacity": 0, offset: 0.2 }, { "opacity": 1 }], + { duration: 250, easing: "linear", fill: "forwards" } + ] + ], + actions: [ + [ + // Actions fade in + [{ "opacity": 0 }, { "opacity": 0, offset: 0.5 }, { "opacity": 1 }], + { duration: 300, easing: "linear", fill: "forwards" } + ] + ] + }; + var DIALOG_DEFAULT_CLOSE_ANIMATION = { + dialog: [ + [ + // Dialog slide up + [{ "transform": "translateY(0)" }, { "transform": "translateY(-50px)" }], + { duration: 150, easing: EASING.EMPHASIZED_ACCELERATE } + ] + ], + scrim: [ + [ + // Scrim fade out + [{ "opacity": 0.32 }, { "opacity": 0 }], + { duration: 150, easing: "linear" } + ] + ], + container: [ + [ + // Container shrink + [{ "height": "100%" }, { "height": "35%" }], + { + duration: 150, + easing: EASING.EMPHASIZED_ACCELERATE, + pseudoElement: "::before" + } + ], + [ + // Container fade out + [{ "opacity": "1" }, { "opacity": "0" }], + { delay: 100, duration: 50, easing: "linear", pseudoElement: "::before" } + ] + ], + headline: [ + [ + // Headline fade out + [{ "opacity": 1 }, { "opacity": 0 }], + { duration: 100, easing: "linear", fill: "forwards" } + ] + ], + content: [ + [ + // Content fade out + [{ "opacity": 1 }, { "opacity": 0 }], + { duration: 100, easing: "linear", fill: "forwards" } + ] + ], + actions: [ + [ + // Actions fade out + [{ "opacity": 1 }, { "opacity": 0 }], + { duration: 100, easing: "linear", fill: "forwards" } + ] + ] + }; + + // node_modules/@material/web/dialog/internal/dialog.js + var dialogBaseClass = mixinDelegatesAria(h3); + var Dialog = class extends dialogBaseClass { + // We do not use `delegatesFocus: true` due to a Chromium bug with + // selecting text. + // See https://bugs.chromium.org/p/chromium/issues/detail?id=950357 + /** + * Opens the dialog when set to `true` and closes it when set to `false`. + */ + get open() { + return this.isOpen; + } + set open(open) { + if (open === this.isOpen) { + return; + } + this.isOpen = open; + if (open) { + this.setAttribute("open", ""); + this.show(); + } else { + this.removeAttribute("open"); + this.close(); + } + } + constructor() { + super(); + this.quick = false; + this.returnValue = ""; + this.noFocusTrap = false; + this.getOpenAnimation = () => DIALOG_DEFAULT_OPEN_ANIMATION; + this.getCloseAnimation = () => DIALOG_DEFAULT_CLOSE_ANIMATION; + this.isOpen = false; + this.isOpening = false; + this.isConnectedPromise = this.getIsConnectedPromise(); + this.isAtScrollTop = false; + this.isAtScrollBottom = false; + this.nextClickIsFromContent = false; + this.hasHeadline = false; + this.hasActions = false; + this.hasIcon = false; + this.escapePressedWithoutCancel = false; + this.treewalker = co ? null : document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT); + if (!co) { + this.addEventListener("submit", this.handleSubmit); + } + } + /** + * Opens the dialog and fires a cancelable `open` event. After a dialog's + * animation, an `opened` event is fired. + * + * Add an `autofocus` attribute to a child of the dialog that should + * receive focus after opening. + * + * @return A Promise that resolves after the animation is finished and the + * `opened` event was fired. + */ + async show() { + this.isOpening = true; + await this.isConnectedPromise; + await this.updateComplete; + const dialog = this.dialog; + if (dialog.open || !this.isOpening) { + this.isOpening = false; + return; + } + const preventOpen = !this.dispatchEvent(new Event("open", { cancelable: true })); + if (preventOpen) { + this.open = false; + this.isOpening = false; + return; + } + dialog.showModal(); + this.open = true; + if (this.scroller) { + this.scroller.scrollTop = 0; + } + this.querySelector("[autofocus]")?.focus(); + await this.animateDialog(this.getOpenAnimation()); + this.dispatchEvent(new Event("opened")); + this.isOpening = false; + } + /** + * Closes the dialog and fires a cancelable `close` event. After a dialog's + * animation, a `closed` event is fired. + * + * @param returnValue A return value usually indicating which button was used + * to close a dialog. If a dialog is canceled by clicking the scrim or + * pressing Escape, it will not change the return value after closing. + * @return A Promise that resolves after the animation is finished and the + * `closed` event was fired. + */ + async close(returnValue = this.returnValue) { + this.isOpening = false; + if (!this.isConnected) { + this.open = false; + return; + } + await this.updateComplete; + const dialog = this.dialog; + if (!dialog.open || this.isOpening) { + this.open = false; + return; + } + const prevReturnValue = this.returnValue; + this.returnValue = returnValue; + const preventClose = !this.dispatchEvent(new Event("close", { cancelable: true })); + if (preventClose) { + this.returnValue = prevReturnValue; + return; + } + await this.animateDialog(this.getCloseAnimation()); + dialog.close(returnValue); + this.open = false; + this.dispatchEvent(new Event("closed")); + } + connectedCallback() { + super.connectedCallback(); + this.isConnectedPromiseResolve(); + } + disconnectedCallback() { + super.disconnectedCallback(); + this.isConnectedPromise = this.getIsConnectedPromise(); + } + render() { + const scrollable = this.open && !(this.isAtScrollTop && this.isAtScrollBottom); + const classes = { + "has-headline": this.hasHeadline, + "has-actions": this.hasActions, + "has-icon": this.hasIcon, + "scrollable": scrollable, + "show-top-divider": scrollable && !this.isAtScrollTop, + "show-bottom-divider": scrollable && !this.isAtScrollBottom + }; + const showFocusTrap = this.open && !this.noFocusTrap; + const focusTrap = ke` + + `; + const { ariaLabel } = this; + return ke` +
+ + ${showFocusTrap ? focusTrap : D} +
+
+ +

+ +

+ +
+
+
+
+ +
+
+
+
+ + +
+
+ ${showFocusTrap ? focusTrap : D} +
+ `; + } + firstUpdated() { + this.intersectionObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + this.handleAnchorIntersection(entry); + } + }, { root: this.scroller }); + this.intersectionObserver.observe(this.topAnchor); + this.intersectionObserver.observe(this.bottomAnchor); + } + handleDialogClick() { + if (this.nextClickIsFromContent) { + this.nextClickIsFromContent = false; + return; + } + const preventDefault = !this.dispatchEvent(new Event("cancel", { cancelable: true })); + if (preventDefault) { + return; + } + this.close(); + } + handleContentClick() { + this.nextClickIsFromContent = true; + } + handleSubmit(event) { + const form = event.target; + const { submitter } = event; + if (form.method !== "dialog" || !submitter) { + return; + } + this.close(submitter.getAttribute("value") ?? this.returnValue); + } + handleCancel(event) { + if (event.target !== this.dialog) { + return; + } + this.escapePressedWithoutCancel = false; + const preventDefault = !redispatchEvent(this, event); + event.preventDefault(); + if (preventDefault) { + return; + } + this.close(); + } + handleClose() { + if (!this.escapePressedWithoutCancel) { + return; + } + this.escapePressedWithoutCancel = false; + this.dialog?.dispatchEvent(new Event("cancel", { cancelable: true })); + } + handleKeydown(event) { + if (event.key !== "Escape") { + return; + } + this.escapePressedWithoutCancel = true; + setTimeout(() => { + this.escapePressedWithoutCancel = false; + }); + } + async animateDialog(animation) { + this.cancelAnimations?.abort(); + this.cancelAnimations = new AbortController(); + if (this.quick) { + return; + } + const { dialog, scrim, container, headline, content, actions } = this; + if (!dialog || !scrim || !container || !headline || !content || !actions) { + return; + } + const { container: containerAnimate, dialog: dialogAnimate, scrim: scrimAnimate, headline: headlineAnimate, content: contentAnimate, actions: actionsAnimate } = animation; + const elementAndAnimation = [ + [dialog, dialogAnimate ?? []], + [scrim, scrimAnimate ?? []], + [container, containerAnimate ?? []], + [headline, headlineAnimate ?? []], + [content, contentAnimate ?? []], + [actions, actionsAnimate ?? []] + ]; + const animations = []; + for (const [element, animation2] of elementAndAnimation) { + for (const animateArgs of animation2) { + const animation3 = element.animate(...animateArgs); + this.cancelAnimations.signal.addEventListener("abort", () => { + animation3.cancel(); + }); + animations.push(animation3); + } + } + await Promise.all(animations.map((animation2) => animation2.finished.catch(() => { + }))); + } + handleHeadlineChange(event) { + const slot = event.target; + this.hasHeadline = slot.assignedElements().length > 0; + } + handleActionsChange(event) { + const slot = event.target; + this.hasActions = slot.assignedElements().length > 0; + } + handleIconChange(event) { + const slot = event.target; + this.hasIcon = slot.assignedElements().length > 0; + } + handleAnchorIntersection(entry) { + const { target, isIntersecting } = entry; + if (target === this.topAnchor) { + this.isAtScrollTop = isIntersecting; + } + if (target === this.bottomAnchor) { + this.isAtScrollBottom = isIntersecting; + } + } + getIsConnectedPromise() { + return new Promise((resolve) => { + this.isConnectedPromiseResolve = resolve; + }); + } + handleFocusTrapFocus(event) { + const [firstFocusableChild, lastFocusableChild] = this.getFirstAndLastFocusableChildren(); + if (!firstFocusableChild || !lastFocusableChild) { + this.dialog?.focus(); + return; + } + const isFirstFocusTrap = event.target === this.firstFocusTrap; + const isLastFocusTrap = !isFirstFocusTrap; + const focusCameFromFirstChild = event.relatedTarget === firstFocusableChild; + const focusCameFromLastChild = event.relatedTarget === lastFocusableChild; + const focusCameFromOutsideDialog = !focusCameFromFirstChild && !focusCameFromLastChild; + const shouldFocusFirstChild = isLastFocusTrap && focusCameFromLastChild || isFirstFocusTrap && focusCameFromOutsideDialog; + if (shouldFocusFirstChild) { + firstFocusableChild.focus(); + return; + } + const shouldFocusLastChild = isFirstFocusTrap && focusCameFromFirstChild || isLastFocusTrap && focusCameFromOutsideDialog; + if (shouldFocusLastChild) { + lastFocusableChild.focus(); + return; + } + } + getFirstAndLastFocusableChildren() { + if (!this.treewalker) { + return [null, null]; + } + let firstFocusableChild = null; + let lastFocusableChild = null; + this.treewalker.currentNode = this.treewalker.root; + while (this.treewalker.nextNode()) { + const nextChild = this.treewalker.currentNode; + if (!isFocusable(nextChild)) { + continue; + } + if (!firstFocusableChild) { + firstFocusableChild = nextChild; + } + lastFocusableChild = nextChild; + } + return [firstFocusableChild, lastFocusableChild]; + } + }; + __decorate([ + n4({ type: Boolean }) + ], Dialog.prototype, "open", null); + __decorate([ + n4({ type: Boolean }) + ], Dialog.prototype, "quick", void 0); + __decorate([ + n4({ attribute: false }) + ], Dialog.prototype, "returnValue", void 0); + __decorate([ + n4() + ], Dialog.prototype, "type", void 0); + __decorate([ + n4({ type: Boolean, attribute: "no-focus-trap" }) + ], Dialog.prototype, "noFocusTrap", void 0); + __decorate([ + e4("dialog") + ], Dialog.prototype, "dialog", void 0); + __decorate([ + e4(".scrim") + ], Dialog.prototype, "scrim", void 0); + __decorate([ + e4(".container") + ], Dialog.prototype, "container", void 0); + __decorate([ + e4(".headline") + ], Dialog.prototype, "headline", void 0); + __decorate([ + e4(".content") + ], Dialog.prototype, "content", void 0); + __decorate([ + e4(".actions") + ], Dialog.prototype, "actions", void 0); + __decorate([ + r4() + ], Dialog.prototype, "isAtScrollTop", void 0); + __decorate([ + r4() + ], Dialog.prototype, "isAtScrollBottom", void 0); + __decorate([ + e4(".scroller") + ], Dialog.prototype, "scroller", void 0); + __decorate([ + e4(".top.anchor") + ], Dialog.prototype, "topAnchor", void 0); + __decorate([ + e4(".bottom.anchor") + ], Dialog.prototype, "bottomAnchor", void 0); + __decorate([ + e4(".focus-trap") + ], Dialog.prototype, "firstFocusTrap", void 0); + __decorate([ + r4() + ], Dialog.prototype, "hasHeadline", void 0); + __decorate([ + r4() + ], Dialog.prototype, "hasActions", void 0); + __decorate([ + r4() + ], Dialog.prototype, "hasIcon", void 0); + function isFocusable(element) { + const knownFocusableElements = ":is(button,input,select,textarea,object,:is(a,area)[href],[tabindex],[contenteditable=true])"; + const notDisabled = ":not(:disabled,[disabled])"; + const notNegativeTabIndex = ':not([tabindex^="-"])'; + if (element.matches(knownFocusableElements + notDisabled + notNegativeTabIndex)) { + return true; + } + const isCustomElement = element.localName.includes("-"); + if (!isCustomElement) { + return false; + } + if (!element.matches(notDisabled)) { + return false; + } + return element.shadowRoot?.delegatesFocus ?? false; + } + + // node_modules/@material/web/dialog/internal/dialog-styles.js + var styles10 = i`:host{border-start-start-radius:var(--md-dialog-container-shape-start-start, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-start-end-radius:var(--md-dialog-container-shape-start-end, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-end-end-radius:var(--md-dialog-container-shape-end-end, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-end-start-radius:var(--md-dialog-container-shape-end-start, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));display:contents;margin:auto;max-height:min(560px,100% - 48px);max-width:min(560px,100% - 48px);min-height:140px;min-width:280px;position:fixed;height:fit-content;width:fit-content}dialog{background:rgba(0,0,0,0);border:none;border-radius:inherit;flex-direction:column;height:inherit;margin:inherit;max-height:inherit;max-width:inherit;min-height:inherit;min-width:inherit;outline:none;overflow:visible;padding:0;width:inherit}dialog[open]{display:flex}::backdrop{background:none}.scrim{background:var(--md-sys-color-scrim, #000);display:none;inset:0;opacity:32%;pointer-events:none;position:fixed;z-index:1}:host([open]) .scrim{display:flex}h2{all:unset;align-self:stretch}.headline{align-items:center;color:var(--md-dialog-headline-color, var(--md-sys-color-on-surface, #1d1b20));display:flex;flex-direction:column;font-family:var(--md-dialog-headline-font, var(--md-sys-typescale-headline-small-font, var(--md-ref-typeface-brand, Roboto)));font-size:var(--md-dialog-headline-size, var(--md-sys-typescale-headline-small-size, 1.5rem));line-height:var(--md-dialog-headline-line-height, var(--md-sys-typescale-headline-small-line-height, 2rem));font-weight:var(--md-dialog-headline-weight, var(--md-sys-typescale-headline-small-weight, var(--md-ref-typeface-weight-regular, 400)));position:relative}slot[name=headline]::slotted(*){align-items:center;align-self:stretch;box-sizing:border-box;display:flex;gap:8px;padding:24px 24px 0}.icon{display:flex}slot[name=icon]::slotted(*){color:var(--md-dialog-icon-color, var(--md-sys-color-secondary, #625b71));fill:currentColor;font-size:var(--md-dialog-icon-size, 24px);margin-top:24px;height:var(--md-dialog-icon-size, 24px);width:var(--md-dialog-icon-size, 24px)}.has-icon slot[name=headline]::slotted(*){justify-content:center;padding-top:16px}.scrollable slot[name=headline]::slotted(*){padding-bottom:16px}.scrollable.has-headline slot[name=content]::slotted(*){padding-top:8px}.container{border-radius:inherit;display:flex;flex-direction:column;flex-grow:1;overflow:hidden;position:relative;transform-origin:top}.container::before{background:var(--md-dialog-container-color, var(--md-sys-color-surface-container-high, #ece6f0));border-radius:inherit;content:"";inset:0;position:absolute}.scroller{display:flex;flex:1;flex-direction:column;overflow:hidden;z-index:1}.scrollable .scroller{overflow-y:scroll}.content{color:var(--md-dialog-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));font-family:var(--md-dialog-supporting-text-font, var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-dialog-supporting-text-size, var(--md-sys-typescale-body-medium-size, 0.875rem));line-height:var(--md-dialog-supporting-text-line-height, var(--md-sys-typescale-body-medium-line-height, 1.25rem));flex:1;font-weight:var(--md-dialog-supporting-text-weight, var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400)));height:min-content;position:relative}slot[name=content]::slotted(*){box-sizing:border-box;padding:24px}.anchor{position:absolute}.top.anchor{top:0}.bottom.anchor{bottom:0}.actions{position:relative}slot[name=actions]::slotted(*){box-sizing:border-box;display:flex;gap:8px;justify-content:flex-end;padding:16px 24px 24px}.has-actions slot[name=content]::slotted(*){padding-bottom:8px}md-divider{display:none;position:absolute}.has-headline.show-top-divider .headline md-divider,.has-actions.show-bottom-divider .actions md-divider{display:flex}.headline md-divider{bottom:0}.actions md-divider{top:0}@media(forced-colors: active){dialog{outline:2px solid WindowText}} +`; + + // node_modules/@material/web/dialog/dialog.js + var MdDialog = class MdDialog2 extends Dialog { + }; + MdDialog.styles = [styles10]; + MdDialog = __decorate([ + t2("md-dialog") + ], MdDialog); + + // node_modules/@material/web/icon/internal/icon.js + var Icon = class extends h3 { + render() { + return ke``; + } + connectedCallback() { + super.connectedCallback(); + const ariaHidden = this.getAttribute("aria-hidden"); + if (ariaHidden === "false") { + this.removeAttribute("aria-hidden"); + return; + } + this.setAttribute("aria-hidden", "true"); + } + }; + + // node_modules/@material/web/icon/internal/icon-styles.js + var styles11 = i`:host{font-size:var(--md-icon-size, 24px);width:var(--md-icon-size, 24px);height:var(--md-icon-size, 24px);color:inherit;font-variation-settings:inherit;font-weight:400;font-family:var(--md-icon-font, Material Symbols Outlined);display:inline-flex;font-style:normal;place-items:center;place-content:center;line-height:1;overflow:hidden;letter-spacing:normal;text-transform:none;user-select:none;white-space:nowrap;word-wrap:normal;flex-shrink:0;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;-moz-osx-font-smoothing:grayscale}::slotted(svg){fill:currentColor}::slotted(*){height:100%;width:100%} +`; + + // node_modules/@material/web/icon/icon.js + var MdIcon = class MdIcon2 extends Icon { + }; + MdIcon.styles = [styles11]; + MdIcon = __decorate([ + t2("md-icon") + ], MdIcon); + + // node_modules/lit-html/static.js + var $e = Symbol.for(""); + var xe = (t4) => { + if (t4?.r === $e) + return t4?._$litStatic$; + }; + var er = (t4, ...r5) => ({ _$litStatic$: r5.reduce((r6, e6, a2) => r6 + ((t5) => { + if (void 0 !== t5._$litStatic$) + return t5._$litStatic$; + throw Error(`Value passed to 'literal' function must be a 'literal' result: ${t5}. Use 'unsafeStatic' to pass non-literal values, but + take care to ensure page security.`); + })(e6) + t4[a2 + 1], t4[0]), r: $e }); + var Te = /* @__PURE__ */ new Map(); + var Ee = (t4) => (r5, ...e6) => { + const a2 = e6.length; + let o5, s2; + const i4 = [], l2 = []; + let n5, u2 = 0, c4 = false; + for (; u2 < a2; ) { + for (n5 = r5[u2]; u2 < a2 && void 0 !== (s2 = e6[u2], o5 = xe(s2)); ) + n5 += o5 + r5[++u2], c4 = true; + u2 !== a2 && l2.push(s2), i4.push(n5), u2++; + } + if (u2 === a2 && i4.push(r5[a2]), c4) { + const t5 = i4.join("$$lit$$"); + void 0 === (r5 = Te.get(t5)) && (i4.raw = i4, Te.set(t5, r5 = i4)), e6 = l2; + } + return t4(r5, ...e6); + }; + var ke2 = Ee(ke); + var Oe2 = Ee(Oe); + var Se2 = Ee(Se); + + // node_modules/@material/web/internal/controller/is-rtl.js + function isRtl(el, shouldCheck = true) { + return shouldCheck && getComputedStyle(el).getPropertyValue("direction").trim() === "rtl"; + } + + // node_modules/@material/web/iconbutton/internal/icon-button.js + var iconButtonBaseClass = mixinDelegatesAria(mixinElementInternals(h3)); + var IconButton = class extends iconButtonBaseClass { + get name() { + return this.getAttribute("name") ?? ""; + } + set name(name) { + this.setAttribute("name", name); + } + /** + * The associated form element with which this element's value will submit. + */ + get form() { + return this[internals].form; + } + /** + * The labels this element is associated with. + */ + get labels() { + return this[internals].labels; + } + constructor() { + super(); + this.disabled = false; + this.softDisabled = false; + this.flipIconInRtl = false; + this.href = ""; + this.target = ""; + this.ariaLabelSelected = ""; + this.toggle = false; + this.selected = false; + this.type = "submit"; + this.value = ""; + this.flipIcon = isRtl(this, this.flipIconInRtl); + if (!co) { + this.addEventListener("click", this.handleClick.bind(this)); + } + } + willUpdate() { + if (this.href) { + this.disabled = false; + this.softDisabled = false; + } + } + render() { + const tag = this.href ? er`div` : er`button`; + const { ariaLabel, ariaHasPopup, ariaExpanded } = this; + const hasToggledAriaLabel = ariaLabel && this.ariaLabelSelected; + const ariaPressedValue = !this.toggle ? D : this.selected; + let ariaLabelValue = D; + if (!this.href) { + ariaLabelValue = hasToggledAriaLabel && this.selected ? this.ariaLabelSelected : ariaLabel; + } + return ke2`<${tag} + class="icon-button ${Rt(this.getRenderClasses())}" + id="button" + aria-label="${ariaLabelValue || D}" + aria-haspopup="${!this.href && ariaHasPopup || D}" + aria-expanded="${!this.href && ariaExpanded || D}" + aria-pressed="${ariaPressedValue}" + aria-disabled=${!this.href && this.softDisabled || D} + ?disabled="${!this.href && this.disabled}" + @click="${this.handleClickOnChild}"> + ${this.renderFocusRing()} + ${this.renderRipple()} + ${!this.selected ? this.renderIcon() : D} + ${this.selected ? this.renderSelectedIcon() : D} + ${this.renderTouchTarget()} + ${this.href && this.renderLink()} + `; + } + renderLink() { + const { ariaLabel } = this; + return ke` + + `; + } + getRenderClasses() { + return { + "flip-icon": this.flipIcon, + "selected": this.toggle && this.selected + }; + } + renderIcon() { + return ke``; + } + renderSelectedIcon() { + return ke``; + } + renderTouchTarget() { + return ke``; + } + renderFocusRing() { + return ke``; + } + renderRipple() { + const isRippleDisabled = !this.href && (this.disabled || this.softDisabled); + return ke``; + } + connectedCallback() { + this.flipIcon = isRtl(this, this.flipIconInRtl); + super.connectedCallback(); + } + /** Handles a click on this element. */ + handleClick(event) { + if (!this.href && this.softDisabled) { + event.stopImmediatePropagation(); + event.preventDefault(); + return; + } + } + /** + * Handles a click on the child
or `; + } + renderLink() { + const { ariaLabel, ariaHasPopup, ariaExpanded } = this; + return ke`${this.renderContent()} + `; + } + renderContent() { + const icon = ke``; + return ke` + + ${this.trailingIcon ? D : icon} + + ${this.trailingIcon ? icon : D} + `; + } + handleClick(event) { + if (!this.href && this.softDisabled) { + event.stopImmediatePropagation(); + event.preventDefault(); + return; + } + if (!isActivationClick(event) || !this.buttonElement) { + return; + } + this.focus(); + dispatchActivationClick(this.buttonElement); + } + handleSlotChange() { + this.hasIcon = this.assignedIcons.length > 0; + } + }; + (() => { + setupFormSubmitter(Button); + })(); + Button.formAssociated = true; + Button.shadowRootOptions = { + mode: "open", + delegatesFocus: true + }; + __decorate([ + n4({ type: Boolean, reflect: true }) + ], Button.prototype, "disabled", void 0); + __decorate([ + n4({ type: Boolean, attribute: "soft-disabled", reflect: true }) + ], Button.prototype, "softDisabled", void 0); + __decorate([ + n4() + ], Button.prototype, "href", void 0); + __decorate([ + n4() + ], Button.prototype, "target", void 0); + __decorate([ + n4({ type: Boolean, attribute: "trailing-icon", reflect: true }) + ], Button.prototype, "trailingIcon", void 0); + __decorate([ + n4({ type: Boolean, attribute: "has-icon", reflect: true }) + ], Button.prototype, "hasIcon", void 0); + __decorate([ + n4() + ], Button.prototype, "type", void 0); + __decorate([ + n4({ reflect: true }) + ], Button.prototype, "value", void 0); + __decorate([ + e4(".button") + ], Button.prototype, "buttonElement", void 0); + __decorate([ + o4({ slot: "icon", flatten: true }) + ], Button.prototype, "assignedIcons", void 0); + + // node_modules/@material/web/button/internal/filled-button.js + var FilledButton = class extends Button { + renderElevationOrOutline() { + return ke``; + } + }; + + // node_modules/@material/web/button/internal/filled-styles.js + var styles4 = i`:host{--_container-color: var(--md-filled-button-container-color, var(--md-sys-color-primary, #6750a4));--_container-elevation: var(--md-filled-button-container-elevation, 0);--_container-height: var(--md-filled-button-container-height, 40px);--_container-shadow-color: var(--md-filled-button-container-shadow-color, var(--md-sys-color-shadow, #000));--_disabled-container-color: var(--md-filled-button-disabled-container-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-container-elevation: var(--md-filled-button-disabled-container-elevation, 0);--_disabled-container-opacity: var(--md-filled-button-disabled-container-opacity, 0.12);--_disabled-label-text-color: var(--md-filled-button-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-filled-button-disabled-label-text-opacity, 0.38);--_focus-container-elevation: var(--md-filled-button-focus-container-elevation, 0);--_focus-label-text-color: var(--md-filled-button-focus-label-text-color, var(--md-sys-color-on-primary, #fff));--_hover-container-elevation: var(--md-filled-button-hover-container-elevation, 1);--_hover-label-text-color: var(--md-filled-button-hover-label-text-color, var(--md-sys-color-on-primary, #fff));--_hover-state-layer-color: var(--md-filled-button-hover-state-layer-color, var(--md-sys-color-on-primary, #fff));--_hover-state-layer-opacity: var(--md-filled-button-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-filled-button-label-text-color, var(--md-sys-color-on-primary, #fff));--_label-text-font: var(--md-filled-button-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-filled-button-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-filled-button-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-filled-button-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_pressed-container-elevation: var(--md-filled-button-pressed-container-elevation, 0);--_pressed-label-text-color: var(--md-filled-button-pressed-label-text-color, var(--md-sys-color-on-primary, #fff));--_pressed-state-layer-color: var(--md-filled-button-pressed-state-layer-color, var(--md-sys-color-on-primary, #fff));--_pressed-state-layer-opacity: var(--md-filled-button-pressed-state-layer-opacity, 0.12);--_disabled-icon-color: var(--md-filled-button-disabled-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-icon-opacity: var(--md-filled-button-disabled-icon-opacity, 0.38);--_focus-icon-color: var(--md-filled-button-focus-icon-color, var(--md-sys-color-on-primary, #fff));--_hover-icon-color: var(--md-filled-button-hover-icon-color, var(--md-sys-color-on-primary, #fff));--_icon-color: var(--md-filled-button-icon-color, var(--md-sys-color-on-primary, #fff));--_icon-size: var(--md-filled-button-icon-size, 18px);--_pressed-icon-color: var(--md-filled-button-pressed-icon-color, var(--md-sys-color-on-primary, #fff));--_container-shape-start-start: var(--md-filled-button-container-shape-start-start, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-start-end: var(--md-filled-button-container-shape-start-end, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-end: var(--md-filled-button-container-shape-end-end, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-start: var(--md-filled-button-container-shape-end-start, var(--md-filled-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_leading-space: var(--md-filled-button-leading-space, 24px);--_trailing-space: var(--md-filled-button-trailing-space, 24px);--_with-leading-icon-leading-space: var(--md-filled-button-with-leading-icon-leading-space, 16px);--_with-leading-icon-trailing-space: var(--md-filled-button-with-leading-icon-trailing-space, 24px);--_with-trailing-icon-leading-space: var(--md-filled-button-with-trailing-icon-leading-space, 24px);--_with-trailing-icon-trailing-space: var(--md-filled-button-with-trailing-icon-trailing-space, 16px)} +`; + + // node_modules/@material/web/button/internal/shared-elevation-styles.js + var styles5 = i`md-elevation{transition-duration:280ms}:host(:is([disabled],[soft-disabled])) md-elevation{transition:none}md-elevation{--md-elevation-level: var(--_container-elevation);--md-elevation-shadow-color: var(--_container-shadow-color)}:host(:focus-within) md-elevation{--md-elevation-level: var(--_focus-container-elevation)}:host(:hover) md-elevation{--md-elevation-level: var(--_hover-container-elevation)}:host(:active) md-elevation{--md-elevation-level: var(--_pressed-container-elevation)}:host(:is([disabled],[soft-disabled])) md-elevation{--md-elevation-level: var(--_disabled-container-elevation)} +`; + + // node_modules/@material/web/button/internal/shared-styles.js + var styles6 = i`:host{border-start-start-radius:var(--_container-shape-start-start);border-start-end-radius:var(--_container-shape-start-end);border-end-start-radius:var(--_container-shape-end-start);border-end-end-radius:var(--_container-shape-end-end);box-sizing:border-box;cursor:pointer;display:inline-flex;gap:8px;min-height:var(--_container-height);outline:none;padding-block:calc((var(--_container-height) - max(var(--_label-text-line-height),var(--_icon-size)))/2);padding-inline-start:var(--_leading-space);padding-inline-end:var(--_trailing-space);place-content:center;place-items:center;position:relative;font-family:var(--_label-text-font);font-size:var(--_label-text-size);line-height:var(--_label-text-line-height);font-weight:var(--_label-text-weight);text-overflow:ellipsis;text-wrap:nowrap;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);vertical-align:top;--md-ripple-hover-color: var(--_hover-state-layer-color);--md-ripple-pressed-color: var(--_pressed-state-layer-color);--md-ripple-hover-opacity: var(--_hover-state-layer-opacity);--md-ripple-pressed-opacity: var(--_pressed-state-layer-opacity)}md-focus-ring{--md-focus-ring-shape-start-start: var(--_container-shape-start-start);--md-focus-ring-shape-start-end: var(--_container-shape-start-end);--md-focus-ring-shape-end-end: var(--_container-shape-end-end);--md-focus-ring-shape-end-start: var(--_container-shape-end-start)}:host(:is([disabled],[soft-disabled])){cursor:default;pointer-events:none}.button{border-radius:inherit;cursor:inherit;display:inline-flex;align-items:center;justify-content:center;border:none;outline:none;-webkit-appearance:none;vertical-align:middle;background:rgba(0,0,0,0);text-decoration:none;min-width:calc(64px - var(--_leading-space) - var(--_trailing-space));width:100%;z-index:0;height:100%;font:inherit;color:var(--_label-text-color);padding:0;gap:inherit;text-transform:inherit}.button::-moz-focus-inner{padding:0;border:0}:host(:hover) .button{color:var(--_hover-label-text-color)}:host(:focus-within) .button{color:var(--_focus-label-text-color)}:host(:active) .button{color:var(--_pressed-label-text-color)}.background{background-color:var(--_container-color);border-radius:inherit;inset:0;position:absolute}.label{overflow:hidden}:is(.button,.label,.label slot),.label ::slotted(*){text-overflow:inherit}:host(:is([disabled],[soft-disabled])) .label{color:var(--_disabled-label-text-color);opacity:var(--_disabled-label-text-opacity)}:host(:is([disabled],[soft-disabled])) .background{background-color:var(--_disabled-container-color);opacity:var(--_disabled-container-opacity)}@media(forced-colors: active){.background{border:1px solid CanvasText}:host(:is([disabled],[soft-disabled])){--_disabled-icon-color: GrayText;--_disabled-icon-opacity: 1;--_disabled-container-opacity: 1;--_disabled-label-text-color: GrayText;--_disabled-label-text-opacity: 1}}:host([has-icon]:not([trailing-icon])){padding-inline-start:var(--_with-leading-icon-leading-space);padding-inline-end:var(--_with-leading-icon-trailing-space)}:host([has-icon][trailing-icon]){padding-inline-start:var(--_with-trailing-icon-leading-space);padding-inline-end:var(--_with-trailing-icon-trailing-space)}::slotted([slot=icon]){display:inline-flex;position:relative;writing-mode:horizontal-tb;fill:currentColor;flex-shrink:0;color:var(--_icon-color);font-size:var(--_icon-size);inline-size:var(--_icon-size);block-size:var(--_icon-size)}:host(:hover) ::slotted([slot=icon]){color:var(--_hover-icon-color)}:host(:focus-within) ::slotted([slot=icon]){color:var(--_focus-icon-color)}:host(:active) ::slotted([slot=icon]){color:var(--_pressed-icon-color)}:host(:is([disabled],[soft-disabled])) ::slotted([slot=icon]){color:var(--_disabled-icon-color);opacity:var(--_disabled-icon-opacity)}.touch{position:absolute;top:50%;height:48px;left:0;right:0;transform:translateY(-50%)}:host([touch-target=wrapper]){margin:max(0px,(48px - var(--_container-height))/2) 0}:host([touch-target=none]) .touch{display:none} +`; + + // node_modules/@material/web/button/filled-button.js + var MdFilledButton = class MdFilledButton2 extends FilledButton { + }; + MdFilledButton.styles = [ + styles6, + styles5, + styles4 + ]; + MdFilledButton = __decorate([ + t2("md-filled-button") + ], MdFilledButton); + + // node_modules/@material/web/button/internal/filled-tonal-button.js + var FilledTonalButton = class extends Button { + renderElevationOrOutline() { + return ke``; + } + }; + + // node_modules/@material/web/button/internal/filled-tonal-styles.js + var styles7 = i`:host{--_container-color: var(--md-filled-tonal-button-container-color, var(--md-sys-color-secondary-container, #e8def8));--_container-elevation: var(--md-filled-tonal-button-container-elevation, 0);--_container-height: var(--md-filled-tonal-button-container-height, 40px);--_container-shadow-color: var(--md-filled-tonal-button-container-shadow-color, var(--md-sys-color-shadow, #000));--_disabled-container-color: var(--md-filled-tonal-button-disabled-container-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-container-elevation: var(--md-filled-tonal-button-disabled-container-elevation, 0);--_disabled-container-opacity: var(--md-filled-tonal-button-disabled-container-opacity, 0.12);--_disabled-label-text-color: var(--md-filled-tonal-button-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-filled-tonal-button-disabled-label-text-opacity, 0.38);--_focus-container-elevation: var(--md-filled-tonal-button-focus-container-elevation, 0);--_focus-label-text-color: var(--md-filled-tonal-button-focus-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_hover-container-elevation: var(--md-filled-tonal-button-hover-container-elevation, 1);--_hover-label-text-color: var(--md-filled-tonal-button-hover-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_hover-state-layer-color: var(--md-filled-tonal-button-hover-state-layer-color, var(--md-sys-color-on-secondary-container, #1d192b));--_hover-state-layer-opacity: var(--md-filled-tonal-button-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-filled-tonal-button-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_label-text-font: var(--md-filled-tonal-button-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-filled-tonal-button-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-filled-tonal-button-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-filled-tonal-button-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_pressed-container-elevation: var(--md-filled-tonal-button-pressed-container-elevation, 0);--_pressed-label-text-color: var(--md-filled-tonal-button-pressed-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_pressed-state-layer-color: var(--md-filled-tonal-button-pressed-state-layer-color, var(--md-sys-color-on-secondary-container, #1d192b));--_pressed-state-layer-opacity: var(--md-filled-tonal-button-pressed-state-layer-opacity, 0.12);--_disabled-icon-color: var(--md-filled-tonal-button-disabled-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-icon-opacity: var(--md-filled-tonal-button-disabled-icon-opacity, 0.38);--_focus-icon-color: var(--md-filled-tonal-button-focus-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_hover-icon-color: var(--md-filled-tonal-button-hover-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_icon-color: var(--md-filled-tonal-button-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_icon-size: var(--md-filled-tonal-button-icon-size, 18px);--_pressed-icon-color: var(--md-filled-tonal-button-pressed-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_container-shape-start-start: var(--md-filled-tonal-button-container-shape-start-start, var(--md-filled-tonal-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-start-end: var(--md-filled-tonal-button-container-shape-start-end, var(--md-filled-tonal-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-end: var(--md-filled-tonal-button-container-shape-end-end, var(--md-filled-tonal-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-start: var(--md-filled-tonal-button-container-shape-end-start, var(--md-filled-tonal-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_leading-space: var(--md-filled-tonal-button-leading-space, 24px);--_trailing-space: var(--md-filled-tonal-button-trailing-space, 24px);--_with-leading-icon-leading-space: var(--md-filled-tonal-button-with-leading-icon-leading-space, 16px);--_with-leading-icon-trailing-space: var(--md-filled-tonal-button-with-leading-icon-trailing-space, 24px);--_with-trailing-icon-leading-space: var(--md-filled-tonal-button-with-trailing-icon-leading-space, 24px);--_with-trailing-icon-trailing-space: var(--md-filled-tonal-button-with-trailing-icon-trailing-space, 16px)} +`; + + // node_modules/@material/web/button/filled-tonal-button.js + var MdFilledTonalButton = class MdFilledTonalButton2 extends FilledTonalButton { + }; + MdFilledTonalButton.styles = [ + styles6, + styles5, + styles7 + ]; + MdFilledTonalButton = __decorate([ + t2("md-filled-tonal-button") + ], MdFilledTonalButton); + + // node_modules/@material/web/button/internal/text-button.js + var TextButton = class extends Button { + }; + + // node_modules/@material/web/button/internal/text-styles.js + var styles8 = i`:host{--_container-height: var(--md-text-button-container-height, 40px);--_disabled-label-text-color: var(--md-text-button-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-text-button-disabled-label-text-opacity, 0.38);--_focus-label-text-color: var(--md-text-button-focus-label-text-color, var(--md-sys-color-primary, #6750a4));--_hover-label-text-color: var(--md-text-button-hover-label-text-color, var(--md-sys-color-primary, #6750a4));--_hover-state-layer-color: var(--md-text-button-hover-state-layer-color, var(--md-sys-color-primary, #6750a4));--_hover-state-layer-opacity: var(--md-text-button-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-text-button-label-text-color, var(--md-sys-color-primary, #6750a4));--_label-text-font: var(--md-text-button-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-text-button-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-text-button-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-text-button-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_pressed-label-text-color: var(--md-text-button-pressed-label-text-color, var(--md-sys-color-primary, #6750a4));--_pressed-state-layer-color: var(--md-text-button-pressed-state-layer-color, var(--md-sys-color-primary, #6750a4));--_pressed-state-layer-opacity: var(--md-text-button-pressed-state-layer-opacity, 0.12);--_disabled-icon-color: var(--md-text-button-disabled-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-icon-opacity: var(--md-text-button-disabled-icon-opacity, 0.38);--_focus-icon-color: var(--md-text-button-focus-icon-color, var(--md-sys-color-primary, #6750a4));--_hover-icon-color: var(--md-text-button-hover-icon-color, var(--md-sys-color-primary, #6750a4));--_icon-color: var(--md-text-button-icon-color, var(--md-sys-color-primary, #6750a4));--_icon-size: var(--md-text-button-icon-size, 18px);--_pressed-icon-color: var(--md-text-button-pressed-icon-color, var(--md-sys-color-primary, #6750a4));--_container-shape-start-start: var(--md-text-button-container-shape-start-start, var(--md-text-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-start-end: var(--md-text-button-container-shape-start-end, var(--md-text-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-end: var(--md-text-button-container-shape-end-end, var(--md-text-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_container-shape-end-start: var(--md-text-button-container-shape-end-start, var(--md-text-button-container-shape, var(--md-sys-shape-corner-full, 9999px)));--_leading-space: var(--md-text-button-leading-space, 12px);--_trailing-space: var(--md-text-button-trailing-space, 12px);--_with-leading-icon-leading-space: var(--md-text-button-with-leading-icon-leading-space, 12px);--_with-leading-icon-trailing-space: var(--md-text-button-with-leading-icon-trailing-space, 16px);--_with-trailing-icon-leading-space: var(--md-text-button-with-trailing-icon-leading-space, 16px);--_with-trailing-icon-trailing-space: var(--md-text-button-with-trailing-icon-trailing-space, 12px);--_container-color: none;--_disabled-container-color: none;--_disabled-container-opacity: 0} +`; + + // node_modules/@material/web/button/text-button.js + var MdTextButton = class MdTextButton2 extends TextButton { + }; + MdTextButton.styles = [styles6, styles8]; + MdTextButton = __decorate([ + t2("md-text-button") + ], MdTextButton); + + // node_modules/@material/web/divider/internal/divider.js + var Divider = class extends h3 { + constructor() { + super(...arguments); + this.inset = false; + this.insetStart = false; + this.insetEnd = false; + } + }; + __decorate([ + n4({ type: Boolean, reflect: true }) + ], Divider.prototype, "inset", void 0); + __decorate([ + n4({ type: Boolean, reflect: true, attribute: "inset-start" }) + ], Divider.prototype, "insetStart", void 0); + __decorate([ + n4({ type: Boolean, reflect: true, attribute: "inset-end" }) + ], Divider.prototype, "insetEnd", void 0); + + // node_modules/@material/web/divider/internal/divider-styles.js + var styles9 = i`:host{box-sizing:border-box;color:var(--md-divider-color, var(--md-sys-color-outline-variant, #cac4d0));display:flex;height:var(--md-divider-thickness, 1px);width:100%}:host([inset]),:host([inset-start]){padding-inline-start:16px}:host([inset]),:host([inset-end]){padding-inline-end:16px}:host::before{background:currentColor;content:"";height:100%;width:100%}@media(forced-colors: active){:host::before{background:CanvasText}} +`; + + // node_modules/@material/web/divider/divider.js + var MdDivider = class MdDivider2 extends Divider { + }; + MdDivider.styles = [styles9]; + MdDivider = __decorate([ + t2("md-divider") + ], MdDivider); + + // node_modules/@material/web/internal/events/redispatch-event.js + function redispatchEvent(element, event) { + if (event.bubbles && (!element.shadowRoot || event.composed)) { + event.stopPropagation(); + } + const copy = Reflect.construct(event.constructor, [event.type, event]); + const dispatched = element.dispatchEvent(copy); + if (!dispatched) { + event.preventDefault(); + } + return dispatched; + } + + // node_modules/@material/web/dialog/internal/animations.js + var DIALOG_DEFAULT_OPEN_ANIMATION = { + dialog: [ + [ + // Dialog slide down + [{ "transform": "translateY(-50px)" }, { "transform": "translateY(0)" }], + { duration: 500, easing: EASING.EMPHASIZED } + ] + ], + scrim: [ + [ + // Scrim fade in + [{ "opacity": 0 }, { "opacity": 0.32 }], + { duration: 500, easing: "linear" } + ] + ], + container: [ + [ + // Container fade in + [{ "opacity": 0 }, { "opacity": 1 }], + { duration: 50, easing: "linear", pseudoElement: "::before" } + ], + [ + // Container grow + // Note: current spec says to grow from 0dp->100% and shrink from + // 100%->35%. We change this to 35%->100% to simplify the animation that + // is supposed to clip content as it grows. From 0dp it's possible to see + // text/actions appear before the container has fully grown. + [{ "height": "35%" }, { "height": "100%" }], + { duration: 500, easing: EASING.EMPHASIZED, pseudoElement: "::before" } + ] + ], + headline: [ + [ + // Headline fade in + [{ "opacity": 0 }, { "opacity": 0, offset: 0.2 }, { "opacity": 1 }], + { duration: 250, easing: "linear", fill: "forwards" } + ] + ], + content: [ + [ + // Content fade in + [{ "opacity": 0 }, { "opacity": 0, offset: 0.2 }, { "opacity": 1 }], + { duration: 250, easing: "linear", fill: "forwards" } + ] + ], + actions: [ + [ + // Actions fade in + [{ "opacity": 0 }, { "opacity": 0, offset: 0.5 }, { "opacity": 1 }], + { duration: 300, easing: "linear", fill: "forwards" } + ] + ] + }; + var DIALOG_DEFAULT_CLOSE_ANIMATION = { + dialog: [ + [ + // Dialog slide up + [{ "transform": "translateY(0)" }, { "transform": "translateY(-50px)" }], + { duration: 150, easing: EASING.EMPHASIZED_ACCELERATE } + ] + ], + scrim: [ + [ + // Scrim fade out + [{ "opacity": 0.32 }, { "opacity": 0 }], + { duration: 150, easing: "linear" } + ] + ], + container: [ + [ + // Container shrink + [{ "height": "100%" }, { "height": "35%" }], + { + duration: 150, + easing: EASING.EMPHASIZED_ACCELERATE, + pseudoElement: "::before" + } + ], + [ + // Container fade out + [{ "opacity": "1" }, { "opacity": "0" }], + { delay: 100, duration: 50, easing: "linear", pseudoElement: "::before" } + ] + ], + headline: [ + [ + // Headline fade out + [{ "opacity": 1 }, { "opacity": 0 }], + { duration: 100, easing: "linear", fill: "forwards" } + ] + ], + content: [ + [ + // Content fade out + [{ "opacity": 1 }, { "opacity": 0 }], + { duration: 100, easing: "linear", fill: "forwards" } + ] + ], + actions: [ + [ + // Actions fade out + [{ "opacity": 1 }, { "opacity": 0 }], + { duration: 100, easing: "linear", fill: "forwards" } + ] + ] + }; + + // node_modules/@material/web/dialog/internal/dialog.js + var dialogBaseClass = mixinDelegatesAria(h3); + var Dialog = class extends dialogBaseClass { + // We do not use `delegatesFocus: true` due to a Chromium bug with + // selecting text. + // See https://bugs.chromium.org/p/chromium/issues/detail?id=950357 + /** + * Opens the dialog when set to `true` and closes it when set to `false`. + */ + get open() { + return this.isOpen; + } + set open(open) { + if (open === this.isOpen) { + return; + } + this.isOpen = open; + if (open) { + this.setAttribute("open", ""); + this.show(); + } else { + this.removeAttribute("open"); + this.close(); + } + } + constructor() { + super(); + this.quick = false; + this.returnValue = ""; + this.noFocusTrap = false; + this.getOpenAnimation = () => DIALOG_DEFAULT_OPEN_ANIMATION; + this.getCloseAnimation = () => DIALOG_DEFAULT_CLOSE_ANIMATION; + this.isOpen = false; + this.isOpening = false; + this.isConnectedPromise = this.getIsConnectedPromise(); + this.isAtScrollTop = false; + this.isAtScrollBottom = false; + this.nextClickIsFromContent = false; + this.hasHeadline = false; + this.hasActions = false; + this.hasIcon = false; + this.escapePressedWithoutCancel = false; + this.treewalker = co ? null : document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT); + if (!co) { + this.addEventListener("submit", this.handleSubmit); + } + } + /** + * Opens the dialog and fires a cancelable `open` event. After a dialog's + * animation, an `opened` event is fired. + * + * Add an `autofocus` attribute to a child of the dialog that should + * receive focus after opening. + * + * @return A Promise that resolves after the animation is finished and the + * `opened` event was fired. + */ + async show() { + this.isOpening = true; + await this.isConnectedPromise; + await this.updateComplete; + const dialog = this.dialog; + if (dialog.open || !this.isOpening) { + this.isOpening = false; + return; + } + const preventOpen = !this.dispatchEvent(new Event("open", { cancelable: true })); + if (preventOpen) { + this.open = false; + this.isOpening = false; + return; + } + dialog.showModal(); + this.open = true; + if (this.scroller) { + this.scroller.scrollTop = 0; + } + this.querySelector("[autofocus]")?.focus(); + await this.animateDialog(this.getOpenAnimation()); + this.dispatchEvent(new Event("opened")); + this.isOpening = false; + } + /** + * Closes the dialog and fires a cancelable `close` event. After a dialog's + * animation, a `closed` event is fired. + * + * @param returnValue A return value usually indicating which button was used + * to close a dialog. If a dialog is canceled by clicking the scrim or + * pressing Escape, it will not change the return value after closing. + * @return A Promise that resolves after the animation is finished and the + * `closed` event was fired. + */ + async close(returnValue = this.returnValue) { + this.isOpening = false; + if (!this.isConnected) { + this.open = false; + return; + } + await this.updateComplete; + const dialog = this.dialog; + if (!dialog.open || this.isOpening) { + this.open = false; + return; + } + const prevReturnValue = this.returnValue; + this.returnValue = returnValue; + const preventClose = !this.dispatchEvent(new Event("close", { cancelable: true })); + if (preventClose) { + this.returnValue = prevReturnValue; + return; + } + await this.animateDialog(this.getCloseAnimation()); + dialog.close(returnValue); + this.open = false; + this.dispatchEvent(new Event("closed")); + } + connectedCallback() { + super.connectedCallback(); + this.isConnectedPromiseResolve(); + } + disconnectedCallback() { + super.disconnectedCallback(); + this.isConnectedPromise = this.getIsConnectedPromise(); + } + render() { + const scrollable = this.open && !(this.isAtScrollTop && this.isAtScrollBottom); + const classes = { + "has-headline": this.hasHeadline, + "has-actions": this.hasActions, + "has-icon": this.hasIcon, + "scrollable": scrollable, + "show-top-divider": scrollable && !this.isAtScrollTop, + "show-bottom-divider": scrollable && !this.isAtScrollBottom + }; + const showFocusTrap = this.open && !this.noFocusTrap; + const focusTrap = ke` + + `; + const { ariaLabel } = this; + return ke` +
+ + ${showFocusTrap ? focusTrap : D} +
+
+ +

+ +

+ +
+
+
+
+ +
+
+
+
+ + +
+
+ ${showFocusTrap ? focusTrap : D} +
+ `; + } + firstUpdated() { + this.intersectionObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + this.handleAnchorIntersection(entry); + } + }, { root: this.scroller }); + this.intersectionObserver.observe(this.topAnchor); + this.intersectionObserver.observe(this.bottomAnchor); + } + handleDialogClick() { + if (this.nextClickIsFromContent) { + this.nextClickIsFromContent = false; + return; + } + const preventDefault = !this.dispatchEvent(new Event("cancel", { cancelable: true })); + if (preventDefault) { + return; + } + this.close(); + } + handleContentClick() { + this.nextClickIsFromContent = true; + } + handleSubmit(event) { + const form = event.target; + const { submitter } = event; + if (form.method !== "dialog" || !submitter) { + return; + } + this.close(submitter.getAttribute("value") ?? this.returnValue); + } + handleCancel(event) { + if (event.target !== this.dialog) { + return; + } + this.escapePressedWithoutCancel = false; + const preventDefault = !redispatchEvent(this, event); + event.preventDefault(); + if (preventDefault) { + return; + } + this.close(); + } + handleClose() { + if (!this.escapePressedWithoutCancel) { + return; + } + this.escapePressedWithoutCancel = false; + this.dialog?.dispatchEvent(new Event("cancel", { cancelable: true })); + } + handleKeydown(event) { + if (event.key !== "Escape") { + return; + } + this.escapePressedWithoutCancel = true; + setTimeout(() => { + this.escapePressedWithoutCancel = false; + }); + } + async animateDialog(animation) { + this.cancelAnimations?.abort(); + this.cancelAnimations = new AbortController(); + if (this.quick) { + return; + } + const { dialog, scrim, container, headline, content, actions } = this; + if (!dialog || !scrim || !container || !headline || !content || !actions) { + return; + } + const { container: containerAnimate, dialog: dialogAnimate, scrim: scrimAnimate, headline: headlineAnimate, content: contentAnimate, actions: actionsAnimate } = animation; + const elementAndAnimation = [ + [dialog, dialogAnimate ?? []], + [scrim, scrimAnimate ?? []], + [container, containerAnimate ?? []], + [headline, headlineAnimate ?? []], + [content, contentAnimate ?? []], + [actions, actionsAnimate ?? []] + ]; + const animations = []; + for (const [element, animation2] of elementAndAnimation) { + for (const animateArgs of animation2) { + const animation3 = element.animate(...animateArgs); + this.cancelAnimations.signal.addEventListener("abort", () => { + animation3.cancel(); + }); + animations.push(animation3); + } + } + await Promise.all(animations.map((animation2) => animation2.finished.catch(() => { + }))); + } + handleHeadlineChange(event) { + const slot = event.target; + this.hasHeadline = slot.assignedElements().length > 0; + } + handleActionsChange(event) { + const slot = event.target; + this.hasActions = slot.assignedElements().length > 0; + } + handleIconChange(event) { + const slot = event.target; + this.hasIcon = slot.assignedElements().length > 0; + } + handleAnchorIntersection(entry) { + const { target, isIntersecting } = entry; + if (target === this.topAnchor) { + this.isAtScrollTop = isIntersecting; + } + if (target === this.bottomAnchor) { + this.isAtScrollBottom = isIntersecting; + } + } + getIsConnectedPromise() { + return new Promise((resolve) => { + this.isConnectedPromiseResolve = resolve; + }); + } + handleFocusTrapFocus(event) { + const [firstFocusableChild, lastFocusableChild] = this.getFirstAndLastFocusableChildren(); + if (!firstFocusableChild || !lastFocusableChild) { + this.dialog?.focus(); + return; + } + const isFirstFocusTrap = event.target === this.firstFocusTrap; + const isLastFocusTrap = !isFirstFocusTrap; + const focusCameFromFirstChild = event.relatedTarget === firstFocusableChild; + const focusCameFromLastChild = event.relatedTarget === lastFocusableChild; + const focusCameFromOutsideDialog = !focusCameFromFirstChild && !focusCameFromLastChild; + const shouldFocusFirstChild = isLastFocusTrap && focusCameFromLastChild || isFirstFocusTrap && focusCameFromOutsideDialog; + if (shouldFocusFirstChild) { + firstFocusableChild.focus(); + return; + } + const shouldFocusLastChild = isFirstFocusTrap && focusCameFromFirstChild || isLastFocusTrap && focusCameFromOutsideDialog; + if (shouldFocusLastChild) { + lastFocusableChild.focus(); + return; + } + } + getFirstAndLastFocusableChildren() { + if (!this.treewalker) { + return [null, null]; + } + let firstFocusableChild = null; + let lastFocusableChild = null; + this.treewalker.currentNode = this.treewalker.root; + while (this.treewalker.nextNode()) { + const nextChild = this.treewalker.currentNode; + if (!isFocusable(nextChild)) { + continue; + } + if (!firstFocusableChild) { + firstFocusableChild = nextChild; + } + lastFocusableChild = nextChild; + } + return [firstFocusableChild, lastFocusableChild]; + } + }; + __decorate([ + n4({ type: Boolean }) + ], Dialog.prototype, "open", null); + __decorate([ + n4({ type: Boolean }) + ], Dialog.prototype, "quick", void 0); + __decorate([ + n4({ attribute: false }) + ], Dialog.prototype, "returnValue", void 0); + __decorate([ + n4() + ], Dialog.prototype, "type", void 0); + __decorate([ + n4({ type: Boolean, attribute: "no-focus-trap" }) + ], Dialog.prototype, "noFocusTrap", void 0); + __decorate([ + e4("dialog") + ], Dialog.prototype, "dialog", void 0); + __decorate([ + e4(".scrim") + ], Dialog.prototype, "scrim", void 0); + __decorate([ + e4(".container") + ], Dialog.prototype, "container", void 0); + __decorate([ + e4(".headline") + ], Dialog.prototype, "headline", void 0); + __decorate([ + e4(".content") + ], Dialog.prototype, "content", void 0); + __decorate([ + e4(".actions") + ], Dialog.prototype, "actions", void 0); + __decorate([ + r4() + ], Dialog.prototype, "isAtScrollTop", void 0); + __decorate([ + r4() + ], Dialog.prototype, "isAtScrollBottom", void 0); + __decorate([ + e4(".scroller") + ], Dialog.prototype, "scroller", void 0); + __decorate([ + e4(".top.anchor") + ], Dialog.prototype, "topAnchor", void 0); + __decorate([ + e4(".bottom.anchor") + ], Dialog.prototype, "bottomAnchor", void 0); + __decorate([ + e4(".focus-trap") + ], Dialog.prototype, "firstFocusTrap", void 0); + __decorate([ + r4() + ], Dialog.prototype, "hasHeadline", void 0); + __decorate([ + r4() + ], Dialog.prototype, "hasActions", void 0); + __decorate([ + r4() + ], Dialog.prototype, "hasIcon", void 0); + function isFocusable(element) { + const knownFocusableElements = ":is(button,input,select,textarea,object,:is(a,area)[href],[tabindex],[contenteditable=true])"; + const notDisabled = ":not(:disabled,[disabled])"; + const notNegativeTabIndex = ':not([tabindex^="-"])'; + if (element.matches(knownFocusableElements + notDisabled + notNegativeTabIndex)) { + return true; + } + const isCustomElement = element.localName.includes("-"); + if (!isCustomElement) { + return false; + } + if (!element.matches(notDisabled)) { + return false; + } + return element.shadowRoot?.delegatesFocus ?? false; + } + + // node_modules/@material/web/dialog/internal/dialog-styles.js + var styles10 = i`:host{border-start-start-radius:var(--md-dialog-container-shape-start-start, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-start-end-radius:var(--md-dialog-container-shape-start-end, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-end-end-radius:var(--md-dialog-container-shape-end-end, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-end-start-radius:var(--md-dialog-container-shape-end-start, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));display:contents;margin:auto;max-height:min(560px,100% - 48px);max-width:min(560px,100% - 48px);min-height:140px;min-width:280px;position:fixed;height:fit-content;width:fit-content}dialog{background:rgba(0,0,0,0);border:none;border-radius:inherit;flex-direction:column;height:inherit;margin:inherit;max-height:inherit;max-width:inherit;min-height:inherit;min-width:inherit;outline:none;overflow:visible;padding:0;width:inherit}dialog[open]{display:flex}::backdrop{background:none}.scrim{background:var(--md-sys-color-scrim, #000);display:none;inset:0;opacity:32%;pointer-events:none;position:fixed;z-index:1}:host([open]) .scrim{display:flex}h2{all:unset;align-self:stretch}.headline{align-items:center;color:var(--md-dialog-headline-color, var(--md-sys-color-on-surface, #1d1b20));display:flex;flex-direction:column;font-family:var(--md-dialog-headline-font, var(--md-sys-typescale-headline-small-font, var(--md-ref-typeface-brand, Roboto)));font-size:var(--md-dialog-headline-size, var(--md-sys-typescale-headline-small-size, 1.5rem));line-height:var(--md-dialog-headline-line-height, var(--md-sys-typescale-headline-small-line-height, 2rem));font-weight:var(--md-dialog-headline-weight, var(--md-sys-typescale-headline-small-weight, var(--md-ref-typeface-weight-regular, 400)));position:relative}slot[name=headline]::slotted(*){align-items:center;align-self:stretch;box-sizing:border-box;display:flex;gap:8px;padding:24px 24px 0}.icon{display:flex}slot[name=icon]::slotted(*){color:var(--md-dialog-icon-color, var(--md-sys-color-secondary, #625b71));fill:currentColor;font-size:var(--md-dialog-icon-size, 24px);margin-top:24px;height:var(--md-dialog-icon-size, 24px);width:var(--md-dialog-icon-size, 24px)}.has-icon slot[name=headline]::slotted(*){justify-content:center;padding-top:16px}.scrollable slot[name=headline]::slotted(*){padding-bottom:16px}.scrollable.has-headline slot[name=content]::slotted(*){padding-top:8px}.container{border-radius:inherit;display:flex;flex-direction:column;flex-grow:1;overflow:hidden;position:relative;transform-origin:top}.container::before{background:var(--md-dialog-container-color, var(--md-sys-color-surface-container-high, #ece6f0));border-radius:inherit;content:"";inset:0;position:absolute}.scroller{display:flex;flex:1;flex-direction:column;overflow:hidden;z-index:1}.scrollable .scroller{overflow-y:scroll}.content{color:var(--md-dialog-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));font-family:var(--md-dialog-supporting-text-font, var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-dialog-supporting-text-size, var(--md-sys-typescale-body-medium-size, 0.875rem));line-height:var(--md-dialog-supporting-text-line-height, var(--md-sys-typescale-body-medium-line-height, 1.25rem));flex:1;font-weight:var(--md-dialog-supporting-text-weight, var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400)));height:min-content;position:relative}slot[name=content]::slotted(*){box-sizing:border-box;padding:24px}.anchor{position:absolute}.top.anchor{top:0}.bottom.anchor{bottom:0}.actions{position:relative}slot[name=actions]::slotted(*){box-sizing:border-box;display:flex;gap:8px;justify-content:flex-end;padding:16px 24px 24px}.has-actions slot[name=content]::slotted(*){padding-bottom:8px}md-divider{display:none;position:absolute}.has-headline.show-top-divider .headline md-divider,.has-actions.show-bottom-divider .actions md-divider{display:flex}.headline md-divider{bottom:0}.actions md-divider{top:0}@media(forced-colors: active){dialog{outline:2px solid WindowText}} +`; + + // node_modules/@material/web/dialog/dialog.js + var MdDialog = class MdDialog2 extends Dialog { + }; + MdDialog.styles = [styles10]; + MdDialog = __decorate([ + t2("md-dialog") + ], MdDialog); + + // node_modules/@material/web/icon/internal/icon.js + var Icon = class extends h3 { + render() { + return ke``; + } + connectedCallback() { + super.connectedCallback(); + const ariaHidden = this.getAttribute("aria-hidden"); + if (ariaHidden === "false") { + this.removeAttribute("aria-hidden"); + return; + } + this.setAttribute("aria-hidden", "true"); + } + }; + + // node_modules/@material/web/icon/internal/icon-styles.js + var styles11 = i`:host{font-size:var(--md-icon-size, 24px);width:var(--md-icon-size, 24px);height:var(--md-icon-size, 24px);color:inherit;font-variation-settings:inherit;font-weight:400;font-family:var(--md-icon-font, Material Symbols Outlined);display:inline-flex;font-style:normal;place-items:center;place-content:center;line-height:1;overflow:hidden;letter-spacing:normal;text-transform:none;user-select:none;white-space:nowrap;word-wrap:normal;flex-shrink:0;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;-moz-osx-font-smoothing:grayscale}::slotted(svg){fill:currentColor}::slotted(*){height:100%;width:100%} +`; + + // node_modules/@material/web/icon/icon.js + var MdIcon = class MdIcon2 extends Icon { + }; + MdIcon.styles = [styles11]; + MdIcon = __decorate([ + t2("md-icon") + ], MdIcon); + + // node_modules/lit-html/static.js + var $e = Symbol.for(""); + var xe = (t4) => { + if (t4?.r === $e) + return t4?._$litStatic$; + }; + var er = (t4, ...r5) => ({ _$litStatic$: r5.reduce((r6, e6, a2) => r6 + ((t5) => { + if (void 0 !== t5._$litStatic$) + return t5._$litStatic$; + throw Error(`Value passed to 'literal' function must be a 'literal' result: ${t5}. Use 'unsafeStatic' to pass non-literal values, but + take care to ensure page security.`); + })(e6) + t4[a2 + 1], t4[0]), r: $e }); + var Te = /* @__PURE__ */ new Map(); + var Ee = (t4) => (r5, ...e6) => { + const a2 = e6.length; + let o5, s2; + const i4 = [], l2 = []; + let n5, u2 = 0, c4 = false; + for (; u2 < a2; ) { + for (n5 = r5[u2]; u2 < a2 && void 0 !== (s2 = e6[u2], o5 = xe(s2)); ) + n5 += o5 + r5[++u2], c4 = true; + u2 !== a2 && l2.push(s2), i4.push(n5), u2++; + } + if (u2 === a2 && i4.push(r5[a2]), c4) { + const t5 = i4.join("$$lit$$"); + void 0 === (r5 = Te.get(t5)) && (i4.raw = i4, Te.set(t5, r5 = i4)), e6 = l2; + } + return t4(r5, ...e6); + }; + var ke2 = Ee(ke); + var Oe2 = Ee(Oe); + var Se2 = Ee(Se); + + // node_modules/@material/web/internal/controller/is-rtl.js + function isRtl(el, shouldCheck = true) { + return shouldCheck && getComputedStyle(el).getPropertyValue("direction").trim() === "rtl"; + } + + // node_modules/@material/web/iconbutton/internal/icon-button.js + var iconButtonBaseClass = mixinDelegatesAria(mixinElementInternals(h3)); + var IconButton = class extends iconButtonBaseClass { + get name() { + return this.getAttribute("name") ?? ""; + } + set name(name) { + this.setAttribute("name", name); + } + /** + * The associated form element with which this element's value will submit. + */ + get form() { + return this[internals].form; + } + /** + * The labels this element is associated with. + */ + get labels() { + return this[internals].labels; + } + constructor() { + super(); + this.disabled = false; + this.softDisabled = false; + this.flipIconInRtl = false; + this.href = ""; + this.target = ""; + this.ariaLabelSelected = ""; + this.toggle = false; + this.selected = false; + this.type = "submit"; + this.value = ""; + this.flipIcon = isRtl(this, this.flipIconInRtl); + if (!co) { + this.addEventListener("click", this.handleClick.bind(this)); + } + } + willUpdate() { + if (this.href) { + this.disabled = false; + this.softDisabled = false; + } + } + render() { + const tag = this.href ? er`div` : er`button`; + const { ariaLabel, ariaHasPopup, ariaExpanded } = this; + const hasToggledAriaLabel = ariaLabel && this.ariaLabelSelected; + const ariaPressedValue = !this.toggle ? D : this.selected; + let ariaLabelValue = D; + if (!this.href) { + ariaLabelValue = hasToggledAriaLabel && this.selected ? this.ariaLabelSelected : ariaLabel; + } + return ke2`<${tag} + class="icon-button ${Rt(this.getRenderClasses())}" + id="button" + aria-label="${ariaLabelValue || D}" + aria-haspopup="${!this.href && ariaHasPopup || D}" + aria-expanded="${!this.href && ariaExpanded || D}" + aria-pressed="${ariaPressedValue}" + aria-disabled=${!this.href && this.softDisabled || D} + ?disabled="${!this.href && this.disabled}" + @click="${this.handleClickOnChild}"> + ${this.renderFocusRing()} + ${this.renderRipple()} + ${!this.selected ? this.renderIcon() : D} + ${this.selected ? this.renderSelectedIcon() : D} + ${this.renderTouchTarget()} + ${this.href && this.renderLink()} + `; + } + renderLink() { + const { ariaLabel } = this; + return ke` + + `; + } + getRenderClasses() { + return { + "flip-icon": this.flipIcon, + "selected": this.toggle && this.selected + }; + } + renderIcon() { + return ke``; + } + renderSelectedIcon() { + return ke``; + } + renderTouchTarget() { + return ke``; + } + renderFocusRing() { + return ke``; + } + renderRipple() { + const isRippleDisabled = !this.href && (this.disabled || this.softDisabled); + return ke``; + } + connectedCallback() { + this.flipIcon = isRtl(this, this.flipIconInRtl); + super.connectedCallback(); + } + /** Handles a click on this element. */ + handleClick(event) { + if (!this.href && this.softDisabled) { + event.stopImmediatePropagation(); + event.preventDefault(); + return; + } + } + /** + * Handles a click on the child
or
`; + }, + phishing: () => { + const text = i18n.t("site:phishingWebsiteDesc.title", { domain: model.tab.domain }); + return import_nanohtml6.default` +
+
+ ${title(model.tab.domain)} ${description((0, import_raw2.default)(text))} +
+ `; } }[state](); } @@ -15604,356 +15843,6 @@ return /* @__PURE__ */ y("a", { href: "javascript:void(0)", className: classes.join(" "), draggable: false, ...rest }, children); } - // node_modules/@material/switch/constants.js - var CssClasses; - (function(CssClasses2) { - CssClasses2["PROCESSING"] = "mdc-switch--processing"; - CssClasses2["SELECTED"] = "mdc-switch--selected"; - CssClasses2["UNSELECTED"] = "mdc-switch--unselected"; - })(CssClasses || (CssClasses = {})); - var Selectors; - (function(Selectors2) { - Selectors2["RIPPLE"] = ".mdc-switch__ripple"; - })(Selectors || (Selectors = {})); - - // node_modules/@material/base/observer.js - function observeProperty(target, property, observer) { - var targetObservers = installObserver(target, property); - var observers = targetObservers.getObservers(property); - observers.push(observer); - return function() { - observers.splice(observers.indexOf(observer), 1); - }; - } - var allTargetObservers = /* @__PURE__ */ new WeakMap(); - function installObserver(target, property) { - var observersMap = /* @__PURE__ */ new Map(); - if (!allTargetObservers.has(target)) { - allTargetObservers.set(target, { - isEnabled: true, - getObservers: function(key) { - var observers = observersMap.get(key) || []; - if (!observersMap.has(key)) { - observersMap.set(key, observers); - } - return observers; - }, - installedProperties: /* @__PURE__ */ new Set() - }); - } - var targetObservers = allTargetObservers.get(target); - if (targetObservers.installedProperties.has(property)) { - return targetObservers; - } - var descriptor = getDescriptor(target, property) || { - configurable: true, - enumerable: true, - value: target[property], - writable: true - }; - var observedDescriptor = __assign({}, descriptor); - var descGet = descriptor.get, descSet = descriptor.set; - if ("value" in descriptor) { - delete observedDescriptor.value; - delete observedDescriptor.writable; - var value_1 = descriptor.value; - descGet = function() { - return value_1; - }; - if (descriptor.writable) { - descSet = function(newValue) { - value_1 = newValue; - }; - } - } - if (descGet) { - observedDescriptor.get = function() { - return descGet.call(this); - }; - } - if (descSet) { - observedDescriptor.set = function(newValue) { - var e_4, _a2; - var previous = descGet ? descGet.call(this) : newValue; - descSet.call(this, newValue); - if (targetObservers.isEnabled && (!descGet || newValue !== previous)) { - try { - for (var _b = __values(targetObservers.getObservers(property)), _c = _b.next(); !_c.done; _c = _b.next()) { - var observer = _c.value; - observer(newValue, previous); - } - } catch (e_4_1) { - e_4 = { error: e_4_1 }; - } finally { - try { - if (_c && !_c.done && (_a2 = _b.return)) - _a2.call(_b); - } finally { - if (e_4) - throw e_4.error; - } - } - } - }; - } - targetObservers.installedProperties.add(property); - Object.defineProperty(target, property, observedDescriptor); - return targetObservers; - } - function getDescriptor(target, property) { - var descriptorTarget = target; - var descriptor; - while (descriptorTarget) { - descriptor = Object.getOwnPropertyDescriptor(descriptorTarget, property); - if (descriptor) { - break; - } - descriptorTarget = Object.getPrototypeOf(descriptorTarget); - } - return descriptor; - } - function setObserversEnabled(target, enabled) { - var targetObservers = allTargetObservers.get(target); - if (targetObservers) { - targetObservers.isEnabled = enabled; - } - } - - // node_modules/@material/base/observer-foundation.js - var MDCObserverFoundation = ( - /** @class */ - function(_super) { - __extends(MDCObserverFoundation2, _super); - function MDCObserverFoundation2(adapter) { - var _this = _super.call(this, adapter) || this; - _this.unobserves = /* @__PURE__ */ new Set(); - return _this; - } - MDCObserverFoundation2.prototype.destroy = function() { - _super.prototype.destroy.call(this); - this.unobserve(); - }; - MDCObserverFoundation2.prototype.observe = function(target, observers) { - var e_1, _a2; - var _this = this; - var cleanup = []; - try { - for (var _b = __values(Object.keys(observers)), _c = _b.next(); !_c.done; _c = _b.next()) { - var property = _c.value; - var observer = observers[property].bind(this); - cleanup.push(this.observeProperty(target, property, observer)); - } - } catch (e_1_1) { - e_1 = { error: e_1_1 }; - } finally { - try { - if (_c && !_c.done && (_a2 = _b.return)) - _a2.call(_b); - } finally { - if (e_1) - throw e_1.error; - } - } - var unobserve = function() { - var e_2, _a3; - try { - for (var cleanup_1 = __values(cleanup), cleanup_1_1 = cleanup_1.next(); !cleanup_1_1.done; cleanup_1_1 = cleanup_1.next()) { - var cleanupFn = cleanup_1_1.value; - cleanupFn(); - } - } catch (e_2_1) { - e_2 = { error: e_2_1 }; - } finally { - try { - if (cleanup_1_1 && !cleanup_1_1.done && (_a3 = cleanup_1.return)) - _a3.call(cleanup_1); - } finally { - if (e_2) - throw e_2.error; - } - } - _this.unobserves.delete(unobserve); - }; - this.unobserves.add(unobserve); - return unobserve; - }; - MDCObserverFoundation2.prototype.observeProperty = function(target, property, observer) { - return observeProperty(target, property, observer); - }; - MDCObserverFoundation2.prototype.setObserversEnabled = function(target, enabled) { - setObserversEnabled(target, enabled); - }; - MDCObserverFoundation2.prototype.unobserve = function() { - var e_3, _a2; - try { - for (var _b = __values(__spreadArray([], __read(this.unobserves))), _c = _b.next(); !_c.done; _c = _b.next()) { - var unobserve = _c.value; - unobserve(); - } - } catch (e_3_1) { - e_3 = { error: e_3_1 }; - } finally { - try { - if (_c && !_c.done && (_a2 = _b.return)) - _a2.call(_b); - } finally { - if (e_3) - throw e_3.error; - } - } - }; - return MDCObserverFoundation2; - }(MDCFoundation) - ); - - // node_modules/@material/switch/foundation.js - var MDCSwitchFoundation = ( - /** @class */ - function(_super) { - __extends(MDCSwitchFoundation2, _super); - function MDCSwitchFoundation2(adapter) { - var _this = _super.call(this, adapter) || this; - _this.handleClick = _this.handleClick.bind(_this); - return _this; - } - MDCSwitchFoundation2.prototype.init = function() { - this.observe(this.adapter.state, { - disabled: this.stopProcessingIfDisabled, - processing: this.stopProcessingIfDisabled - }); - }; - MDCSwitchFoundation2.prototype.handleClick = function() { - if (this.adapter.state.disabled) { - return; - } - this.adapter.state.selected = !this.adapter.state.selected; - }; - MDCSwitchFoundation2.prototype.stopProcessingIfDisabled = function() { - if (this.adapter.state.disabled) { - this.adapter.state.processing = false; - } - }; - return MDCSwitchFoundation2; - }(MDCObserverFoundation) - ); - var MDCSwitchRenderFoundation = ( - /** @class */ - function(_super) { - __extends(MDCSwitchRenderFoundation2, _super); - function MDCSwitchRenderFoundation2() { - return _super !== null && _super.apply(this, arguments) || this; - } - MDCSwitchRenderFoundation2.prototype.init = function() { - _super.prototype.init.call(this); - this.observe(this.adapter.state, { - disabled: this.onDisabledChange, - processing: this.onProcessingChange, - selected: this.onSelectedChange - }); - }; - MDCSwitchRenderFoundation2.prototype.initFromDOM = function() { - this.setObserversEnabled(this.adapter.state, false); - this.adapter.state.selected = this.adapter.hasClass(CssClasses.SELECTED); - this.onSelectedChange(); - this.adapter.state.disabled = this.adapter.isDisabled(); - this.adapter.state.processing = this.adapter.hasClass(CssClasses.PROCESSING); - this.setObserversEnabled(this.adapter.state, true); - this.stopProcessingIfDisabled(); - }; - MDCSwitchRenderFoundation2.prototype.onDisabledChange = function() { - this.adapter.setDisabled(this.adapter.state.disabled); - }; - MDCSwitchRenderFoundation2.prototype.onProcessingChange = function() { - this.toggleClass(this.adapter.state.processing, CssClasses.PROCESSING); - }; - MDCSwitchRenderFoundation2.prototype.onSelectedChange = function() { - this.adapter.setAriaChecked(String(this.adapter.state.selected)); - this.toggleClass(this.adapter.state.selected, CssClasses.SELECTED); - this.toggleClass(!this.adapter.state.selected, CssClasses.UNSELECTED); - }; - MDCSwitchRenderFoundation2.prototype.toggleClass = function(addClass, className) { - if (addClass) { - this.adapter.addClass(className); - } else { - this.adapter.removeClass(className); - } - }; - return MDCSwitchRenderFoundation2; - }(MDCSwitchFoundation) - ); - - // node_modules/@material/switch/component.js - var MDCSwitch = ( - /** @class */ - function(_super) { - __extends(MDCSwitch2, _super); - function MDCSwitch2(root, foundation) { - var _this = _super.call(this, root, foundation) || this; - _this.root = root; - return _this; - } - MDCSwitch2.attachTo = function(root) { - return new MDCSwitch2(root); - }; - MDCSwitch2.prototype.initialize = function() { - this.ripple = new MDCRipple(this.root, this.createRippleFoundation()); - }; - MDCSwitch2.prototype.initialSyncWithDOM = function() { - var rippleElement = this.root.querySelector(Selectors.RIPPLE); - if (!rippleElement) { - throw new Error("Switch " + Selectors.RIPPLE + " element is required."); - } - this.rippleElement = rippleElement; - this.root.addEventListener("click", this.foundation.handleClick); - this.foundation.initFromDOM(); - }; - MDCSwitch2.prototype.destroy = function() { - _super.prototype.destroy.call(this); - this.ripple.destroy(); - this.root.removeEventListener("click", this.foundation.handleClick); - }; - MDCSwitch2.prototype.getDefaultFoundation = function() { - return new MDCSwitchRenderFoundation(this.createAdapter()); - }; - MDCSwitch2.prototype.createAdapter = function() { - var _this = this; - return { - addClass: function(className) { - _this.root.classList.add(className); - }, - hasClass: function(className) { - return _this.root.classList.contains(className); - }, - isDisabled: function() { - return _this.root.disabled; - }, - removeClass: function(className) { - _this.root.classList.remove(className); - }, - setAriaChecked: function(ariaChecked) { - return _this.root.setAttribute("aria-checked", ariaChecked); - }, - setDisabled: function(disabled) { - _this.root.disabled = disabled; - }, - state: this - }; - }; - MDCSwitch2.prototype.createRippleFoundation = function() { - return new MDCRippleFoundation(this.createRippleAdapter()); - }; - MDCSwitch2.prototype.createRippleAdapter = function() { - var _this = this; - return __assign(__assign({}, MDCRipple.createAdapter(this)), { computeBoundingRect: function() { - return _this.rippleElement.getBoundingClientRect(); - }, isUnbounded: function() { - return true; - } }); - }; - return MDCSwitch2; - }(MDCComponent) - ); - // shared/js/ui/components/toggle.jsx function ProtectionToggle(props) { const [toggleState, toggle] = useToggleState(props.model, props.toggle); @@ -16014,48 +15903,7 @@ const labelEnabled = ns.site("enableProtectionsSwitch.title"); const labelDisabled = ns.site("disableProtectionsSwitch.title"); const label = toggleState.active ? labelDisabled : labelEnabled; - return isAndroid() ? /* @__PURE__ */ y(AndroidToggle, { toggleState, onToggle: props.onToggle, label }) : /* @__PURE__ */ y(DefaultToggleButton, { toggleState, label, onToggle: props.onToggle }); - } - function AndroidToggle(props) { - const ref = _(null); - const className = `mdc-switch mdc-switch--${props.toggleState.active ? "selected" : "unselected"}`; - y2(() => { - if (!ref.current) - return; - const elem = ( - /** @type {HTMLButtonElement} */ - ref.current - ); - if (!(elem instanceof HTMLButtonElement)) - return; - const switchInstance = new MDCSwitch(ref.current); - switchInstance.listen("click", () => { - const pressed = elem.getAttribute("aria-checked"); - const next = pressed === "true" ? "false" : "true"; - elem.setAttribute("aria-checked", next); - props.onToggle(); - switchInstance.destroy(); - }); - return () => { - switchInstance.destroy(); - }; - }, []); - return /* @__PURE__ */ y( - "button", - { - ref, - id: "basic-switch", - class: className, - type: "button", - role: "switch", - "aria-checked": "false", - "aria-label": props.label, - disabled: props.toggleState.disabled - }, - /* @__PURE__ */ y("div", { class: "mdc-switch__track" }), - /* @__PURE__ */ y("div", { class: "mdc-switch__handle-track" }, /* @__PURE__ */ y("div", { class: "mdc-switch__handle" }, /* @__PURE__ */ y("div", { class: "mdc-switch__shadow" }, /* @__PURE__ */ y("div", { class: "mdc-elevation-overlay" })), /* @__PURE__ */ y("div", { class: "mdc-switch__ripple" }))), - /* @__PURE__ */ y("span", { class: "mdc-switch__focus-ring-wrapper" }, /* @__PURE__ */ y("div", { class: "mdc-switch__focus-ring" })) - ); + return /* @__PURE__ */ y(DefaultToggleButton, { toggleState, label, onToggle: props.onToggle }); } function DefaultToggleButton(props) { const { toggleState, label } = props; @@ -16652,6 +16500,164 @@ return arr2; } + // v2/components/custom-element-loader.jsx + function CustomElementLoader(props) { + const [state, dispatch] = s2(reducer, { status: "idle" }); + p2(() => { + if (state.status === "idle") { + if (window.__ddg_did_load && window.__ddg_did_load.includes(props.element)) { + dispatch({ kind: "skip-loading" }); + } else { + dispatch({ kind: "load-script", element: props.element }); + } + return; + } + if (state.status === "script-ready") { + dispatch({ kind: "load-element" }); + customElements.whenDefined(props.element).then(() => { + if (!window.__ddg_did_load) + window.__ddg_did_load = []; + window.__ddg_did_load.push(props.element); + dispatch({ kind: "element-loaded" }); + }); + } + }, [state.status, props.src, props.element]); + if (state.status === "element-ready") { + return props.children; + } + if (state.status === "script-pending") { + return /* @__PURE__ */ y("script", { src: props.src, onLoad: () => dispatch({ kind: "script-loaded" }) }); + } + return ( + /** @type {any} */ + null + ); + } + function reducer(state, event) { + console.log("incoming", event, "current", state); + switch (state.status) { + case "idle": { + switch (event.kind) { + case "load-script": { + return { ...state, status: ( + /** @type {const} */ + "script-pending" + ) }; + } + case "skip-loading": { + return { ...state, status: ( + /** @type {const} */ + "element-ready" + ) }; + } + } + break; + } + case "script-pending": { + switch (event.kind) { + case "script-loaded": { + return { ...state, status: ( + /** @type {const} */ + "script-ready" + ) }; + } + } + break; + } + case "script-ready": { + switch (event.kind) { + case "load-element": { + return { ...state, status: ( + /** @type {const} */ + "element-pending" + ) }; + } + } + break; + } + case "element-pending": { + switch (event.kind) { + case "element-loaded": { + return { ...state, status: ( + /** @type {const} */ + "element-ready" + ) }; + } + } + } + } + return state; + } + + // v2/components/android-breakage-modal-wrapper.jsx + var DDG_DIALOG_NAME = "ddg-android-breakage-dialog"; + var DDG_DIALOG_PATH = "../public/js/android-breakage-dialog.js"; + function FormSelectElementWithDialog() { + const platformFeatures = useFeatures(); + const randomised = F(() => { + const f3 = createBreakageFeaturesFrom(platformFeatures); + return f3.categoryList(); + }, [platformFeatures]); + const selectRef = _(null); + function onClick(e3) { + e3.preventDefault(); + e3.stopImmediatePropagation(); + const elem = document.querySelector(DDG_DIALOG_NAME); + if (!elem) + return console.warn("could not find custom element", "ddg-android-breakage-dialog"); + if (!selectRef.current) + return console.warn("could not find select ref"); + elem.show(selectRef.current.value); + } + return /* @__PURE__ */ y(k, null, /* @__PURE__ */ y(CustomElementLoader, { src: DDG_DIALOG_PATH, element: DDG_DIALOG_NAME }, /* @__PURE__ */ y( + AndroidBreakageDialogWrapper, + { + items: randomised, + onSelect: (value) => { + if (!selectRef.current) + return; + selectRef.current.value = value; + } + } + )), /* @__PURE__ */ y("div", { className: "form__select breakage-form__input--dropdown", onClick, "data-testid": "select-click-capture" }, /* @__PURE__ */ y("select", { name: "category", ref: selectRef }, /* @__PURE__ */ y("option", { value: "", selected: true, disabled: true }, ns.report("pickYourIssueFromTheList.title")), randomised.map(([key, value]) => { + return /* @__PURE__ */ y("option", { value: key }, value); + })))); + } + function AndroidBreakageDialogWrapper({ items, onSelect }) { + const ref = _(null); + p2(() => { + const controller = new AbortController(); + ref.current?.addEventListener( + "did-select", + (e3) => { + const selection = ( + /** @type {{value: string}} */ + e3.detail + ); + const matched = items.find(([name]) => name === selection.value); + if (!matched) + throw new Error("value did not match a variant"); + const [value] = matched; + onSelect(value); + }, + { signal: controller.signal } + ); + return () => { + return controller.abort(); + }; + }, []); + return /* @__PURE__ */ y( + "ddg-android-breakage-dialog", + { + items, + ref, + title: ns.report("pickYourIssueFromTheList.title"), + cancelText: ns.site("navigationCancel.title"), + okText: ns.site("okDialogAction.title") + } + ); + } + // v2/screens/breakage-form-screen.jsx function BreakageFormScreen({ includeToggle }) { const data = useData(); @@ -16678,17 +16684,17 @@ }); setState("sent"); } - let topNav2 = /* @__PURE__ */ y(SecondaryTopNav, null); + let topNav2 = platformSwitch({ + android: () => /* @__PURE__ */ y(SecondaryTopNav, null, /* @__PURE__ */ y(Title, null, ns.site("websiteNotWorkingCta.title"))), + default: () => /* @__PURE__ */ y(SecondaryTopNav, null) + }); if (!canPop) { topNav2 = platformSwitch({ ios: () => /* @__PURE__ */ y(TopNav, { done: /* @__PURE__ */ y(Done, { onClick: onClose }) }), + android: () => /* @__PURE__ */ y(TopNav, { back: /* @__PURE__ */ y(Back, { onClick: onClose }) }, /* @__PURE__ */ y(Title, null, ns.site("websiteNotWorkingCta.title"))), 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, { @@ -16701,12 +16707,20 @@ 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")), "$", randomised.map(([key, value]) => { - return /* @__PURE__ */ y("option", { value: key }, value); - }))) + before: platformFeatures.breakageFormCategorySelect === "material-web-dialog" ? /* @__PURE__ */ y(FormSelectElementWithDialog, null) : /* @__PURE__ */ y(DefaultSelectElement, null) } )), /* @__PURE__ */ y("div", { className: "breakage-form__footer padding-x-double token-breakage-form-body" }, ns.report("reportsAreAnonymousDesc.title")))); } + function DefaultSelectElement() { + const platformFeatures = useFeatures(); + const randomised = F(() => { + const f3 = createBreakageFeaturesFrom(platformFeatures); + return f3.categoryList(); + }, [platformFeatures]); + return /* @__PURE__ */ y("div", { className: "form__select breakage-form__input--dropdown" }, /* @__PURE__ */ y("select", { name: "category" }, /* @__PURE__ */ y("option", { value: "", selected: true, disabled: true }, ns.report("pickYourIssueFromTheList.title")), randomised.map(([key, value]) => { + return /* @__PURE__ */ y("option", { value: key }, value); + }))); + } function FormElement({ onSubmit, before, after, placeholder }) { let bullet = "\n \u2022 "; placeholder = placeholder || ns.report("tellUsMoreDesc.title", { bullet }); @@ -16879,6 +16893,7 @@ } // shared/js/ui/components/toggle-report/toggle-report-provider.jsx + init_schema_parsers(); var ToggleReportContext = G({ value: ( /** @type {import('../../../../../schema/__generated__/schema.types').ToggleReportScreen} */ @@ -16907,7 +16922,17 @@ p2(() => { const msg = new FetchToggleReportOptions(); model.fetch(msg)?.then((data) => { - dispatch({ status: "ready", value: data }); + const parsed = toggleReportScreenSchema.safeParse(data); + if (parsed.success) { + dispatch({ status: "ready", value: data }); + } else { + console.group("ToggleReportProvider"); + console.error("the response for FetchToggleReportOptions did not match the schema"); + console.error("response:", data); + console.error("error:", parsed.error.toString()); + console.groupEnd(); + dispatch({ status: "error", error: parsed.error.toString() }); + } }).catch((e3) => { dispatch({ status: "error", error: e3.toString() }); }); @@ -17167,17 +17192,56 @@ // shared/js/ui/components/toggle-report.jsx function ToggleReport() { - const buttonVariant = platform.name === "ios" ? "ios-secondary" : "macos-standard"; - const buttonLayout = platform.name === "ios" ? "vertical" : "horizontal"; - const buttonSize = platform.name === "ios" ? "big" : "small"; const innerGap = platform.name === "ios" ? "24px" : "16px"; + const desktop = platform.name === "macos" || platform.name === "windows"; + const extension = platform.name === "browser"; const { value, didClickSuccessScreen } = q2(ToggleReportContext); const [state, dispatch] = useToggleReportState(); useIosAnimation(state, dispatch); - if (state.value === "sent" && platform.name === "macos") { - return /* @__PURE__ */ y(ToggleReportWrapper, { state: state.value }, /* @__PURE__ */ y(ToggleReportSent, { onClick: didClickSuccessScreen })); + if (state.value === "sent" && (desktop || extension)) { + return /* @__PURE__ */ y(ToggleReportWrapper, { state: state.value }, extension && /* @__PURE__ */ y(SetAutoHeight, null), /* @__PURE__ */ y(ToggleReportSent, { onClick: didClickSuccessScreen })); + } + if (desktop || extension) { + return /* @__PURE__ */ y(ToggleReportWrapper, { state: state.value }, extension && /* @__PURE__ */ y(SetAutoHeight, null), /* @__PURE__ */ y(Stack, { gap: "40px" }, /* @__PURE__ */ y(Stack, { gap: "24px" }, /* @__PURE__ */ y(Stack, { gap: innerGap }, /* @__PURE__ */ y("div", { className: "medium-icon-container hero-icon--toggle-report" }), /* @__PURE__ */ y(ToggleReportTitle, null, ns.toggleReport("siteNotWorkingTitle.title")), /* @__PURE__ */ y("div", null, /* @__PURE__ */ y("h2", { className: "token-title-3 text--center" }, ns.toggleReport("siteNotWorkingSubTitle.title")), /* @__PURE__ */ y(DesktopRevealText, { state, toggle: () => dispatch("toggle") }))), state.value === "showing" && /* @__PURE__ */ y(Scrollable, null, /* @__PURE__ */ y(ToggleReportDataList, { rows: value.data })), /* @__PURE__ */ y(ToggleReportButtons, { send: () => dispatch("send"), reject: () => dispatch("reject") })))); + } + if (platform.name === "ios") { + return /* @__PURE__ */ y(ToggleReportWrapper, { state: state.value }, /* @__PURE__ */ y(Stack, { gap: "40px" }, /* @__PURE__ */ y(Stack, { gap: "24px" }, /* @__PURE__ */ y(Stack, { gap: innerGap }, /* @__PURE__ */ y("div", { className: "medium-icon-container hero-icon--toggle-report" }), /* @__PURE__ */ y(ToggleReportTitle, null, ns.toggleReport("siteNotWorkingTitle.title")), /* @__PURE__ */ y("div", null, /* @__PURE__ */ y("h2", { className: "token-title-3 text--center" }, ns.toggleReport("siteNotWorkingSubTitle.title")))), /* @__PURE__ */ y(ToggleReportButtons, { send: () => dispatch("send"), reject: () => dispatch("reject") }), state.value !== "showing" && /* @__PURE__ */ y(RevealText, { toggle: () => dispatch("toggle-ios") })), state.value === "showing" && /* @__PURE__ */ y("div", { className: "ios-separator" }, /* @__PURE__ */ y(ToggleReportDataList, { rows: value.data })))); } - return /* @__PURE__ */ y(ToggleReportWrapper, { state: state.value }, /* @__PURE__ */ y(Stack, { gap: "40px" }, /* @__PURE__ */ y(Stack, { gap: "24px" }, /* @__PURE__ */ y(Stack, { gap: innerGap }, /* @__PURE__ */ y("div", { className: "medium-icon-container hero-icon--toggle-report" }), /* @__PURE__ */ y(ToggleReportTitle, null, ns.toggleReport("siteNotWorkingTitle.title")), /* @__PURE__ */ y("div", null, /* @__PURE__ */ y("h2", { className: "token-title-3 text--center" }, ns.toggleReport("siteNotWorkingSubTitle.title")), platform.name === "macos" && /* @__PURE__ */ y("div", null, /* @__PURE__ */ y("p", { className: "text--center token-title-3" }, /* @__PURE__ */ y(PlainTextLink, { onClick: () => dispatch("toggle") }, state.value === "hiding" && ns.toggleReport("siteNotWorkingInfoReveal.title"), state.value === "showing" && ns.toggleReport("siteNotWorkingInfoHide.title")))))), platform.name === "macos" && state.value === "showing" && /* @__PURE__ */ y(Scrollable, null, /* @__PURE__ */ y(ToggleReportDataList, { rows: value.data })), /* @__PURE__ */ y(ButtonBar, { layout: buttonLayout }, /* @__PURE__ */ y(Button, { variant: buttonVariant, btnSize: buttonSize, onClick: () => dispatch("reject") }, ns.toggleReport("dontSendReport.title")), /* @__PURE__ */ y(Button, { variant: buttonVariant, btnSize: buttonSize, onClick: () => dispatch("send") }, ns.report("sendReport.title"))), platform.name === "ios" && state.value !== "showing" && /* @__PURE__ */ y("p", { className: "text--center token-title-3" }, /* @__PURE__ */ y(PlainTextLink, { onClick: () => dispatch("toggle-ios"), className: "token-bold" }, ns.toggleReport("siteNotWorkingInfoReveal.title")))), platform.name === "ios" && state.value === "showing" && /* @__PURE__ */ y("div", { className: "ios-separator" }, /* @__PURE__ */ y(ToggleReportDataList, { rows: value.data })))); + return /* @__PURE__ */ y("p", null, "unsupported platform: ", platform.name); + } + function SetAutoHeight() { + p2(() => { + const inner = ( + /** @type {HTMLElement} */ + document.querySelector('[data-screen="toggleReport"] .page-inner') + ); + if (inner) { + inner.style.height = "auto"; + const height = getContentHeight(); + document.body.style.setProperty("--height", `${height}px`); + const unsub = setupMutationObserverForExtensions((height2) => { + document.body.style.setProperty("--height", `${height2}px`); + }); + return () => { + unsub(); + }; + } else { + console.warn("Could not select the required element"); + } + }, []); + return null; + } + function ToggleReportButtons({ send, reject }) { + const buttonVariant = platform.name === "ios" ? "ios-secondary" : "macos-standard"; + const buttonLayout = platform.name === "ios" ? "vertical" : "horizontal"; + const buttonSize = platform.name === "ios" ? "big" : "small"; + return /* @__PURE__ */ y(ButtonBar, { layout: buttonLayout }, /* @__PURE__ */ y(Button, { variant: buttonVariant, btnSize: buttonSize, onClick: reject }, ns.toggleReport("dontSendReport.title")), /* @__PURE__ */ y(Button, { variant: buttonVariant, btnSize: buttonSize, onClick: send }, ns.report("sendReport.title"))); + } + function RevealText({ toggle }) { + return /* @__PURE__ */ y("p", { className: "text--center token-title-3" }, /* @__PURE__ */ y(PlainTextLink, { onClick: toggle, className: "token-bold" }, ns.toggleReport("siteNotWorkingInfoReveal.title"))); + } + function DesktopRevealText({ state, toggle }) { + return /* @__PURE__ */ y("div", null, /* @__PURE__ */ y("p", { className: "text--center token-title-3" }, /* @__PURE__ */ y(PlainTextLink, { onClick: toggle }, state.value === "hiding" && ns.toggleReport("siteNotWorkingInfoReveal.title"), state.value === "showing" && ns.toggleReport("siteNotWorkingInfoHide.title")))); } // v2/screens/toggle-report-screen.jsx @@ -17185,6 +17249,8 @@ const fetcher = useFetcher(); const features = useFeatures(); const onClose = useClose(); + const connectionCount = useConnectionCount(); + const connectionId = `connection-${connectionCount}`; p2(() => { document.body.dataset.screen = "toggleReport"; return () => { @@ -17193,9 +17259,14 @@ }, []); const done = platformSwitch({ ios: () => /* @__PURE__ */ y(Done, { onClick: onClose }), - default: () => /* @__PURE__ */ y(Close, { onClick: onClose }) + macos: () => /* @__PURE__ */ y(Close, { onClick: onClose }), + default: () => null + }); + const back = platformSwitch({ + android: () => /* @__PURE__ */ y(Back, { onClick: onClose }), + default: () => null }); - return /* @__PURE__ */ y("div", { "data-toggle-report": "parent", class: "toggle-report page-inner", "data-opener": features.opener }, features.opener === "menu" ? /* @__PURE__ */ y(TopNav, { done }) : /* @__PURE__ */ y(TopNav, null), /* @__PURE__ */ y("div", { "data-testid": "toggle-report" }, /* @__PURE__ */ y(ToggleReportProvider, { model: { fetch: fetcher }, screen: features.initialScreen }, /* @__PURE__ */ y(ToggleReport, null)))); + return /* @__PURE__ */ y("div", { "data-toggle-report": "parent", class: "toggle-report page-inner", "data-opener": features.opener }, features.opener === "menu" ? /* @__PURE__ */ y(TopNav, { back, done }) : /* @__PURE__ */ y(TopNav, null), /* @__PURE__ */ y("div", { "data-testid": "toggle-report" }, /* @__PURE__ */ y(ToggleReportProvider, { key: connectionId, model: { fetch: fetcher }, screen: features.initialScreen }, /* @__PURE__ */ y(ToggleReport, null)))); } // v2/components/nav.jsx @@ -17352,6 +17423,7 @@ } // v2/navigation.jsx + init_schema_parsers(); var availableScreens = { primaryScreen: { kind: "root", component: () => /* @__PURE__ */ y(PrimaryScreen, null) }, // screens that would load immediately @@ -17369,6 +17441,10 @@ consentManaged: { kind: "subview", component: () => /* @__PURE__ */ y(ConsentManagedScreen, { cosmetic: false }) }, cookieHidden: { kind: "subview", component: () => /* @__PURE__ */ y(ConsentManagedScreen, { cosmetic: true }) } }; + var entries = ( + /** @type {[ScreenName, { kind: 'subview' | 'root', component: () => any}][]} */ + Object.entries(availableScreens) + ); var NavContext = G({ /** @type {(name: ScreenName, params?: Record) => void} */ push() { @@ -17378,10 +17454,6 @@ pop() { throw new Error("not implemented"); }, - /** @type {(stack: ScreenName[]) => void} */ - goto(stack) { - throw new Error("not implemented " + stack); - }, params: new URLSearchParams(""), /** @type {() => boolean} */ canPop: () => false, @@ -17407,6 +17479,9 @@ const { canPopFrom } = useNav(); return canPopFrom(screen); } + function isScreenName(input) { + return screenKindSchema.safeParse(input).success; + } function navReducer(state, event) { if (!window.__ddg_integration_test) { console.log("\u{1F4E9}", event, state); @@ -17451,14 +17526,9 @@ }; } case "push": { - const nextParams = new URLSearchParams(state.params); - for (let [key, value] of Object.entries(event.params)) { - nextParams.set(key, value); - } if (!event.opts.animate) { return { ...state, - params: nextParams, stack: state.stack.concat(event.name), state: ( /** @type {const} */ @@ -17469,7 +17539,6 @@ } return { ...state, - params: nextParams, stack: state.stack.concat(event.name), state: ( /** @type {const} */ @@ -17480,7 +17549,9 @@ } case "pop": { if (state.stack.length < 2) { - console.warn("ignoring a `pop` event"); + if (!window.__ddg_integration_test) { + console.warn("ignoring a `pop` event", window.location.search); + } return state; } if (!event.opts.animate) { @@ -17522,7 +17593,6 @@ stack: props.stack, state: "initial", commit: [], - params: props.params, via: void 0 }); const parentRef = _(null); @@ -17541,31 +17611,24 @@ }; }, [state.state]); p2(() => { - if (state.state !== "settled") { - return; - } - if (state.via === "push") { - const url = new URL(window.location.href); - url.searchParams.delete("stack"); - for (let string of state.stack) { - url.searchParams.append("stack", string); - } - for (let [key, value] of Object.entries(state.params)) { - url.searchParams.set(key, value); + function popstateHandler() { + const currentUrlParams = new URLSearchParams(location.search); + const currentURLStack = currentUrlParams.getAll("stack"); + const navigationIntentionIsForwards = currentURLStack.length > state.stack.length; + if (navigationIntentionIsForwards) { + const lastEntry = currentURLStack[currentURLStack.length - 1]; + if (isScreenName(lastEntry)) { + dispatch({ type: "push", name: lastEntry, opts: { animate: props.animate && isAndroid() } }); + } + } else { + dispatch({ type: "pop", opts: { animate: props.animate && isAndroid() } }); } - window.history.pushState({}, "", url); - } - if (state.via === "pop") { - window.history.go(-1); - } - function handler() { - dispatch({ type: "pop", opts: { animate: props.animate } }); } - window.addEventListener("popstate", handler); + window.addEventListener("popstate", popstateHandler); return () => { - window.removeEventListener("popstate", handler); + window.removeEventListener("popstate", popstateHandler); }; - }, [state.state, state.params, state.via, props.animate]); + }, [state.state, state.stack, state.via, props.animate]); const canPop = T2(() => { if (state.state === "transitioning") { return state.commit.length > 1 || state.stack.length > 1; @@ -17588,13 +17651,33 @@ return v3; }, [state.state, state.stack, state.commit]); const api = { - push: (name, params = {}) => dispatch({ type: "push", name, opts: { animate: props.animate }, params }), - pop: () => dispatch({ type: "pop", opts: { animate: props.animate } }), - goto: (stack) => dispatch({ type: "goto", stack, opts: { animate: props.animate } }), + /** + * @param {ScreenName} name + * @param {Record} params + */ + push: (name, params = {}) => { + const url = new URL(window.location.href); + for (let [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + url.searchParams.delete("stack"); + for (let string of state.stack) { + url.searchParams.append("stack", string); + } + url.searchParams.append("stack", name); + window.history.pushState({}, "", url); + dispatch({ type: "push", name, opts: { animate: props.animate } }); + }, + pop: () => { + window.history.go(-1); + dispatch({ type: "pop", opts: { animate: props.animate } }); + }, canPop, canPopFrom, screen, - params: state.params + get params() { + return new URLSearchParams(location.search); + } }; return /* @__PURE__ */ y(NavContext.Provider, { value: api }, /* @__PURE__ */ y( "div", @@ -17610,29 +17693,23 @@ transform: `translateX(` + -((state.stack.length - 1) * 100) + "%)" } }, - Object.entries(availableScreens).map(([name, item]) => { - const inStack = state.stack.includes(name); - const commiting = state.commit.includes(name); - const current = state.stack[state.stack.length - 1] === name; + entries.map(([screenName, item]) => { + const inStack = state.stack.includes(screenName); + const commiting = state.commit.includes(screenName); + const current = state.stack[state.stack.length - 1] === screenName; if (!inStack && !commiting) return null; if (item.kind === "root") { - return /* @__PURE__ */ y(ScreenContext.Provider, { value: { screen: ( - /** @type {ScreenName} */ - name - ) } }, /* @__PURE__ */ y("section", { className: "app-height", key: name }, item.component())); + return /* @__PURE__ */ y(ScreenContext.Provider, { value: { screen: screenName } }, /* @__PURE__ */ y("section", { className: "app-height", key: screenName }, item.component())); } - const translateValue = state.stack.includes(name) ? state.stack.indexOf(name) : state.commit.includes(name) ? state.commit.indexOf(name) : 0; + const translateValue = state.stack.includes(screenName) ? state.stack.indexOf(screenName) : state.commit.includes(screenName) ? state.commit.indexOf(screenName) : 0; const cssProp = `translateX(${translateValue * 100}%)`; - return /* @__PURE__ */ y(ScreenContext.Provider, { value: { screen: ( - /** @type {ScreenName} */ - name - ) } }, /* @__PURE__ */ y( + return /* @__PURE__ */ y(ScreenContext.Provider, { value: { screen: screenName } }, /* @__PURE__ */ y( "section", { "data-current": String(current), className: "sliding-subview-v2", - key: name, + key: screenName, style: { transform: cssProp } }, item.component() @@ -17884,124 +17961,4 @@ classnames/index.js: * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. *) - -@material/switch/constants.js: - (** - * @license - * Copyright 2021 Google Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - *) - -@material/base/observer.js: - (** - * @license - * Copyright 2021 Google Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - *) - -@material/base/observer-foundation.js: - (** - * @license - * Copyright 2021 Google Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - *) - -@material/switch/foundation.js: - (** - * @license - * Copyright 2021 Google Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - *) - -@material/switch/component.js: - (** - * @license - * Copyright 2021 Google Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - *) */ diff --git a/package-lock.json b/package-lock.json index e08a10bb284c..0e79a4e4e737 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "dependencies": { "@duckduckgo/autoconsent": "^10.15.0", - "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#13.1.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.16.0", - "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#5.1.0", + "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#14.0.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.19.0", + "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#7.0.2", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1724449523" }, "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", @@ -62,17 +65,20 @@ "version": "10.15.0", "resolved": "https://registry.npmjs.org/@duckduckgo/autoconsent/-/autoconsent-10.15.0.tgz", "integrity": "sha512-Jxaogy2IuZEEV1+xPyo3c3PnZJmBO6ima/MapF2VolI/IKxXnL+9yYqyydPhSk0ahx42YINA6uIK6zexlKDIkQ==", + "license": "MPL-2.0", "dependencies": { "tldts-experimental": "^6.1.37" } }, "node_modules/@duckduckgo/autofill": { - "resolved": "git+ssh://git@github.com/duckduckgo/duckduckgo-autofill.git#1fee787458d13f8ed07f9fe81aecd6e59609339e", - "hasInstallScript": true + "resolved": "git+ssh://git@github.com/duckduckgo/duckduckgo-autofill.git#8462e3b2d03015246e06ca5b6cfe9e381f626095", + "hasInstallScript": true, + "license": "Apache-2.0" }, "node_modules/@duckduckgo/content-scope-scripts": { - "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#4f0d109f13beec7e8beaf0bd5c0e2c1d528677f8", + "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#1ed569676555d493c9c5575eaed22aa02569aac9", "hasInstallScript": true, + "license": "Apache-2.0", "workspaces": [ "packages/special-pages", "packages/messaging" @@ -85,20 +91,22 @@ } }, "node_modules/@duckduckgo/privacy-dashboard": { - "resolved": "git+ssh://git@github.com/duckduckgo/privacy-dashboard.git#549e393d54c8fc3df1292175135eb93988e5342f", + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-dashboard.git#597bffc321a8f4b8d23b13aa0145406c393c0d8e", "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#6133e7d9d9cd5f1b925cab1971b4d785dc639df7" + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-reference-tests.git#6133e7d9d9cd5f1b925cab1971b4d785dc639df7", + "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 +121,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 +131,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 +141,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 +151,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 +170,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 +183,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 +204,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 +221,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.5.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", - "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", + "version": "22.7.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", + "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } @@ -221,6 +239,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 +249,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 +262,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 +274,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 +295,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 +310,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 +319,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 +344,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 +353,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 +362,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -345,6 +376,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 +386,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 +396,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 +407,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 +431,7 @@ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dev": true, + "license": "MIT", "dependencies": { "hasown": "^2.0.2" }, @@ -409,13 +446,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 +469,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 +479,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 +491,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 +514,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.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", - "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 +542,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 +552,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", @@ -517,10 +566,11 @@ } }, "node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -537,6 +587,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 +616,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 +639,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 +649,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 +659,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 +670,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 +683,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" }, @@ -633,10 +692,11 @@ } }, "node_modules/terser": { - "version": "5.33.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.33.0.tgz", - "integrity": "sha512-JuPVaB7s1gdFKPKTelwUyRq5Sid2A3Gko2S0PncwdBq7kN9Ti9HPWDQ06MPsEDGsZeVESjKEnyGy68quBk1w6g==", + "version": "5.34.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.34.1.tgz", + "integrity": "sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -651,28 +711,32 @@ } }, "node_modules/tldts-core": { - "version": "6.1.46", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.46.tgz", - "integrity": "sha512-zA3ai/j4aFcmbqTvTONkSBuWs0Q4X4tJxa0gV9sp6kDbq5dAhQDSg0WUkReEm0fBAKAGNj+wPKCCsR8MYOYmwA==" + "version": "6.1.48", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.48.tgz", + "integrity": "sha512-3gD9iKn/n2UuFH1uilBviK9gvTNT6iYwdqrj1Vr5mh8FuelvpRNaYVH4pNYqUgOGU4aAdL9X35eLuuj0gRsx+A==", + "license": "MIT" }, "node_modules/tldts-experimental": { - "version": "6.1.46", - "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.46.tgz", - "integrity": "sha512-u99EeyzhUNRwJep1M1CXBGmQ6OWncv0bi98/9CBv+AX6ep0B3HOkl0iS+C2OisWWoMBXdNkdNQa85aLkyrWwlA==", + "version": "6.1.48", + "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.48.tgz", + "integrity": "sha512-DfEGuLszDlllzx51WTABXB6LeMF46odcTWGUqG9rdTaRhiRlp+Ldkr1jiHugWBw/etwj71kr02rAUNt4cAet/w==", + "license": "MIT", "dependencies": { - "tldts-core": "^6.1.46" + "tldts-core": "^6.1.48" } }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "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 80ee8bb614e6..b1275ff6b8b5 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ }, "dependencies": { "@duckduckgo/autoconsent": "^10.15.0", - "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#13.1.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.16.0", - "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#5.1.0", + "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#14.0.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.19.0", + "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#7.0.2", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1724449523" } } diff --git a/privacy-dashboard/privacy-dashboard-impl/lint-baseline.xml b/privacy-dashboard/privacy-dashboard-impl/lint-baseline.xml index cf57a380497d..648af03c72d5 100644 --- a/privacy-dashboard/privacy-dashboard-impl/lint-baseline.xml +++ b/privacy-dashboard/privacy-dashboard-impl/lint-baseline.xml @@ -26,8 +26,8 @@ + errorLine1=" webBrokenSiteFormFeature.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" webBrokenSiteFormFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" webBrokenSiteFormFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" webBrokenSiteFormFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" webBrokenSiteFormFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> if (userChangedValues) finish() }, - onPrivacyProtectionsClicked = { newValue -> - viewModel.onPrivacyProtectionsClicked(newValue, dashboardOpenedFromCustomTab()) + onPrivacyProtectionsClicked = { payload -> + viewModel.onPrivacyProtectionsClicked(payload, dashboardOpenedFromCustomTab()) }, onUrlClicked = { payload -> viewModel.onUrlClicked(payload) 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 0e3605afd55b..df9070fc6212 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 @@ -24,8 +24,8 @@ import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.domain 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.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 @@ -45,6 +45,8 @@ import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel. 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.ScreenKind.BREAKAGE_FORM +import com.duckduckgo.privacy.dashboard.impl.ui.ScreenKind.PRIMARY_SCREEN import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener import com.squareup.moshi.Moshi @@ -224,11 +226,11 @@ class PrivacyDashboardHybridViewModel @Inject constructor( init { viewModelScope.launch { val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() - pixel.fire(PRIVACY_DASHBOARD_OPENED, pixelParams, type = COUNT) + pixel.fire(PRIVACY_DASHBOARD_OPENED, pixelParams, type = Count) pixel.fire( pixel = PRIVACY_DASHBOARD_FIRST_TIME_OPENED, parameters = mapOf("daysSinceInstall" to userBrowserProperties.daysSinceInstalled().toString(), "from_onboarding" to "false"), - type = UNIQUE, + type = Unique(), ) } privacyProtectionsPopupExperimentExternalPixels.tryReportPrivacyDashboardOpened() @@ -304,33 +306,49 @@ class PrivacyDashboardHybridViewModel @Inject constructor( } fun onPrivacyProtectionsClicked( - enabled: Boolean, + payload: String, dashboardOpenedFromCustomTab: Boolean = false, ) { - Timber.i("PrivacyDashboard: onPrivacyProtectionsClicked $enabled") + Timber.i("PrivacyDashboard: onPrivacyProtectionsClicked $payload") viewModelScope.launch(dispatcher.io()) { + val event = privacyDashboardPayloadAdapter.onPrivacyProtectionsClicked(payload) ?: return@launch + protectionsToggleUsageListener.onPrivacyProtectionsToggleUsed() delay(CLOSE_ON_PROTECTIONS_TOGGLE_DELAY) currentViewState().siteViewState.domain?.let { domain -> val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() - if (enabled) { + if (event.isProtected) { userAllowListRepository.removeDomainFromUserAllowList(domain) if (dashboardOpenedFromCustomTab) { - pixel.fire(CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_REMOVE) + if (event.eventOrigin.screen == PRIMARY_SCREEN) { + pixel.fire(CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_REMOVE) + } } else { - pixel.fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE, pixelParams, type = COUNT) + val pixelName = when (event.eventOrigin.screen) { + PRIMARY_SCREEN -> PRIVACY_DASHBOARD_ALLOWLIST_REMOVE + BREAKAGE_FORM -> BROKEN_SITE_ALLOWLIST_REMOVE + else -> null + } + pixelName?.let { pixel.fire(it, pixelParams, type = Count) } } } else { userAllowListRepository.addDomainToUserAllowList(domain) if (dashboardOpenedFromCustomTab) { - pixel.fire(CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_ADD) + if (event.eventOrigin.screen == PRIMARY_SCREEN) { + pixel.fire(CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_ADD) + } } else { - pixel.fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD, pixelParams, type = COUNT) + val pixelName = when (event.eventOrigin.screen) { + PRIMARY_SCREEN -> PRIVACY_DASHBOARD_ALLOWLIST_ADD + BREAKAGE_FORM -> BROKEN_SITE_ALLOWLIST_ADD + else -> null + } + pixelName?.let { pixel.fire(it, pixelParams, type = Count) } } } - privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromPrivacyDashboard(enabled) + privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromPrivacyDashboard(event.isProtected) } } } 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 d6aba58f9646..c62a1c6579b3 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 @@ -20,15 +20,15 @@ import android.webkit.JavascriptInterface class PrivacyDashboardJavascriptInterface constructor( val onBrokenSiteClicked: () -> Unit, - val onPrivacyProtectionsClicked: (Boolean) -> Unit, + val onPrivacyProtectionsClicked: (String) -> Unit, val onUrlClicked: (String) -> Unit, val onOpenSettings: (String) -> Unit, val onClose: () -> Unit, val onSubmitBrokenSiteReport: (String) -> Unit, ) { @JavascriptInterface - fun toggleAllowlist(newValue: String) { - onPrivacyProtectionsClicked(newValue.toBoolean()) + fun toggleAllowlist(payload: String) { + onPrivacyProtectionsClicked(payload) } @JavascriptInterface 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 41cdf2a3597e..904f3a85b5be 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 @@ -18,6 +18,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.duckduckgo.privacy.dashboard.impl.ui.AppPrivacyDashboardPayloadAdapter.PrivacyProtectionsClicked import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi import javax.inject.Inject @@ -27,6 +28,7 @@ interface PrivacyDashboardPayloadAdapter { fun onUrlClicked(payload: String): String fun onOpenSettings(payload: String): String fun onSubmitBrokenSiteReport(payload: String): BreakageReportRequest? + fun onPrivacyProtectionsClicked(payload: String): PrivacyProtectionsClicked? } @ContributesBinding(AppScope::class) @@ -47,6 +49,11 @@ class AppPrivacyDashboardPayloadAdapter @Inject constructor( return kotlin.runCatching { payloadAdapter.fromJson(payload) }.getOrNull() } + override fun onPrivacyProtectionsClicked(payload: String): PrivacyProtectionsClicked? { + val payloadAdapter = moshi.adapter(PrivacyProtectionsClicked::class.java) + return kotlin.runCatching { payloadAdapter.fromJson(payload) }.getOrNull() + } + data class Payload(val url: String) data class SettingsPayload(val target: String) @@ -54,4 +61,9 @@ class AppPrivacyDashboardPayloadAdapter @Inject constructor( val category: String, val description: String, ) + + data class PrivacyProtectionsClicked( + val isProtected: Boolean, + val eventOrigin: EventOrigin, + ) } 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 2f36edbd1233..541436ea2612 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 @@ -32,7 +32,7 @@ class PrivacyDashboardRenderer( private val onPrivacyProtectionSettingChanged: (Boolean) -> Unit, private val moshi: Moshi, private val onBrokenSiteClicked: () -> Unit, - private val onPrivacyProtectionsClicked: (Boolean) -> Unit, + private val onPrivacyProtectionsClicked: (String) -> Unit, private val onUrlClicked: (String) -> Unit, private val onOpenSettings: (String) -> Unit, private val onClose: () -> Unit, @@ -45,8 +45,8 @@ class PrivacyDashboardRenderer( webView.addJavascriptInterface( PrivacyDashboardJavascriptInterface( onBrokenSiteClicked = { onBrokenSiteClicked() }, - onPrivacyProtectionsClicked = { newValue -> - onPrivacyProtectionsClicked(newValue) + onPrivacyProtectionsClicked = { payload -> + onPrivacyProtectionsClicked(payload) }, onUrlClicked = { onUrlClicked(it) 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 b6fb594dd3eb..4f1e3cadb5f0 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 @@ -33,7 +33,7 @@ sealed class RendererViewHolder { val holder: WebView, val onPrivacyProtectionSettingChanged: (Boolean) -> Unit, val onBrokenSiteClicked: () -> Unit, - val onPrivacyProtectionsClicked: (Boolean) -> Unit, + val onPrivacyProtectionsClicked: (String) -> Unit, val onUrlClicked: (String) -> Unit, val onOpenSettings: (String) -> Unit, val onClose: () -> Unit, diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/ScreenKind.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/ScreenKind.kt new file mode 100644 index 000000000000..b9732e5989aa --- /dev/null +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/ScreenKind.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.privacy.dashboard.impl.ui + +/** + * Based on https://duckduckgo.github.io/privacy-dashboard/types/Generated_Schema_Definitions.ScreenKind.html + */ +enum class ScreenKind(val value: String) { + PRIMARY_SCREEN("primaryScreen"), + BREAKAGE_FORM("breakageForm"), + PROMPT_BREAKAGE_FORM("promptBreakageForm"), + TOGGLE_REPORT("toggleReport"), + CATEGORY_TYPE_SELECTION("categoryTypeSelection"), + CATEGORY_SELECTION("categorySelection"), + CHOICE_TOGGLE("choiceToggle"), + CHOICE_BREAKAGE_FORM("choiceBreakageForm"), + CONNECTION("connection"), + TRACKERS("trackers"), + NON_TRACKERS("nonTrackers"), + CONSENT_MANAGED("consentManaged"), + COOKIE_HIDDEN("cookieHidden"), + ; +} 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 9bf518794cbf..0a42401bc897 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 @@ -25,7 +25,7 @@ import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.domain 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.Count import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.brokensite.api.BrokenSite import com.duckduckgo.brokensite.api.BrokenSiteSender @@ -64,6 +64,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -116,7 +117,7 @@ class PrivacyDashboardHybridViewModelTest { @Test fun whenUserClicksOnReportBrokenSiteThenCommandEmitted() = runTest { - webBrokenSiteFormFeature.self().setEnabled(State(enable = false)) + webBrokenSiteFormFeature.self().setRawStoredState(State(enable = false)) testee.onReportBrokenSiteSelected() @@ -128,7 +129,7 @@ class PrivacyDashboardHybridViewModelTest { @Test fun whenUserClicksOnReportBrokenSiteAndWebFormEnabledThenCommandIsNotEmitted() = runTest { - webBrokenSiteFormFeature.self().setEnabled(State(enable = true)) + webBrokenSiteFormFeature.self().setRawStoredState(State(enable = true)) testee.onReportBrokenSiteSelected() @@ -151,7 +152,7 @@ class PrivacyDashboardHybridViewModelTest { @Test fun whenOnPrivacyProtectionClickedThenUpdateViewState() = runTest { testee.onSiteChanged(site(siteAllowed = false)) - testee.onPrivacyProtectionsClicked(enabled = false) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = false)) testee.viewState.test { awaitItem() @@ -170,7 +171,7 @@ class PrivacyDashboardHybridViewModelTest { userAllowListRepository.domainsInUserAllowListFlow() .test { assertFalse(site.domain in awaitItem()) - testee.onPrivacyProtectionsClicked(enabled = false) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = false)) assertTrue(site.domain in awaitItem()) } } @@ -192,7 +193,7 @@ class PrivacyDashboardHybridViewModelTest { val site = site(siteAllowed = false) testee.onSiteChanged(site) - testee.onPrivacyProtectionsClicked(enabled = false) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = false)) verify(privacyProtectionsToggleUsageListener).onPrivacyProtectionsToggleUsed() } @@ -203,15 +204,15 @@ class PrivacyDashboardHybridViewModelTest { whenever(privacyProtectionsPopupExperimentExternalPixels.getPixelParams()).thenReturn(params) val site = site(siteAllowed = false) testee.onSiteChanged(site) - testee.onPrivacyProtectionsClicked(enabled = false) - testee.onPrivacyProtectionsClicked(enabled = true) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = false)) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = true)) coroutineRule.testScope.advanceUntilIdle() - verify(pixel).fire(PRIVACY_DASHBOARD_OPENED, params, type = COUNT) + verify(pixel).fire(PRIVACY_DASHBOARD_OPENED, params, type = Count) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportPrivacyDashboardOpened() - verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD, params, type = COUNT) + verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD, params, type = Count) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled = false) - verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE, params, type = COUNT) + verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE, params, type = Count) verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled = true) } @@ -219,7 +220,7 @@ class PrivacyDashboardHybridViewModelTest { fun whenOnPrivacyProtectionClickedAndProtectionsEnabledAndOpenedFromCustomTabThenFireCustomTabSpecificPixel() = runTest { val site = site(siteAllowed = false) testee.onSiteChanged(site) - testee.onPrivacyProtectionsClicked(enabled = true, dashboardOpenedFromCustomTab = true) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = true), dashboardOpenedFromCustomTab = true) coroutineRule.testScope.advanceUntilIdle() verify(pixel).fire(PrivacyDashboardCustomTabPixelNames.CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_REMOVE) } @@ -228,14 +229,14 @@ class PrivacyDashboardHybridViewModelTest { fun whenOnPrivacyProtectionClickedAndProtectionsDisabledAndOpenedFromCustomTabThenFireCustomTabSpecificPixel() = runTest { val site = site(siteAllowed = false) testee.onSiteChanged(site) - testee.onPrivacyProtectionsClicked(enabled = false, dashboardOpenedFromCustomTab = true) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = false), dashboardOpenedFromCustomTab = true) coroutineRule.testScope.advanceUntilIdle() verify(pixel).fire(PrivacyDashboardCustomTabPixelNames.CUSTOM_TABS_PRIVACY_DASHBOARD_ALLOW_LIST_ADD) } @Test fun whenUserClicksOnSubmitReportThenSubmitsReport() = runTest { - webBrokenSiteFormFeature.self().setEnabled(State(enable = true)) + webBrokenSiteFormFeature.self().setRawStoredState(State(enable = true)) val siteUrl = "https://example.com" val userRefreshCount = 2 @@ -292,7 +293,7 @@ class PrivacyDashboardHybridViewModelTest { @Test fun whenUserClicksOnSubmitReportAndSiteUrlIsEmptyThenDoesNotSubmitReport() = runTest { - webBrokenSiteFormFeature.self().setEnabled(State(enable = true)) + webBrokenSiteFormFeature.self().setRawStoredState(State(enable = true)) testee.onSiteChanged(site(url = "")) @@ -308,7 +309,7 @@ class PrivacyDashboardHybridViewModelTest { @Test fun whenUserClicksOnSubmitReportThenCommandIsSent() = runTest { - webBrokenSiteFormFeature.self().setEnabled(State(enable = true)) + webBrokenSiteFormFeature.self().setRawStoredState(State(enable = true)) testee.onSiteChanged(site()) @@ -324,6 +325,42 @@ class PrivacyDashboardHybridViewModelTest { } } + @Test + fun whenPrivacyProtectionsDisabledOnBrokenSiteScreenThenPixelIsSent() = runTest { + testee.onSiteChanged(site(siteAllowed = false)) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = false, screen = "breakageForm")) + advanceUntilIdle() + verify(pixel).fire(BROKEN_SITE_ALLOWLIST_ADD) + verify(pixel, never()).fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD) + } + + @Test + fun whenPrivacyProtectionsEnabledOnBrokenSiteScreenThenPixelIsSent() = runTest { + testee.onSiteChanged(site(siteAllowed = false)) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = true, screen = "breakageForm")) + advanceUntilIdle() + verify(pixel).fire(BROKEN_SITE_ALLOWLIST_REMOVE) + verify(pixel, never()).fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE) + } + + @Test + fun whenPrivacyProtectionsDisabledOnPrimaryScreenThenPixelIsSent() = runTest { + testee.onSiteChanged(site(siteAllowed = false)) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = false, screen = "primaryScreen")) + advanceUntilIdle() + verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD) + verify(pixel, never()).fire(BROKEN_SITE_ALLOWLIST_ADD) + } + + @Test + fun whenPrivacyProtectionsEnabledOnPrimaryScreenThenPixelIsSent() = runTest { + testee.onSiteChanged(site(siteAllowed = false)) + testee.onPrivacyProtectionsClicked(privacyProtectionsClickedPayload(isProtected = true, screen = "primaryScreen")) + advanceUntilIdle() + verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE) + verify(pixel, never()).fire(BROKEN_SITE_ALLOWLIST_REMOVE) + } + private fun site( url: String = "https://example.com", siteAllowed: Boolean = false, @@ -335,6 +372,11 @@ class PrivacyDashboardHybridViewModelTest { whenever(site.realBrokenSiteContext).thenReturn(mock()) return site } + + private fun privacyProtectionsClickedPayload( + isProtected: Boolean, + screen: String = "primaryScreen", + ): String = """{"isProtected":$isProtected,"eventOrigin":{"screen":"$screen"}}""" } private class FakeUserAllowListRepository : UserAllowListRepository { diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt index b0587ec29fcf..cb2f088c9972 100644 --- a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt @@ -42,8 +42,10 @@ interface PrivacyProtectionsPopupManager { * * This function should be called whenever the user triggers page refresh, * either by the pull-to-refresh gesture or the button in the menu. + * + * @param isOmnibarAtTop The position of the omnibar can be at the top or bottom. */ - fun onPageRefreshTriggeredByUser() + fun onPageRefreshTriggeredByUser(isOmnibarAtTheTop: Boolean) /** * Handles the event of a page being fully loaded. diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt index 1ecbfca4f094..7152a551f765 100644 --- a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt @@ -27,6 +27,10 @@ sealed class PrivacyProtectionsPopupViewState { * Indicates whether the popup should show the "Don't show again" button. */ val doNotShowAgainOptionAvailable: Boolean, + /** + * Indicates whether the the position of the omnibar is at the top. + */ + val isOmnibarAtTheTop: Boolean, ) : PrivacyProtectionsPopupViewState() data object Gone : PrivacyProtectionsPopupViewState() diff --git a/privacy-protections-popup/privacy-protections-popup-impl/lint-baseline.xml b/privacy-protections-popup/privacy-protections-popup-impl/lint-baseline.xml index 006929f43934..eff14f6bfc64 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/lint-baseline.xml +++ b/privacy-protections-popup/privacy-protections-popup-impl/lint-baseline.xml @@ -4,8 +4,8 @@ + errorLine1=" featureFlag.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" featureFlag.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" featureFlag.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt index 6e058ce26d0f..45249bc0d255 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt @@ -18,17 +18,22 @@ package com.duckduckgo.privacyprotectionspopup.impl import android.content.Context import android.graphics.Point +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.MarginLayoutParams import android.widget.Button +import android.widget.FrameLayout import android.widget.PopupWindow +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.doOnDetach import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative +import com.duckduckgo.common.ui.view.shape.DaxBubbleCardView.EdgePosition.LEFT import com.duckduckgo.common.ui.view.shape.DaxBubbleEdgeTreatment +import com.duckduckgo.common.ui.view.text.DaxTextView import com.duckduckgo.common.ui.view.toPx import com.duckduckgo.mobile.android.R import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopup @@ -43,6 +48,7 @@ import com.duckduckgo.privacyprotectionspopup.impl.R.* import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupButtonsHorizontalBinding import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupButtonsVerticalBinding import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupPrivacyDashboardBinding +import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupPrivacyDashboardBottomBinding import com.google.android.material.shape.ShapeAppearanceModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -89,7 +95,7 @@ class PrivacyProtectionsPopupImpl( } private fun showPopup(viewState: PrivacyProtectionsPopupViewState.Visible) = anchor.doOnLayout { - val popupContent = createPopupContentView(viewState.doNotShowAgainOptionAvailable) + val popupContent = createPopupContentView(viewState.doNotShowAgainOptionAvailable, viewState.isOmnibarAtTheTop) val popupWindowSpec = createPopupWindowSpec(popupContent = popupContent.root) popupWindowSpec.overrideContentPaddingStartPx?.let { contentPaddingStartPx -> @@ -122,7 +128,11 @@ class PrivacyProtectionsPopupImpl( _events.tryEmit(DISMISSED) popupWindow = null } - showAsDropDown(anchor, popupWindowSpec.horizontalOffsetPx, popupWindowSpec.verticalOffsetPx) + if (viewState.isOmnibarAtTheTop) { + showAsDropDown(anchor, popupWindowSpec.horizontalOffsetPx, popupWindowSpec.verticalOffsetPx) + } else { + showAtLocation(anchor, Gravity.BOTTOM, popupWindowSpec.horizontalOffsetPx, popupWindowSpec.verticalOffsetPx) + } } anchor.doOnDetach { dismissPopup() } @@ -134,10 +144,17 @@ class PrivacyProtectionsPopupImpl( popupWindow = null } - private fun createPopupContentView(doNotShowAgainAvailable: Boolean): PopupViewHolder { - val popupContent = PopupPrivacyDashboardBinding.inflate(LayoutInflater.from(context)) - val buttonsViewHolder = inflateButtons(popupContent, doNotShowAgainAvailable) - adjustBodyTextToAvailableWidth(popupContent) + private fun createPopupContentView(doNotShowAgainAvailable: Boolean, isOmnibarAtTheTop: Boolean): PopupViewHolder { + return when (isOmnibarAtTheTop) { + true -> createPopupContentViewTop(doNotShowAgainAvailable) + false -> createPopupContentViewBottom(doNotShowAgainAvailable) + } + } + + private fun createPopupContentViewBottom(doNotShowAgainAvailable: Boolean): PopupViewHolder { + val popupContent = PopupPrivacyDashboardBottomBinding.inflate(LayoutInflater.from(context)) + val buttonsViewHolder = inflateButtons(popupContent.cardViewContent, popupContent.buttonsContainer, doNotShowAgainAvailable) + adjustBodyTextToAvailableWidth(popupContent.cardViewContent, popupContent.bodyText) // Override CardView's default elevation with popup/dialog elevation popupContent.cardView.cardElevation = POPUP_DEFAULT_ELEVATION_DP.toPx() @@ -145,11 +162,35 @@ class PrivacyProtectionsPopupImpl( val cornerRadius = context.resources.getDimension(R.dimen.mediumShapeCornerRadius) val cornerSize = context.resources.getDimension(R.dimen.daxBubbleDialogEdge) val distanceFromEdge = EDGE_TREATMENT_DISTANCE_FROM_EDGE.toPx() - POPUP_HORIZONTAL_OFFSET_DP.toPx() - val edgeTreatment = DaxBubbleEdgeTreatment(cornerSize, distanceFromEdge) + popupContent.cardView.shapeAppearanceModel = ShapeAppearanceModel.builder() + .setAllCornerSizes(cornerRadius) + .setBottomEdge(DaxBubbleEdgeTreatment(cornerSize, distanceFromEdge, LEFT)) + .build() + + popupContent.shieldIconHighlight.startAnimation(buildShieldIconHighlightAnimation()) + + return PopupViewHolder( + root = popupContent.root, + anchorOverlay = popupContent.anchorOverlay, + omnibarOverlay = popupContent.omnibarOverlay, + buttons = buttonsViewHolder, + ) + } + + private fun createPopupContentViewTop(doNotShowAgainAvailable: Boolean): PopupViewHolder { + val popupContent = PopupPrivacyDashboardBinding.inflate(LayoutInflater.from(context)) + val buttonsViewHolder = inflateButtons(popupContent.cardViewContent, popupContent.buttonsContainer, doNotShowAgainAvailable) + adjustBodyTextToAvailableWidth(popupContent.cardViewContent, popupContent.bodyText) + // Override CardView's default elevation with popup/dialog elevation + popupContent.cardView.cardElevation = POPUP_DEFAULT_ELEVATION_DP.toPx() + + val cornerRadius = context.resources.getDimension(R.dimen.mediumShapeCornerRadius) + val cornerSize = context.resources.getDimension(R.dimen.daxBubbleDialogEdge) + val distanceFromEdge = EDGE_TREATMENT_DISTANCE_FROM_EDGE.toPx() - POPUP_HORIZONTAL_OFFSET_DP.toPx() popupContent.cardView.shapeAppearanceModel = ShapeAppearanceModel.builder() .setAllCornerSizes(cornerRadius) - .setTopEdge(edgeTreatment) + .setTopEdge(DaxBubbleEdgeTreatment(cornerSize, distanceFromEdge)) .build() popupContent.shieldIconHighlight.startAnimation(buildShieldIconHighlightAnimation()) @@ -162,11 +203,15 @@ class PrivacyProtectionsPopupImpl( ) } - private fun inflateButtons(popupContent: PopupPrivacyDashboardBinding, doNotShowAgainAvailable: Boolean): PopupButtonsViewHolder { - val availableWidth = getAvailablePopupCardViewContentWidthPx(popupContent) + private fun inflateButtons( + cardViewContent: ConstraintLayout, + buttonsContainer: FrameLayout, + doNotShowAgainAvailable: Boolean, + ): PopupButtonsViewHolder { + val availableWidth = getAvailablePopupCardViewContentWidthPx(cardViewContent) val horizontalButtons = PopupButtonsHorizontalBinding - .inflate(LayoutInflater.from(context), popupContent.buttonsContainer, false) + .inflate(LayoutInflater.from(context), buttonsContainer, false) .apply { dontShowAgainButton.isVisible = doNotShowAgainAvailable dismissButton.isVisible = !doNotShowAgainAvailable @@ -177,7 +222,7 @@ class PrivacyProtectionsPopupImpl( .measuredWidth return if (horizontalButtonsWidth <= availableWidth) { - popupContent.buttonsContainer.addView(horizontalButtons.root) + buttonsContainer.addView(horizontalButtons.root) PopupButtonsViewHolder( dismiss = horizontalButtons.dismissButton, doNotShowAgain = horizontalButtons.dontShowAgainButton, @@ -185,12 +230,12 @@ class PrivacyProtectionsPopupImpl( ) } else { val verticalButtons = PopupButtonsVerticalBinding - .inflate(LayoutInflater.from(context), popupContent.buttonsContainer, true) + .inflate(LayoutInflater.from(context), buttonsContainer, true) .apply { dontShowAgainButton.isVisible = doNotShowAgainAvailable dismissButton.isVisible = !doNotShowAgainAvailable } - popupContent.buttonsContainer.layoutParams = popupContent.buttonsContainer.layoutParams.apply { width = 0 } + buttonsContainer.layoutParams = buttonsContainer.layoutParams.apply { width = 0 } PopupButtonsViewHolder( dismiss = verticalButtons.dismissButton, doNotShowAgain = verticalButtons.dontShowAgainButton, @@ -199,16 +244,19 @@ class PrivacyProtectionsPopupImpl( } } - private fun adjustBodyTextToAvailableWidth(popupContent: PopupPrivacyDashboardBinding) { - val availableWidth = getAvailablePopupCardViewContentWidthPx(popupContent) + private fun adjustBodyTextToAvailableWidth( + cardViewContent: ConstraintLayout, + bodyText: DaxTextView, + ) { + val availableWidth = getAvailablePopupCardViewContentWidthPx(cardViewContent) val defaultText = context.getString(string.privacy_protections_popup_body) val shortText = context.getString(string.privacy_protections_popup_body_short) - popupContent.bodyText.post { - val textPaint = popupContent.bodyText.paint + bodyText.post { + val textPaint = bodyText.paint - popupContent.bodyText.text = when { + bodyText.text = when { textPaint.measureText(defaultText) <= availableWidth -> defaultText textPaint.measureText(shortText) <= availableWidth -> shortText else -> defaultText // No need to use the shorter text if it wraps anyway @@ -258,9 +306,9 @@ class PrivacyProtectionsPopupImpl( ) } - private fun getAvailablePopupCardViewContentWidthPx(popupContent: PopupPrivacyDashboardBinding): Int { + private fun getAvailablePopupCardViewContentWidthPx(cardViewContent: ConstraintLayout): Int { val popupExternalMarginsWidth = 2 * anchor.locationInWindow.x + POPUP_HORIZONTAL_OFFSET_DP.toPx() - val popupInternalPaddingWidth = popupContent.cardViewContent.paddingStart + popupContent.cardViewContent.paddingEnd + val popupInternalPaddingWidth = cardViewContent.paddingStart + cardViewContent.paddingEnd return context.screenWidth - popupExternalMarginsWidth - popupInternalPaddingWidth } diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt index 9a37a3c92f1c..2528e6519957 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt @@ -130,7 +130,7 @@ class PrivacyProtectionsPopupManagerImpl @Inject constructor( } } - override fun onPageRefreshTriggeredByUser() { + override fun onPageRefreshTriggeredByUser(isOmnibarAtTheTop: Boolean) { var popupTriggered = false var experimentVariantToStore: PrivacyProtectionsPopupExperimentVariant? = null @@ -154,7 +154,10 @@ class PrivacyProtectionsPopupManagerImpl @Inject constructor( oldState.copy( viewState = if (shouldShowPopup) { - PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = oldState.popupData.popupTriggerCount > 0) + PrivacyProtectionsPopupViewState.Visible( + doNotShowAgainOptionAvailable = oldState.popupData.popupTriggerCount > 0, + isOmnibarAtTheTop = isOmnibarAtTheTop, + ) } else { PrivacyProtectionsPopupViewState.Gone }, diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard_bottom.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard_bottom.xml new file mode 100644 index 000000000000..3a83573f8b2e --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard_bottom.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt index dc2de8b3d4c2..87c4f6cc2ba9 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt @@ -106,7 +106,7 @@ class PrivacyProtectionsPopupManagerImplTest { @Before fun setup() { - featureFlag.self().setEnabled(State(enable = true)) + featureFlag.self().setRawStoredState(State(enable = true)) } @Test @@ -118,7 +118,7 @@ class PrivacyProtectionsPopupManagerImplTest { assertEquals(PrivacyProtectionsPopupViewState.Gone, awaitItem()) subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) expectNoEvents() - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertTrue(awaitItem() is PrivacyProtectionsPopupViewState.Visible) expectNoEvents() } @@ -128,7 +128,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenRefreshIsTriggeredThenPopupIsShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) } @@ -138,7 +138,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenUrlIsDuckDuckGoThenPopupIsNotShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://duckduckgo.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -146,10 +146,10 @@ class PrivacyProtectionsPopupManagerImplTest { @Test fun whenFeatureIsDisabledThenPopupIsNotShown() = runTest { - featureFlag.self().setEnabled(State(enable = false)) + featureFlag.self().setRawStoredState(State(enable = false)) subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -161,7 +161,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -171,7 +171,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenUrlIsMissingThenPopupIsNotShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -181,7 +181,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageLoadedWithHttpErrorThenPopupIsNotShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = listOf(500), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -191,7 +191,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageLoadedWithBrowserErrorThenPopupIsNotShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = true) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -201,7 +201,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageIsChangedThenPopupIsNotDismissed() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -215,7 +215,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDismissEventIsHandledThenViewStateIsUpdated() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -229,7 +229,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDismissButtonClickedEventIsHandledThenPopupIsDismissed() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -245,7 +245,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { assertEquals(PrivacyProtectionsPopupViewState.Gone, awaitItem()) subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertTrue(awaitItem() is PrivacyProtectionsPopupViewState.Visible) @@ -260,7 +260,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDisableProtectionsClickedEventIsHandledThenDomainIsAddedToUserAllowlist() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertFalse(userAllowListRepository.isUrlInUserAllowList("https://www.example.com")) assertPopupVisible(visible = true) @@ -275,11 +275,11 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupWasDismissedRecentlyForTheSameDomainThenItWontBeShownOnRefresh() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) subject.onUiEvent(DISMISSED) assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) @@ -291,12 +291,12 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { timeProvider.time = Instant.parse("2023-11-29T10:15:30.000Z") subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) subject.onUiEvent(DISMISSED) assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) timeProvider.time += Duration.ofDays(2) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) } @@ -306,21 +306,21 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupWasDismissedRecentlyThenItWontBeShownOnForTheSameDomainButWillBeForOtherDomains() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) subject.onUiEvent(DISMISSED) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DISMISSED) subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -334,7 +334,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { assertPopupVisible(visible = false) subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) protectionsEnabledFlow.emit(true) expectNoEvents() } @@ -347,7 +347,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) timeProvider.time += Duration.ofSeconds(5) protectionsEnabledFlow.emit(true) assertPopupVisible(visible = false) @@ -361,7 +361,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -374,7 +374,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) } @@ -384,7 +384,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageReloadsOnRefreshWithHttpErrorThenPopupIsNotDismissed() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -399,7 +399,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupIsShownThenTriggerCountIsIncremented() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) assertEquals(1, dataStore.getPopupTriggerCount()) @@ -409,7 +409,7 @@ class PrivacyProtectionsPopupManagerImplTest { assertPopupVisible(visible = false) subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) assertEquals(2, dataStore.getPopupTriggerCount()) @@ -422,9 +422,15 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) - assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = false), expectMostRecentItem()) + assertEquals( + PrivacyProtectionsPopupViewState.Visible( + doNotShowAgainOptionAvailable = false, + isOmnibarAtTheTop = true, + ), + expectMostRecentItem(), + ) } } @@ -434,9 +440,15 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) - assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = true), expectMostRecentItem()) + assertEquals( + PrivacyProtectionsPopupViewState.Visible( + doNotShowAgainOptionAvailable = true, + isOmnibarAtTheTop = true, + ), + expectMostRecentItem(), + ) } } @@ -446,9 +458,15 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) - assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = true), expectMostRecentItem()) + assertEquals( + PrivacyProtectionsPopupViewState.Visible( + doNotShowAgainOptionAvailable = true, + isOmnibarAtTheTop = true, + ), + expectMostRecentItem(), + ) subject.onUiEvent(DONT_SHOW_AGAIN_CLICKED) @@ -456,7 +474,7 @@ class PrivacyProtectionsPopupManagerImplTest { assertTrue(dataStore.getDoNotShowAgainClicked()) subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) expectNoEvents() } @@ -467,7 +485,7 @@ class PrivacyProtectionsPopupManagerImplTest { dataStore.setExperimentVariant(CONTROL) subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -480,7 +498,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) assertEquals(CONTROL, dataStore.getExperimentVariant()) @@ -498,7 +516,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) cancelAndIgnoreRemainingEvents() verify(pixels).reportExperimentVariantAssigned() @@ -511,7 +529,7 @@ class PrivacyProtectionsPopupManagerImplTest { dataStore.setExperimentVariant(TEST) subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -523,7 +541,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupIsTriggeredThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) verify(pixels).reportPopupTriggered() @@ -534,7 +552,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPrivacyProtectionsDisableButtonIsClickedThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DISABLE_PROTECTIONS_CLICKED) @@ -548,7 +566,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDismissButtonIsClickedThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DISMISS_CLICKED) @@ -562,7 +580,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupIsDismissedViaClickOutsideThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DISMISSED) @@ -576,7 +594,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDoNotShowAgainButtonIsClickedThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DONT_SHOW_AGAIN_CLICKED) @@ -590,7 +608,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPrivacyDashboardIsOpenedThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(PRIVACY_DASHBOARD_CLICKED) @@ -604,7 +622,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageIsRefreshedAndConditionsAreMetThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) verify(pixels).reportPageRefreshOnPossibleBreakage() cancelAndIgnoreRemainingEvents() @@ -613,10 +631,10 @@ class PrivacyProtectionsPopupManagerImplTest { @Test fun whenPageIsRefreshedAndFeatureIsDisabledAndThereIsNoExperimentVariantThenPixelIsNotSent() = runTest { - featureFlag.self().setEnabled(State(enable = false)) + featureFlag.self().setRawStoredState(State(enable = false)) subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) verify(pixels).reportPageRefreshOnPossibleBreakage() cancelAndIgnoreRemainingEvents() diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelNameTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelNameTest.kt index 3d4f1ebc9601..925471c15df5 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelNameTest.kt +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelNameTest.kt @@ -16,9 +16,9 @@ package com.duckduckgo.privacyprotectionspopup.impl -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -44,9 +44,9 @@ class PrivacyProtectionsPopupPixelNameTest( fun pixelNameSuffixShouldMatchPixelType() { val pixelName = pixel.pixelName val requiredSuffix = when (pixel.type) { - COUNT -> "_c" - DAILY -> "_d" - UNIQUE -> "_u" + is Count -> "_c" + is Daily -> "_d" + is Unique -> "_u" } assertTrue( "Pixel name should end with '$requiredSuffix': $pixelName", diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/pixels/RemoteMessagingPixels.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/pixels/RemoteMessagingPixels.kt index 9e64635236e4..9911dc25956b 100644 --- a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/pixels/RemoteMessagingPixels.kt +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/pixels/RemoteMessagingPixels.kt @@ -17,7 +17,7 @@ package com.duckduckgo.remote.messaging.impl.pixels import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.remote.messaging.api.RemoteMessage import com.squareup.anvil.annotations.ContributesBinding @@ -38,7 +38,7 @@ class RealRemoteMessagingPixels @Inject constructor( private val pixel: Pixel, ) : RemoteMessagingPixels { override fun fireRemoteMessageShownPixel(remoteMessage: RemoteMessage) { - pixel.fire(pixel = RemoteMessagingPixelName.REMOTE_MESSAGE_SHOWN_UNIQUE, parameters = remoteMessage.asPixelParams(), type = PixelType.UNIQUE) + pixel.fire(pixel = RemoteMessagingPixelName.REMOTE_MESSAGE_SHOWN_UNIQUE, parameters = remoteMessage.asPixelParams(), type = Unique()) pixel.fire(pixel = RemoteMessagingPixelName.REMOTE_MESSAGE_SHOWN, parameters = remoteMessage.asPixelParams()) } diff --git a/remote-messaging/remote-messaging-internal/lint-baseline.xml b/remote-messaging/remote-messaging-internal/lint-baseline.xml index 680052240567..00ef44b89cc1 100644 --- a/remote-messaging/remote-messaging-internal/lint-baseline.xml +++ b/remote-messaging/remote-messaging-internal/lint-baseline.xml @@ -4,8 +4,8 @@ + errorLine1=" rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = value))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> appCoroutineScope.launch(dispatcherProvider.io()) { - rmfInternalSettings.useStatingEndpoint().setEnabled(State(enable = value)) + rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = value)) } } diff --git a/remote-messaging/remote-messaging-internal/src/test/java/com/duckduckgo/remote/messaging/internal/feature/RmfStagingEnvInterceptorTest.kt b/remote-messaging/remote-messaging-internal/src/test/java/com/duckduckgo/remote/messaging/internal/feature/RmfStagingEnvInterceptorTest.kt index 8e11468793f1..13d7cb1aaf6a 100644 --- a/remote-messaging/remote-messaging-internal/src/test/java/com/duckduckgo/remote/messaging/internal/feature/RmfStagingEnvInterceptorTest.kt +++ b/remote-messaging/remote-messaging-internal/src/test/java/com/duckduckgo/remote/messaging/internal/feature/RmfStagingEnvInterceptorTest.kt @@ -14,7 +14,7 @@ class RmfStagingEnvInterceptorTest { @Test fun interceptEndpointWhenEnabled() { - rmfInternalSettings.useStatingEndpoint().setEnabled(State(enable = true)) + rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = true)) val chain = FakeChain(RMF_URL_V1) val response = interceptor.intercept(chain) @@ -29,7 +29,7 @@ class RmfStagingEnvInterceptorTest { @Test fun interceptNoopWhenDisabled() { - rmfInternalSettings.useStatingEndpoint().setEnabled(State(enable = false)) + rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = false)) val chain = FakeChain(RMF_URL_V1) val response = interceptor.intercept(chain) @@ -44,7 +44,7 @@ class RmfStagingEnvInterceptorTest { @Test fun interceptIgnoreUnknownEndpointWhenEnabled() { - rmfInternalSettings.useStatingEndpoint().setEnabled(State(enable = true)) + rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = true)) val chain = FakeChain(UNKNOWN_URL) val response = interceptor.intercept(chain) @@ -54,7 +54,7 @@ class RmfStagingEnvInterceptorTest { @Test fun interceptIgnoreUnknownEndpointWhenDisabled() { - rmfInternalSettings.useStatingEndpoint().setEnabled(State(enable = false)) + rmfInternalSettings.useStatingEndpoint().setRawStoredState(State(enable = false)) val chain = FakeChain(UNKNOWN_URL) val response = interceptor.intercept(chain) diff --git a/saved-sites/saved-sites-impl/lint-baseline.xml b/saved-sites/saved-sites-impl/lint-baseline.xml index 5fdbc9ef3aeb..fb4046364b73 100644 --- a/saved-sites/saved-sites-impl/lint-baseline.xml +++ b/saved-sites/saved-sites-impl/lint-baseline.xml @@ -4,8 +4,8 @@ + errorLine1=" setting.self().setRawStoredState(Toggle.State(true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(Toggle.State(false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enabled))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" setting.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> = emptyMap(), encodedParameters: Map = emptyMap(), - type: PixelType = COUNT, + type: PixelType = Count, ) /** @@ -134,7 +137,7 @@ interface Pixel { pixelName: String, parameters: Map = emptyMap(), encodedParameters: Map = emptyMap(), - type: PixelType = COUNT, + type: PixelType = Count, ) /** diff --git a/statistics/statistics-impl/lint-baseline.xml b/statistics/statistics-impl/lint-baseline.xml index ab9a4be8367a..6612c18157b4 100644 --- a/statistics/statistics-impl/lint-baseline.xml +++ b/statistics/statistics-impl/lint-baseline.xml @@ -30,7 +30,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/RxPixelSender.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/RxPixelSender.kt index ba4050fff1b8..ca11a47401df 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/RxPixelSender.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/RxPixelSender.kt @@ -21,6 +21,9 @@ import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.statistics.config.StatisticsLibraryConfig import com.duckduckgo.app.statistics.model.PixelEntity import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.statistics.store.PendingPixelDao import com.duckduckgo.app.statistics.store.PixelFiredRepository import com.duckduckgo.app.statistics.store.StatisticsDataStore @@ -166,9 +169,9 @@ class RxPixelSender @Inject constructor( type: Pixel.PixelType, ): Boolean = when (type) { - Pixel.PixelType.COUNT -> true - Pixel.PixelType.DAILY -> !pixelFiredRepository.hasDailyPixelFiredToday(pixelName) - Pixel.PixelType.UNIQUE -> !pixelFiredRepository.hasUniquePixelFired(pixelName) + is Count -> true + is Daily -> !pixelFiredRepository.hasDailyPixelFiredToday(type.tag ?: pixelName) + is Unique -> !pixelFiredRepository.hasUniquePixelFired(type.tag ?: pixelName) } private suspend fun storePixelFired( @@ -176,9 +179,9 @@ class RxPixelSender @Inject constructor( type: Pixel.PixelType, ) { when (type) { - Pixel.PixelType.COUNT -> {} // no-op - Pixel.PixelType.DAILY -> pixelFiredRepository.storeDailyPixelFiredToday(pixelName) - Pixel.PixelType.UNIQUE -> pixelFiredRepository.storeUniquePixelFired(pixelName) + is Count -> {} // no-op + is Daily -> pixelFiredRepository.storeDailyPixelFiredToday(type.tag ?: pixelName) + is Unique -> pixelFiredRepository.storeUniquePixelFired(type.tag ?: pixelName) } } } diff --git a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt index e7ea93b721a8..5b74af86f483 100644 --- a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt +++ b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt @@ -32,9 +32,9 @@ import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.model.PixelEntity import com.duckduckgo.app.statistics.model.QueryParamsTypeConverter import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.app.statistics.store.PendingPixelDao import com.duckduckgo.app.statistics.store.PixelFiredRepository import com.duckduckgo.app.statistics.store.StatisticsDataStore @@ -113,7 +113,7 @@ class RxPixelSenderTest { givenVariant("variant") givenFormFactor(DeviceInfo.FormFactor.PHONE) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), COUNT) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Count) .test().assertValue(PIXEL_SENT) verify(api).fire(eq("test"), eq("phone"), eq("atbvariant"), any(), any(), any()) @@ -124,7 +124,7 @@ class RxPixelSenderTest { givenApiSendPixelSucceeds() givenFormFactor(DeviceInfo.FormFactor.TABLET) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), COUNT) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Count) .test().assertValue(PIXEL_SENT) verify(api).fire(eq("test"), eq("tablet"), eq(""), any(), any(), any()) @@ -135,7 +135,7 @@ class RxPixelSenderTest { givenApiSendPixelSucceeds() givenFormFactor(DeviceInfo.FormFactor.PHONE) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), COUNT) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Count) .test().assertValue(PIXEL_SENT) verify(api).fire(eq("test"), eq("phone"), eq(""), any(), any(), any()) @@ -151,7 +151,7 @@ class RxPixelSenderTest { val params = mapOf("param1" to "value1", "param2" to "value2") val expectedParams = mapOf("param1" to "value1", "param2" to "value2", "appVersion" to "1.0.0") - testee.sendPixel(TEST.pixelName, params, emptyMap(), COUNT) + testee.sendPixel(TEST.pixelName, params, emptyMap(), Count) .test().assertValue(PIXEL_SENT) verify(api).fire("test", "phone", "atbvariant", expectedParams, emptyMap()) @@ -165,7 +165,7 @@ class RxPixelSenderTest { givenFormFactor(DeviceInfo.FormFactor.PHONE) givenAppVersion("1.0.0") - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), COUNT) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Count) .test().assertValue(PIXEL_SENT) val expectedParams = mapOf("appVersion" to "1.0.0") @@ -307,7 +307,7 @@ class RxPixelSenderTest { givenPixelApiSucceeds() givenFormFactor(DeviceInfo.FormFactor.PHONE) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), DAILY) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Daily()) .test().assertValue(PIXEL_SENT) verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) @@ -319,7 +319,7 @@ class RxPixelSenderTest { givenPixelApiFails() givenFormFactor(DeviceInfo.FormFactor.PHONE) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), DAILY) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Daily()) .test().assertError(RuntimeException::class.java) verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) @@ -330,19 +330,54 @@ class RxPixelSenderTest { fun whenDailyPixelHasAlreadyBeenFiredTodayThenItIsNotFiredAgain() = runTest { pixelFiredRepository.dailyPixelsFiredToday += TEST.pixelName - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), DAILY) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Daily()) .test().assertValue(PIXEL_IGNORED) verifyNoInteractions(api) assertTrue(TEST.pixelName in pixelFiredRepository.dailyPixelsFiredToday) } + @Test + fun whenDailyPixelIsFiredThenTagIsStored() = runTest { + givenPixelApiSucceeds() + givenFormFactor(DeviceInfo.FormFactor.PHONE) + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Daily("tag")) + .test().assertValue(PIXEL_SENT) + + verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) + assertTrue("tag" in pixelFiredRepository.dailyPixelsFiredToday) + } + + @Test + fun whenDailyPixelFireFailsThenTagIsNotStored() = runTest { + givenPixelApiFails() + givenFormFactor(DeviceInfo.FormFactor.PHONE) + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Daily("tag")) + .test().assertError(RuntimeException::class.java) + + verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) + assertFalse("tag" in pixelFiredRepository.dailyPixelsFiredToday) + } + + @Test + fun whenDailyPixelHasAlreadyBeenFiredAndUsesTagTodayThenItIsNotFiredAgain() = runTest { + pixelFiredRepository.dailyPixelsFiredToday += "tag" + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Daily("tag")) + .test().assertValue(PIXEL_IGNORED) + + verifyNoInteractions(api) + assertTrue("tag" in pixelFiredRepository.dailyPixelsFiredToday) + } + @Test fun whenUniquePixelIsFiredThenPixelNameIsStored() = runTest { givenPixelApiSucceeds() givenFormFactor(DeviceInfo.FormFactor.PHONE) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), UNIQUE) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Unique()) .test().assertValue(PIXEL_SENT) verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) @@ -354,7 +389,7 @@ class RxPixelSenderTest { givenPixelApiFails() givenFormFactor(DeviceInfo.FormFactor.PHONE) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), UNIQUE) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Unique()) .test().assertError(RuntimeException::class.java) verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) @@ -365,13 +400,48 @@ class RxPixelSenderTest { fun whenUniquePixelHasAlreadyBeenFiredThenItIsNotFiredAgain() = runTest { pixelFiredRepository.uniquePixelsFired += TEST.pixelName - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), UNIQUE) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Unique()) .test().assertValue(PIXEL_IGNORED) verifyNoInteractions(api) assertTrue(TEST.pixelName in pixelFiredRepository.uniquePixelsFired) } + @Test + fun whenUniquePixelIsFiredThenTagIsStored() = runTest { + givenPixelApiSucceeds() + givenFormFactor(DeviceInfo.FormFactor.PHONE) + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Unique("tag")) + .test().assertValue(PIXEL_SENT) + + verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) + assertTrue("tag" in pixelFiredRepository.uniquePixelsFired) + } + + @Test + fun whenUniquePixelFireFailsThenTagIsNotStored() = runTest { + givenPixelApiFails() + givenFormFactor(DeviceInfo.FormFactor.PHONE) + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Unique("tag")) + .test().assertError(RuntimeException::class.java) + + verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) + assertFalse("tag" in pixelFiredRepository.uniquePixelsFired) + } + + @Test + fun whenUniquePixelHasAlreadyBeenFiredAndUsesTagThenItIsNotFiredAgain() = runTest { + pixelFiredRepository.uniquePixelsFired += "tag" + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), Unique("tag")) + .test().assertValue(PIXEL_IGNORED) + + verifyNoInteractions(api) + assertTrue("tag" in pixelFiredRepository.uniquePixelsFired) + } + private fun assertPixelEntity( expectedEntity: PixelEntity, pixelEntity: PixelEntity, diff --git a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt index 8e92da95b36e..36740df0d9ed 100644 --- a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt +++ b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt @@ -18,7 +18,7 @@ package com.duckduckgo.app.statistics.pixels import com.duckduckgo.app.statistics.api.PixelSender import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult.PIXEL_SENT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.pixels.RxBasedPixelTest.TestPixels.TEST import com.duckduckgo.common.test.InstantSchedulersRule import io.reactivex.Completable @@ -48,7 +48,7 @@ class RxBasedPixelTest { val pixel = RxBasedPixel(mockPixelSender) pixel.fire(TEST) - verify(mockPixelSender).sendPixel("test", emptyMap(), emptyMap(), COUNT) + verify(mockPixelSender).sendPixel("test", emptyMap(), emptyMap(), Count) } @Test @@ -58,7 +58,7 @@ class RxBasedPixelTest { val pixel = RxBasedPixel(mockPixelSender) pixel.fire(TEST) - verify(mockPixelSender).sendPixel("test", emptyMap(), emptyMap(), COUNT) + verify(mockPixelSender).sendPixel("test", emptyMap(), emptyMap(), Count) } @Test @@ -69,7 +69,7 @@ class RxBasedPixelTest { val params = mapOf("param1" to "value1", "param2" to "value2") pixel.fire(TEST, params) - verify(mockPixelSender).sendPixel("test", params, emptyMap(), COUNT) + verify(mockPixelSender).sendPixel("test", params, emptyMap(), Count) } @Test diff --git a/subscriptions/subscriptions-impl/lint-baseline.xml b/subscriptions/subscriptions-impl/lint-baseline.xml index 39130f6ef6d7..349cd706e4ea 100644 --- a/subscriptions/subscriptions-impl/lint-baseline.xml +++ b/subscriptions/subscriptions-impl/lint-baseline.xml @@ -4,8 +4,8 @@ + errorLine1=" privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> = types.associateWith { type -> if (withSuffix) "${baseName}_${type.pixelNameSuffix}" else baseName } @@ -178,7 +177,7 @@ enum class SubscriptionPixel( internal val PixelType.pixelNameSuffix: String get() = when (this) { - COUNT -> "c" - DAILY -> "d" - UNIQUE -> "u" + is Count -> "c" + is Daily -> "d" + is Unique -> "u" } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameters.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameters.kt index 02eddd77fed0..f66883889185 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameters.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameters.kt @@ -83,7 +83,9 @@ class PproDaysUntilExpirySurveyParameterPlugin @Inject constructor( class PproStatusParameterPlugin @Inject constructor( private val subscriptionsManager: SubscriptionsManager, ) : SurveyParameterPlugin { + private val invalidCharRegex = Regex("[ -]") override val surveyParamKey: String = "ppro_status" - override suspend fun evaluate(): String = subscriptionsManager.getSubscription()?.status?.statusName?.lowercase() ?: "" + override suspend fun evaluate(): String = + subscriptionsManager.getSubscription()?.status?.statusName?.lowercase()?.replace(invalidCharRegex, "_") ?: "" } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/RealPrivacyProUnifiedFeedbackTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/RealPrivacyProUnifiedFeedbackTest.kt index 3d4df9186326..ecf3c50e6985 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/RealPrivacyProUnifiedFeedbackTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/RealPrivacyProUnifiedFeedbackTest.kt @@ -47,46 +47,46 @@ class RealPrivacyProUnifiedFeedbackTest { @Test fun whenFeatureEnabledAndSourceIsPproThenShouldUseUnifiedFeedbackTrue() = runTest { - privacyProFeature.useUnifiedFeedback().setEnabled(Toggle.State(enable = true)) + privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true)) assertTrue(testee.shouldUseUnifiedFeedback(SUBSCRIPTION_SETTINGS)) } @Test fun whenFeatureEnabledAndSourceIsVPNThenShouldUseUnifiedFeedbackTrue() = runTest { - privacyProFeature.useUnifiedFeedback().setEnabled(Toggle.State(enable = true)) + privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true)) assertTrue(testee.shouldUseUnifiedFeedback(VPN_MANAGEMENT)) } @Test fun whenFeatureEnabledAndSourceIsVPNExclusionThenShouldUseUnifiedFeedbackTrue() = runTest { - privacyProFeature.useUnifiedFeedback().setEnabled(Toggle.State(enable = true)) + privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true)) assertTrue(testee.shouldUseUnifiedFeedback(VPN_EXCLUDED_APPS)) } @Test fun whenFeatureEnabledWithActiveSubsAndSourceIsSettingsThenShouldUseUnifiedFeedbackFalse() = runTest { - privacyProFeature.useUnifiedFeedback().setEnabled(Toggle.State(enable = true)) + privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true)) whenever(subscriptions.getSubscriptionStatus()).thenReturn(AUTO_RENEWABLE) assertTrue(testee.shouldUseUnifiedFeedback(DDG_SETTINGS)) } @Test fun whenFeatureEnabledWithInActiveSubsAndSourceIsSettingsThenShouldUseUnifiedFeedbackFalse() = runTest { - privacyProFeature.useUnifiedFeedback().setEnabled(Toggle.State(enable = true)) + privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true)) whenever(subscriptions.getSubscriptionStatus()).thenReturn(INACTIVE) assertFalse(testee.shouldUseUnifiedFeedback(DDG_SETTINGS)) } @Test fun whenFeatureEnabledWithExpiredSubsAndSourceIsSettingsThenShouldUseUnifiedFeedbackFalse() = runTest { - privacyProFeature.useUnifiedFeedback().setEnabled(Toggle.State(enable = true)) + privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true)) whenever(subscriptions.getSubscriptionStatus()).thenReturn(EXPIRED) assertFalse(testee.shouldUseUnifiedFeedback(DDG_SETTINGS)) } @Test fun whenFeatureEnabledWithWaitingSubsAndSourceIsSettingsThenShouldUseUnifiedFeedbackFalse() = runTest { - privacyProFeature.useUnifiedFeedback().setEnabled(Toggle.State(enable = true)) + privacyProFeature.useUnifiedFeedback().setRawStoredState(Toggle.State(enable = true)) whenever(subscriptions.getSubscriptionStatus()).thenReturn(WAITING) assertFalse(testee.shouldUseUnifiedFeedback(DDG_SETTINGS)) } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelTest.kt index 175b3ea60e87..1c75bf4699b6 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelTest.kt @@ -1,8 +1,8 @@ package com.duckduckgo.subscriptions.impl.pixels -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.PURCHASE_SUCCESS_ORIGIN import org.junit.Assert.* import org.junit.Test @@ -27,9 +27,9 @@ class SubscriptionPixelTest( if (pixel == PURCHASE_SUCCESS_ORIGIN) return pixel.getPixelNames().forEach { (pixelType, pixelName) -> val expectedSuffix = when (pixelType) { - COUNT -> "_c" - DAILY -> "_d" - UNIQUE -> "_u" + is Count -> "_c" + is Daily -> "_d" + is Unique -> "_u" } assertTrue(pixelName.endsWith(expectedSuffix)) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt index a9cdecb4966c..0d44eb25e4f2 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt @@ -2,6 +2,9 @@ package com.duckduckgo.subscriptions.impl.survey import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE +import com.duckduckgo.subscriptions.api.SubscriptionStatus.GRACE_PERIOD +import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE +import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.impl.SubscriptionsConstants import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.repository.Subscription @@ -138,7 +141,34 @@ class PproSurveyParameterPluginTest { val plugin = PproStatusParameterPlugin(subscriptionsManager) - assertEquals("auto-renewable", plugin.evaluate()) + assertEquals("auto_renewable", plugin.evaluate()) + } + + @Test + fun whenSubscriptionIsInactiveThenStatusParamEvaluatesToSubscriptionData() = runTest { + whenever(subscriptionsManager.getSubscription()).thenReturn(testSubscription.copy(status = INACTIVE)) + + val plugin = PproStatusParameterPlugin(subscriptionsManager) + + assertEquals("inactive", plugin.evaluate()) + } + + @Test + fun whenSubscriptionIsNotAutoRenewableThenStatusParamEvaluatesToSubscriptionData() = runTest { + whenever(subscriptionsManager.getSubscription()).thenReturn(testSubscription.copy(status = NOT_AUTO_RENEWABLE)) + + val plugin = PproStatusParameterPlugin(subscriptionsManager) + + assertEquals("not_auto_renewable", plugin.evaluate()) + } + + @Test + fun whenSubscriptionIsGracePeriodThenStatusParamEvaluatesToSubscriptionData() = runTest { + whenever(subscriptionsManager.getSubscription()).thenReturn(testSubscription.copy(status = GRACE_PERIOD)) + + val plugin = PproStatusParameterPlugin(subscriptionsManager) + + assertEquals("grace_period", plugin.evaluate()) } @Test diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt index 178c65dc4699..6fba33ebefaf 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt @@ -196,7 +196,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenGetSubscriptionOptionsThenSendCommand() = runTest { - privacyProFeature.allowPurchase().setEnabled(Toggle.State(enable = true)) + privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = true)) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn( SubscriptionOffer( @@ -224,7 +224,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenGetSubscriptionsAndNoSubscriptionOfferThenSendCommandWithEmptyData() = runTest { - privacyProFeature.allowPurchase().setEnabled(Toggle.State(enable = true)) + privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = true)) viewModel.commands().test { viewModel.processJsCallbackMessage("test", "getSubscriptionOptions", "id", JSONObject("{}")) @@ -245,7 +245,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenGetSubscriptionsAndToggleOffThenSendCommandWithEmptyData() = runTest { - privacyProFeature.allowPurchase().setEnabled(Toggle.State(enable = false)) + privacyProFeature.allowPurchase().setRawStoredState(Toggle.State(enable = false)) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn( SubscriptionOffer( monthlyPlanId = "monthly", diff --git a/sync/sync-impl/lint-baseline.xml b/sync/sync-impl/lint-baseline.xml index fc926d351cdf..6e9665b4268b 100644 --- a/sync/sync-impl/lint-baseline.xml +++ b/sync/sync-impl/lint-baseline.xml @@ -158,8 +158,8 @@ + errorLine1=" syncPromotionFeature.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncPromotionFeature.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncPromotionFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncPromotionFeature.bookmarks().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncPromotionFeature.passwords().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.self().setRawStoredState(State(enable = true))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.self().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level0ShowSync().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level1AllowDataSyncing().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level1AllowDataSyncing().setRawStoredState(State(enable = true, minSupportedVersion = 2))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level1AllowDataSyncing().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level2AllowSetupFlows().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level2AllowSetupFlows().setRawStoredState(State(enable = true, minSupportedVersion = 2))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level2AllowSetupFlows().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level3AllowCreateAccount().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level3AllowCreateAccount().setRawStoredState(State(enable = true, minSupportedVersion = 2))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" syncFeature.level3AllowCreateAccount().setRawStoredState(State(enable = false))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> { pixel.fire( String.format(Locale.US, SYNC_OBJECT_LIMIT_EXCEEDED_DAILY.pixelName, feature.field), - type = Pixel.PixelType.DAILY, + type = Pixel.PixelType.Daily(), ) } API_CODE.CONTENT_TOO_LARGE.code -> { pixel.fire( String.format(Locale.US, SyncPixelName.SYNC_REQUEST_SIZE_LIMIT_EXCEEDED_DAILY.pixelName, feature.field), - type = Pixel.PixelType.DAILY, + type = Pixel.PixelType.Daily(), ) } API_CODE.VALIDATION_ERROR.code -> { pixel.fire( String.format(Locale.US, SyncPixelName.SYNC_VALIDATION_ERROR_DAILY.pixelName, feature.field), - type = Pixel.PixelType.DAILY, + type = Pixel.PixelType.Daily(), ) } API_CODE.TOO_MANY_REQUESTS_1.code, API_CODE.TOO_MANY_REQUESTS_2.code -> { pixel.fire( String.format(Locale.US, SyncPixelName.SYNC_TOO_MANY_REQUESTS_DAILY.pixelName, feature.field), - type = Pixel.PixelType.DAILY, + type = Pixel.PixelType.Daily(), ) } } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncRemoteFeatureToggleTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncRemoteFeatureToggleTest.kt index 347ded91a874..bd971e1c498e 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncRemoteFeatureToggleTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncRemoteFeatureToggleTest.kt @@ -59,13 +59,13 @@ class SyncRemoteFeatureToggleTest { @Before fun setup() { - syncFeature.self().setEnabled(State(enable = true)) + syncFeature.self().setRawStoredState(State(enable = true)) } @Test fun whenFeatureDisabledThenInternalBuildShowSyncTrue() { whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.INTERNAL) - syncFeature.self().setEnabled(State(enable = false)) + syncFeature.self().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertTrue(testee.showSync()) @@ -74,7 +74,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenFeatureDisabledThenShowSyncIsFalse() { whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.PLAY) - syncFeature.self().setEnabled(State(enable = false)) + syncFeature.self().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.showSync()) @@ -82,7 +82,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenShowSyncDisabledThenAllFeaturesDisabled() { - syncFeature.level0ShowSync().setEnabled(State(enable = false)) + syncFeature.level0ShowSync().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.allowDataSyncing()) @@ -92,7 +92,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowDataSyncingFalseThenAllowDataSyncingFalse() { - syncFeature.level1AllowDataSyncing().setEnabled(State(enable = false)) + syncFeature.level1AllowDataSyncing().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.allowDataSyncing()) @@ -101,7 +101,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowDataSyncEnabledButNotForThisVersionThenAllowDataSyncingOnNewerVersionTrue() { whenever(appBuildConfig.versionCode).thenReturn(1) - syncFeature.level1AllowDataSyncing().setEnabled(State(enable = true, minSupportedVersion = 2)) + syncFeature.level1AllowDataSyncing().setRawStoredState(State(enable = true, minSupportedVersion = 2)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.allowDataSyncing()) @@ -110,7 +110,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowDataSyncingFalseThenSetupFlowsAndCreateAccountDisabled() { - syncFeature.level1AllowDataSyncing().setEnabled(State(enable = false)) + syncFeature.level1AllowDataSyncing().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.allowSetupFlows()) @@ -119,7 +119,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowSetupFlowsFalseThenAllowDataSyncingEnabled() { - syncFeature.level2AllowSetupFlows().setEnabled(State(enable = false)) + syncFeature.level2AllowSetupFlows().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertTrue(testee.allowDataSyncing()) @@ -128,7 +128,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowSetupFlowsEnabledButNotForThisVersionThenAllowSetupFlowsOnNewerVersionTrue() { whenever(appBuildConfig.versionCode).thenReturn(1) - syncFeature.level2AllowSetupFlows().setEnabled(State(enable = true, minSupportedVersion = 2)) + syncFeature.level2AllowSetupFlows().setRawStoredState(State(enable = true, minSupportedVersion = 2)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.allowSetupFlows()) @@ -137,7 +137,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowSetupFlowsFalseThenSetupFlowsAndCreateAccountDisabled() { - syncFeature.level2AllowSetupFlows().setEnabled(State(enable = false)) + syncFeature.level2AllowSetupFlows().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.allowSetupFlows()) @@ -146,7 +146,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowCreateAccountFalseThenAllowCreateAccountFalse() { - syncFeature.level3AllowCreateAccount().setEnabled(State(enable = false)) + syncFeature.level3AllowCreateAccount().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.allowCreateAccount()) @@ -155,7 +155,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowCreateAccountTrueButNotForThisVersionThenAllowCreateAccountOnNewerVersionTrue() { whenever(appBuildConfig.versionCode).thenReturn(1) - syncFeature.level3AllowCreateAccount().setEnabled(State(enable = true, minSupportedVersion = 2)) + syncFeature.level3AllowCreateAccount().setRawStoredState(State(enable = true, minSupportedVersion = 2)) givenSyncRemoteFeatureToggle(syncFeature) assertFalse(testee.allowCreateAccount()) @@ -164,7 +164,7 @@ class SyncRemoteFeatureToggleTest { @Test fun whenAllowCreateAccountFalseThenDataSyncingAndSetupFlowsEnabled() { - syncFeature.level3AllowCreateAccount().setEnabled(State(enable = false)) + syncFeature.level3AllowCreateAccount().setRawStoredState(State(enable = false)) givenSyncRemoteFeatureToggle(syncFeature) assertTrue(testee.allowDataSyncing()) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/pixels/SyncPixelsTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/pixels/SyncPixelsTest.kt index b3097ee82867..9c922f13f187 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/pixels/SyncPixelsTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/pixels/SyncPixelsTest.kt @@ -123,21 +123,21 @@ class RealSyncPixelsTest { fun whenfireDailyApiErrorForObjectLimitExceededThenPixelSent() { testee.fireDailySyncApiErrorPixel(SyncableType.BOOKMARKS, Error(code = API_CODE.COUNT_LIMIT.code)) - verify(pixel).fire("m_sync_bookmarks_object_limit_exceeded_daily", emptyMap(), emptyMap(), type = Pixel.PixelType.DAILY) + verify(pixel).fire("m_sync_bookmarks_object_limit_exceeded_daily", emptyMap(), emptyMap(), type = Pixel.PixelType.Daily()) } @Test fun whenfireDailyApiErrorForRequestSizeLimitExceededThenPixelSent() { testee.fireDailySyncApiErrorPixel(SyncableType.BOOKMARKS, Error(code = API_CODE.CONTENT_TOO_LARGE.code)) - verify(pixel).fire("m_sync_bookmarks_request_size_limit_exceeded_daily", emptyMap(), emptyMap(), type = Pixel.PixelType.DAILY) + verify(pixel).fire("m_sync_bookmarks_request_size_limit_exceeded_daily", emptyMap(), emptyMap(), type = Pixel.PixelType.Daily()) } @Test fun whenfireDailyApiErrorForValidationErrorThenPixelSent() { testee.fireDailySyncApiErrorPixel(SyncableType.BOOKMARKS, Error(code = API_CODE.VALIDATION_ERROR.code)) - verify(pixel).fire("m_sync_bookmarks_validation_error_daily", emptyMap(), emptyMap(), type = Pixel.PixelType.DAILY) + verify(pixel).fire("m_sync_bookmarks_validation_error_daily", emptyMap(), emptyMap(), type = Pixel.PixelType.Daily()) } @Test @@ -145,7 +145,7 @@ class RealSyncPixelsTest { testee.fireDailySyncApiErrorPixel(SyncableType.BOOKMARKS, Error(code = API_CODE.TOO_MANY_REQUESTS_1.code)) testee.fireDailySyncApiErrorPixel(SyncableType.BOOKMARKS, Error(code = API_CODE.TOO_MANY_REQUESTS_2.code)) - verify(pixel, times(2)).fire("m_sync_bookmarks_too_many_requests_daily", emptyMap(), emptyMap(), type = Pixel.PixelType.DAILY) + verify(pixel, times(2)).fire("m_sync_bookmarks_too_many_requests_daily", emptyMap(), emptyMap(), type = Pixel.PixelType.Daily()) } private fun givenSomeDailyStats(): DailyStats { diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionsImplTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionsImplTest.kt index ee80142c1ee2..226afffab403 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionsImplTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionsImplTest.kt @@ -73,7 +73,7 @@ class SyncPromotionsImplTest { @Test fun whenCouldShowPasswordPromoButTopLevelPromoFlagDisabledThenCannotShowPromo() = runTest { - syncPromotionFeature.self().setEnabled(State(enable = false)) + syncPromotionFeature.self().setRawStoredState(State(enable = false)) assertFalse(testee.canShowPasswordsPromotion(savedPasswords = 5)) } @@ -85,7 +85,7 @@ class SyncPromotionsImplTest { @Test fun whenCouldShowBookmarkPromoButTopLevelPromoFlagDisabledThenCannotShowPromo() = runTest { - syncPromotionFeature.self().setEnabled(State(enable = false)) + syncPromotionFeature.self().setRawStoredState(State(enable = false)) assertFalse(testee.canShowBookmarksPromotion(savedBookmarks = 5)) } @@ -137,8 +137,8 @@ class SyncPromotionsImplTest { private fun configureAllTogglesEnabled() { configureSyncFeatureFlagState(state = true) - syncPromotionFeature.self().setEnabled(State(enable = true)) - syncPromotionFeature.bookmarks().setEnabled(State(enable = true)) - syncPromotionFeature.passwords().setEnabled(State(enable = true)) + syncPromotionFeature.self().setRawStoredState(State(enable = true)) + syncPromotionFeature.bookmarks().setRawStoredState(State(enable = true)) + syncPromotionFeature.passwords().setRawStoredState(State(enable = true)) } } diff --git a/voice-search/voice-search-impl/lint-baseline.xml b/voice-search/voice-search-impl/lint-baseline.xml index 110565e5a569..52ffd8721378 100644 --- a/voice-search/voice-search-impl/lint-baseline.xml +++ b/voice-search/voice-search-impl/lint-baseline.xml @@ -48,8 +48,8 @@ + errorLine1=" voiceSearchFeature.self().setRawStoredState(State(voiceSearchEnabled))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> , excludedLocales: Array) { - voiceSearchFeature.self().setEnabled(State(voiceSearchEnabled)) + voiceSearchFeature.self().setRawStoredState(State(voiceSearchEnabled)) whenever(voiceSearchFeatureRepository.minVersion).thenReturn(minSdk) whenever(voiceSearchFeatureRepository.manufacturerExceptions).thenReturn(CopyOnWriteArrayList(excludedManufacturers.map { Manufacturer(it) })) whenever(voiceSearchFeatureRepository.localeExceptions).thenReturn(CopyOnWriteArrayList(excludedLocales.map { Locale(it) }))