diff --git a/.cron.yml b/.cron.yml index 3e9ae015f6..c51e01f7e0 100644 --- a/.cron.yml +++ b/.cron.yml @@ -11,14 +11,6 @@ jobs: target-tasks-method: nightly when: - {hour: 5, minute: 0} - # This is a temporary hook in order to not overload Google Play. - # See bug 1628413 for more context. - - name: nightly-on-google-play - job: - type: decision-task - treeherder-symbol: Nd-gp - target-tasks-method: nightly-on-google-play - when: - {hour: 17, minute: 0} - name: fennec-production job: diff --git a/.github/workflows/build-contributor-pr.yml b/.github/workflows/build-contributor-pr.yml index f7f992f756..b780b9d848 100644 --- a/.github/workflows/build-contributor-pr.yml +++ b/.github/workflows/build-contributor-pr.yml @@ -107,7 +107,7 @@ jobs: run-ui: runs-on: macos-11 - if: github.event.pull_request.head.repo.full_name != github.repository && github.actor != 'MickeyMoz' + if: ${{ false }} # disable for now' timeout-minutes: 60 strategy: diff --git a/app/build.gradle b/app/build.gradle index fc09c6bb71..3973f7d5f6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,10 +1,11 @@ plugins { id "com.jetbrains.python.envs" version "0.0.26" + id "com.google.protobuf" version "0.8.17" } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-parcelize' apply plugin: 'jacoco' apply plugin: 'androidx.navigation.safeargs.kotlin' apply plugin: 'com.google.android.gms.oss-licenses-plugin' @@ -208,6 +209,7 @@ android { } beta { java.srcDirs = ['src/migration/java'] + manifest.srcFile "src/migration/AndroidManifest.xml" } release { java.srcDirs = ['src/migration/java'] @@ -244,6 +246,8 @@ android { packagingOptions { exclude 'META-INF/atomicfu.kotlin_module' + exclude 'META-INF/AL2.0' + exclude 'META-INF/LGPL2.1' } testOptions { @@ -254,7 +258,6 @@ android { // reserve more memory and also create a new process after every 80 test classes. This // is a band-aid solution and eventually we should try to find and fix the leaks // instead. :) - maxParallelForks = 2 forkEvery = 80 maxHeapSize = "3072m" minHeapSize = "1024m" @@ -270,19 +273,19 @@ android { } } -android.applicationVariants.all { variant -> - // ------------------------------------------------------------------------------------------------- // Set up kotlin-allopen plugin for writing tests // ------------------------------------------------------------------------------------------------- - boolean hasTest = gradle.startParameter.taskNames.find { it.contains("test") || it.contains("Test") } != null - if (hasTest) { - apply plugin: 'kotlin-allopen' - allOpen { - annotation("org.mozilla.fenix.utils.OpenClass") - } +boolean hasTest = gradle.startParameter.taskNames.find { it.contains("test") || it.contains("Test") } != null +if (hasTest) { + apply plugin: 'kotlin-allopen' + allOpen { + annotation("org.mozilla.fenix.utils.OpenClass") } +} + +android.applicationVariants.all { variant -> // ------------------------------------------------------------------------------------------------- // Generate version codes for builds @@ -424,10 +427,6 @@ android.applicationVariants.all { variant -> } } -androidExtensions { - experimental = true -} - // Generate Kotlin code for the Fenix Glean metrics. apply plugin: "org.mozilla.telemetry.glean-gradle-plugin" @@ -464,6 +463,9 @@ dependencies { implementation Deps.sentry + implementation Deps.mozilla_compose_awesomebar + + implementation Deps.mozilla_concept_awesomebar implementation Deps.mozilla_concept_base implementation Deps.mozilla_concept_engine implementation Deps.mozilla_concept_menu @@ -473,8 +475,6 @@ dependencies { implementation Deps.mozilla_concept_toolbar implementation Deps.mozilla_concept_tabstray - implementation Deps.mozilla_browser_awesomebar - implementation Deps.mozilla_feature_downloads implementation Deps.mozilla_browser_domains implementation Deps.mozilla_browser_icons implementation Deps.mozilla_browser_menu @@ -486,9 +486,7 @@ dependencies { implementation Deps.mozilla_browser_thumbnails implementation Deps.mozilla_browser_toolbar - implementation Deps.mozilla_support_extensions implementation Deps.mozilla_feature_addons - implementation Deps.mozilla_feature_accounts implementation Deps.mozilla_feature_app_links implementation Deps.mozilla_feature_autofill @@ -521,22 +519,23 @@ dependencies { implementation Deps.mozilla_feature_webcompat implementation Deps.mozilla_feature_webnotifications implementation Deps.mozilla_feature_webcompat_reporter + implementation Deps.mozilla_service_pocket implementation Deps.mozilla_service_digitalassetlinks implementation Deps.mozilla_service_sync_autofill implementation Deps.mozilla_service_sync_logins implementation Deps.mozilla_service_firefox_accounts - implementation Deps.mozilla_service_glean + implementation(Deps.mozilla_service_glean) implementation Deps.mozilla_service_location implementation Deps.mozilla_service_nimbus + implementation Deps.mozilla_support_extensions implementation Deps.mozilla_support_base implementation Deps.mozilla_support_images implementation Deps.mozilla_support_ktx implementation Deps.mozilla_support_rustlog implementation Deps.mozilla_support_utils implementation Deps.mozilla_support_locale - implementation Deps.mozilla_support_migration implementation Deps.mozilla_ui_colors @@ -563,21 +562,25 @@ dependencies { implementation Deps.androidx_navigation_fragment implementation Deps.androidx_navigation_ui implementation Deps.androidx_recyclerview + implementation Deps.androidx_lifecycle_common implementation Deps.androidx_lifecycle_livedata + implementation Deps.androidx_lifecycle_process implementation Deps.androidx_lifecycle_runtime implementation Deps.androidx_lifecycle_viewmodel implementation Deps.androidx_core implementation Deps.androidx_core_ktx implementation Deps.androidx_transition implementation Deps.androidx_work_ktx + implementation Deps.androidx_datastore + implementation Deps.protobuf_javalite implementation Deps.google_material androidTestImplementation Deps.uiautomator -// Removed pending AndroidX fixes androidTestImplementation "tools.fastlane:screengrab:2.0.0" // This Falcon version is added to maven central now required for Screengrab implementation 'com.jraska:falcon:2.2.0' -// androidTestImplementation "br.com.concretesolutions:kappuccino:1.2.1" + + androidTestImplementation Deps.androidx_compose_ui_test androidTestImplementation Deps.espresso_core, { exclude group: 'com.android.support', module: 'support-annotations' @@ -622,11 +625,30 @@ dependencies { // For the initial release of Glean 19, we require consumer applications to // depend on a separate library for unit tests. This will be removed in future releases. - testImplementation "org.mozilla.telemetry:glean-forUnitTests:${project.ext.glean_version}" + testImplementation "org.mozilla.telemetry:glean-native-forUnitTests:${project.ext.glean_version}" lintChecks project(":mozilla-lint-rules") } +protobuf { + protoc { + artifact = Deps.protobuf_compiler + } + + // Generates the java Protobuf-lite code for the Protobufs in this project. See + // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation + // for more information. + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option 'lite' + } + } + } + } +} + if (project.hasProperty("coverage")) { tasks.withType(Test).configureEach { jacoco.includeNoLocationClasses = true diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 7c62294db7..b879264963 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,19 +1,5 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + @@ -645,7 +686,7 @@ errorLine2=" ~~~~"> @@ -656,7 +697,7 @@ errorLine2=" ~~~~"> @@ -667,7 +708,7 @@ errorLine2=" ~~~~"> @@ -678,7 +719,7 @@ errorLine2=" ~~~~"> @@ -689,7 +730,7 @@ errorLine2=" ~~~~"> @@ -700,7 +741,7 @@ errorLine2=" ~~~~"> @@ -711,21 +752,10 @@ errorLine2=" ~~~~"> - - - - + + + + + + + + + + + + + + + + @@ -810,7 +884,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -821,7 +895,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -832,7 +906,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -848,14 +922,14 @@ + id="Typos" + message="Repeated word "zaman" in message: possible typo" + errorLine1=" <string name="studies_description">Firefox zaman zaman araştırmalar yükleyip çalıştırabilir.</string>" + errorLine2=" ^"> + file="src/main/res/values-tr/strings.xml" + line="637" + column="48"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + message=""search_shortcuts_button" is translated here but not found in default locale" + errorLine1=" <string name="search_shortcuts_button">Sèlte ràpide</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + message=""onboarding_tracking_protection_strict_button_description" is translated here but not found in default locale" + errorLine1=" <string name="onboarding_tracking_protection_strict_button_description">Cuireann sé seo cosc ar níos mó lorgairí, rud a thugann cosaint agus feidhmíocht níos fearr, ach d\'fhéadfadh sé suímh áirithe a bhriseadh</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - @@ -3817,39 +3385,6 @@ column="13"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + message=""onboarding_feature_section_header" is translated here but not found in default locale" + errorLine1=" <string name="onboarding_feature_section_header">Coñeza o %s</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - @@ -6600,50 +5827,6 @@ column="13"/> - - - - - - - - - - - - - - - - + id="IntentFilterExportedReceiver" + message="When using intent filters, please specify `android:exported` as well" + errorLine1=" <activity-alias" + errorLine2=" ~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="44" + column="10"/> + id="IntentFilterExportedReceiver" + message="When using intent filters, please specify `android:exported` as well" + errorLine1=" <activity-alias" + errorLine2=" ~~~~~~~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="68" + column="10"/> + id="IntentFilterExportedReceiver" + message="When using intent filters, please specify `android:exported` as well" + errorLine1=" <activity" + errorLine2=" ~~~~~~~~"> + file="src/main/AndroidManifest.xml" + line="76" + column="10"/> + id="IntentFilterExportedReceiver" + message="When using intent filters, please specify `android:exported` as well" + errorLine1=" <service" + errorLine2=" ~~~~~~~"> + + + + + + + + + id="UnspecifiedImmutableFlag" + message="Missing `PendingIntent` mutability flag" + errorLine1=" 0" + errorLine2=" ~"> + + + + + + + + + + + + + + + + @@ -7117,6 +6366,39 @@ column="6"/> + + + + + + + + + + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + @@ -7680,1355 +6874,1234 @@ - + + message="The resource `R.color.quick_action_reader_appearance_icon_background` appears to be unused" + errorLine1=" <color name="quick_action_reader_appearance_icon_background">#ecbcfb</color>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.color.private_browsing_button_accent_color` appears to be unused" + errorLine1=" <color name="private_browsing_button_accent_color">@color/foundation_private_theme</color>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.color.mozac_feature_readerview_text_color` appears to be unused" + errorLine1=" <color name="mozac_feature_readerview_text_color">@color/primary_text_light_theme</color>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.color.onboarding_card_background_dark` appears to be unused" + errorLine1=" <color name="onboarding_card_background_dark">@color/photonInk80</color>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.color.nightly_launcher_background` appears to be unused" + errorLine1=" <color name="nightly_launcher_background">#220033</color>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.color.mozac_ui_tabcounter_default_tint` appears to be unused" + errorLine1=" <color name="mozac_ui_tabcounter_default_tint">@color/primary_text_light_theme</color>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.color.mozac_ui_tabcounter_default_text` appears to be unused" + errorLine1=" <color name="mozac_ui_tabcounter_default_text">@color/primary_text_light_theme</color>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.color.toolbar_menu_transparent` appears to be unused" + errorLine1=" <color name="toolbar_menu_transparent">@android:color/transparent</color>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.layout.component_session_control` appears to be unused" + errorLine1="<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/layout/component_session_control.xml" + line="6" + column="1"/> + message="The resource `R.drawable.dialog_button_background` appears to be unused" + errorLine1="<shape xmlns:android="http://schemas.android.com/apk/res/android">" + errorLine2="^"> + file="src/main/res/drawable/dialog_button_background.xml" + line="6" + column="1"/> + message="The resource `R.dimen.mozac_browser_menu_corner_radius` appears to be unused" + errorLine1=" <dimen name="mozac_browser_menu_corner_radius">8dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.mozac_browser_menu2_corner_radius` appears to be unused" + errorLine1=" <dimen name="mozac_browser_menu2_corner_radius">8dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.toolbar_elevation` appears to be unused" + errorLine1=" <dimen name="toolbar_elevation">7dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.library_item_icon_margin_vertical` appears to be unused" + errorLine1=" <dimen name="library_item_icon_margin_vertical">8dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - + message="The resource `R.dimen.session_card_padding` appears to be unused" + errorLine1=" <dimen name="session_card_padding">14dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - + message="The resource `R.dimen.radio_button_drawable_padding` appears to be unused" + errorLine1=" <dimen name="radio_button_drawable_padding">32dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - + message="The resource `R.dimen.tp_onboarding_width` appears to be unused" + errorLine1=" <dimen name="tp_onboarding_width">256dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.tab_counter_box_width_height` appears to be unused" + errorLine1=" <dimen name="tab_counter_box_width_height">24dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.design_quick_action_sheet_peek_height_min` appears to be unused" + errorLine1=" <dimen name="design_quick_action_sheet_peek_height_min">64dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.bottom_sheet_top_padding` appears to be unused" + errorLine1=" <dimen name="bottom_sheet_top_padding">8dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.sign_in_button_padding` appears to be unused" + errorLine1=" <dimen name="sign_in_button_padding">10dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.sign_in_button_margin_top` appears to be unused" + errorLine1=" <dimen name="sign_in_button_margin_top">32dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.sign_in_button_margin` appears to be unused" + errorLine1=" <dimen name="sign_in_button_margin">16dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.search_fragment_clipboard_item_vertical_margin` appears to be unused" + errorLine1=" <dimen name="search_fragment_clipboard_item_vertical_margin">8dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.search_fragment_shortcuts_label_margin_horizontal` appears to be unused" + errorLine1=" <dimen name="search_fragment_shortcuts_label_margin_horizontal">18dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.search_fragment_shortcuts_label_margin_vertical` appears to be unused" + errorLine1=" <dimen name="search_fragment_shortcuts_label_margin_vertical">18dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.search_fragment_shortcuts_label_text_size` appears to be unused" + errorLine1=" <dimen name="search_fragment_shortcuts_label_text_size">12sp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.migration_default_text_size` appears to be unused" + errorLine1=" <dimen name="migration_default_text_size">16sp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.share_all_apps_list_padding_start` appears to be unused" + errorLine1=" <dimen name="share_all_apps_list_padding_start">16dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.tab_tray_top_offset` appears to be unused" + errorLine1=" <dimen name="tab_tray_top_offset">40dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.tab_tray_grid_item_selected_border_width` appears to be unused" + errorLine1=" <dimen name="tab_tray_grid_item_selected_border_width">2dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.tab_tray_favicon_border_radius` appears to be unused" + errorLine1=" <dimen name="tab_tray_favicon_border_radius">4dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.dimen.saved_logins_item_padding` appears to be unused" + errorLine1=" <dimen name="saved_logins_item_padding">4dp</dimen>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.layout.fragment_edit_custom_search_engine` appears to be unused" + errorLine1="<ScrollView" + errorLine2="^"> + file="src/main/res/layout/fragment_edit_custom_search_engine.xml" + line="6" + column="1"/> - - - - + message="The resource `R.layout.fragment_search` appears to be unused" + errorLine1="<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/layout/fragment_search.xml" + line="5" + column="1"/> + message="The resource `R.xml.home_scene` appears to be unused" + errorLine1="<MotionScene" + errorLine2="^"> + file="src/main/res/xml/home_scene.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_accessibility` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_accessibility.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_alert` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_alert.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_confirm_email` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_confirm_email.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_cookies` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_cookies.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_cryptominers` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_cryptominers.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_data_collection` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_data_collection.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_download_default` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_download_default.xml" + line="4" + column="1"/> - + message="The resource `R.drawable.ic_drawer_pull_tab` appears to be unused" + errorLine1="<inset xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_drawer_pull_tab.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_energy` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_energy.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_fingerprinters` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_fingerprinters.xml" + line="5" + column="1"/> - + message="The resource `R.drawable.ic_formfill` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_formfill.xml" + line="5" + column="1"/> - + message="The resource `R.drawable.ic_language` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_language.xml" + line="5" + column="1"/> + message="The resource `R.mipmap.ic_launcher_private` appears to be unused" + errorLine1="<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">" + errorLine2="^"> + file="src/main/res/mipmap-anydpi-v26/ic_launcher_private.xml" + line="6" + column="1"/> + message="The resource `R.drawable.ic_report_issues` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_report_issues.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_screenshots` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_screenshots.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_send` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_send.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_shortcuts` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_shortcuts.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_social_media_trackers` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_social_media_trackers.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_tabs` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_tabs.xml" + line="5" + column="1"/> + message="The resource `R.drawable.ic_tracking_content` appears to be unused" + errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/ic_tracking_content.xml" + line="5" + column="1"/> + message="The resource `R.integer.strike_thru_start_offset` appears to be unused" + errorLine1=" <integer name="strike_thru_start_offset">0</integer>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/integers.xml" + line="6" + column="14"/> + message="The resource `R.integer.strike_thru_duration` appears to be unused" + errorLine1=" <integer name="strike_thru_duration">500</integer>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/integers.xml" + line="7" + column="14"/> + message="The resource `R.layout.layout_url_background` appears to be unused" + errorLine1="<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/layout/layout_url_background.xml" + line="5" + column="1"/> + message="The resource `R.layout.list_element` appears to be unused" + errorLine1="<androidx.constraintlayout.widget.ConstraintLayout" + errorLine2="^"> + file="src/main/res/layout/list_element.xml" + line="5" + column="1"/> + message="The resource `R.drawable.locale_search_background` appears to be unused" + errorLine1="<shape xmlns:android="http://schemas.android.com/apk/res/android">" + errorLine2="^"> + file="src/main/res/drawable/locale_search_background.xml" + line="2" + column="1"/> + message="The resource `R.layout.mozac_browser_menu_item_switch` appears to be unused" + errorLine1="<Switch xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/layout/mozac_browser_menu_item_switch.xml" + line="5" + column="1"/> + message="The resource `R.drawable.notification_indicator` appears to be unused" + errorLine1="<shape xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + file="src/main/res/drawable/notification_indicator.xml" + line="6" + column="1"/> + message="The resource `R.anim.placeholder_animation` appears to be unused" + errorLine1="<translate" + errorLine2="^"> + file="src/main/res/anim/placeholder_animation.xml" + line="5" + column="1"/> + message="The resource `R.string.pref_key_feedback` appears to be unused" + errorLine1=" <string name="pref_key_feedback" translatable="false">pref_key_feedback</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/preference_keys.xml" + line="44" + column="13"/> + message="The resource `R.string.pref_key_have_read_fxa_account_json` appears to be unused" + errorLine1=" <string name="pref_key_have_read_fxa_account_json" translatable="false">pref_key_have_read_fxa_account_json</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/preference_keys.xml" + line="51" + column="13"/> + message="The resource `R.string.pref_key_private_mode` appears to be unused" + errorLine1=" <string name="pref_key_private_mode" translatable="false">pref_key_private_mode</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/preference_keys.xml" + line="54" + column="13"/> + message="The resource `R.string.pref_key_toolbar` appears to be unused" + errorLine1=" <string name="pref_key_toolbar" translatable="false">pref_key_toolbar</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/preference_keys.xml" + line="56" + column="13"/> + message="The resource `R.string.pref_key_mozilla_location_service` appears to be unused" + errorLine1=" <string name="pref_key_mozilla_location_service" translatable="false">pref_key_mozilla_location_service</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/preference_keys.xml" + line="77" + column="13"/> + message="The resource `R.string.pref_key_fenix_health_report` appears to be unused" + errorLine1=" <string name="pref_key_fenix_health_report" translatable="false">pref_key_fenix_health_report</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/preference_keys.xml" + line="78" + column="13"/> + message="The resource `R.string.search_widget_cfr_message` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Brže dođite do Firefoxa. Dodajte widget na svoj Početni ekran.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="72" + column="13"/> + message="The resource `R.string.browser_menu_new_tab` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Yeni vərəq</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-az/strings.xml" + line="74" + column="13"/> + message="The resource `R.string.search_widget_cfr_pos_button_text` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Dodaj widge</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="74" + column="13"/> + message="The resource `R.string.search_widget_cfr_neg_button_text` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Ne sada</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="76" + column="13"/> + message="The resource `R.string.preferences_tracking_protection_turned_off_globally` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Qlobal olaraq söndürülüb, açmaq üçün Tənzimləmələrə gedin.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-az/strings.xml" + line="285" + column="13"/> + message="The resource `R.string.preferences_home_2` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Pagina d’accolta</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-co/strings.xml" + line="397" + column="13"/> + message="The resource `R.string.bookmark_created_snackbar` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Əlfəcin yaradıldı.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-az/strings.xml" + line="448" + column="13"/> + message="The resource `R.string.library_synced_tabs` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Sinhronizovani tabovi</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="478" + column="13"/> + message="The resource `R.string.download_empty_message` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Nema preuzimanja</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="630" + column="13"/> + message="The resource `R.string.onboarding_firefox_account_header` appears to be unused" + errorLine1=" <string name="chrome_scheme">"%s səyyahınızdan daha çox şey əldə edin.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-az/strings.xml" + line="708" + column="13"/> + message="The resource `R.string.onboarding_firefox_account_sign_in` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Firefox-a daxil ol</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-az/strings.xml" + line="710" + column="13"/> + message="The resource `R.string.preferences_opening_screen` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Screnu d’apertura</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-co/strings.xml" + line="724" + column="13"/> + message="The resource `R.string.opening_screen_homepage` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Pagina d’accolta</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-co/strings.xml" + line="728" + column="13"/> + message="The resource `R.string.opening_screen_last_tab` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Ultima unghjetta</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-co/strings.xml" + line="732" + column="13"/> + message="The resource `R.string.opening_screen_after_four_hours_of_inactivity` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Pagina d’accolta dopu quattru ore d’inattività</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-co/strings.xml" + line="736" + column="13"/> + message="The resource `R.string.opening_screen_after_four_hours_of_inactivity` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Pagina d’accolta dopu quattru ore d’inattività</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-co/strings.xml" + line="736" + column="13"/> + message="The resource `R.string.preferences_passwords_sync_logins_on` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Aktiv</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-az/strings.xml" + line="867" + column="13"/> + message="The resource `R.string.preferences_passwords_sync_logins_on` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Aktiv</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-az/strings.xml" + line="867" + column="13"/> + message="The resource `R.string.preferences_passwords_sync_logins_off` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Sönülü</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-az/strings.xml" + line="869" + column="13"/> + message="The resource `R.string.onboarding_toolbar_position_header` appears to be unused" + errorLine1=" <string name="chrome_scheme">"অবস্থান নিন</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bn/strings.xml" + line="996" + column="13"/> - - - - - + message="The resource `R.string.onboarding_feature_section_header` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Upoznajte %s</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1028" + column="13"/> + message="The resource `R.string.onboarding_account_sign_in_header` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Počnite sinhronizirati zabilješke, lozinke i još mnogo toga sa svojim Firefox računom.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1037" + column="13"/> + message="The resource `R.string.onboarding_manual_sign_in_learn_more` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Saznajte više</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1039" + column="13"/> + message="The resource `R.string.onboarding_firefox_account_auto_signin_header_2` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Prijavljeni ste kao %s na drugim Firefox browseru na ovom telefonu. Da li se želite prijaviti s ovim računom?</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1043" + column="13"/> + message="The resource `R.string.onboarding_tracking_protection_header_2` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Automatska privatnost</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1057" + column="13"/> + message="The resource `R.string.onboarding_tracking_protection_description_2` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Postavke privatnosti i sigurnosti blokiraju trackere, malware i kompanije koje vas prate.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1060" + column="13"/> + message="The resource `R.string.onboarding_tracking_protection_standard_button_description_2` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Blokira manje trackera. Stranice se normalno učitavaju.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1064" + column="13"/> + message="The resource `R.string.onboarding_tracking_protection_strict_button_description_2` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Blokira više trackera, reklama i popupa. Stranice se brže učitavaju, ali neke funkcionalnosti možda neće raditi.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1070" + column="13"/> + message="The resource `R.string.onboarding_toolbar_position_description` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Isprobajte jednoručno surafanje s alatnom trakom na dnu ili vrhu.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1076" + column="13"/> + message="The resource `R.string.onboarding_privacy_notice_description` appears to be unused" + errorLine1=" <string name="chrome_scheme">"%s smo dizajnirali da vam pruži kontrolu nad onim što dijelite + 1091 online i što dijelite s nama.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1090" + column="13"/> + message="The resource `R.string.onboarding_theme_picker_description1` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Štedite bateriju i vid omogućavanjem tamnog režima.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1104" + column="13"/> + message="The resource `R.string.preference_enhanced_tracking_protection_standard_description_3` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Blokira manje trackera. Stranice se normalno učitavaju.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + file="src/main/res/values-bs/strings.xml" + line="1160" + column="13"/> + message="The resource `R.string.preference_enhanced_tracking_protection_strict_description_2` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Blokira više trackera, reklama i popupa. Stranice se brže učitavaju, ali neke funkcionalnosti možda neće raditi.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.string.add_to_homescreen_description` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Ovu web stranicu možete lahko dodati na Početni ekran telefona za brz pristup istoj i da surfate brže s iskustvom sličnom aplikaciji.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.string.synced_tabs_connect_to_sync_account` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Conetti un conto Firefox.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.string.no_collections_header1` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Sakupite stvari koje su vam važne</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.string.no_collections_description1` appears to be unused" + errorLine1=" <string name="chrome_scheme">"Grupišite slične pretrage, stranice i tabove za brži pristup kasnije.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.string.top_sites_toggle_top_recent_sites_3` appears to be unused" + errorLine1=" <string name="Siti principale i più visitati</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -9039,7 +8112,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9050,7 +8123,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9061,7 +8134,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9072,7 +8145,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9083,7 +8156,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9094,7 +8167,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9105,7 +8178,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9116,7 +8189,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9127,7 +8200,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9138,7 +8211,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9149,7 +8222,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9160,7 +8233,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9171,7 +8244,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9226,7 +8299,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -9237,69 +8310,41 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - + + + - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + message="The resource `R.string.private_browsing_delete_session` appears to be unused" + errorLine1=" <string name="private_browsing_delete_session">Delete session</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + - + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + message="The resource `R.string.tab_tray_menu_toggle` appears to be unused" + errorLine1=" <string name="tab_tray_menu_toggle">Toggle tab mode</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="686" + column="13"/> + message="The resource `R.string.bookmark_created_snackbar` appears to be unused" + errorLine1=" <string name="bookmark_created_snackbar">S’ha creyau lo marcapachinas.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="687" + column="13"/> + message="The resource `R.string.tabs_menu_close_all_tabs` appears to be unused" + errorLine1=" <string name="tabs_menu_close_all_tabs">Close all tabs</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="706" + column="13"/> + message="The resource `R.string.tabs_menu_share_tabs` appears to be unused" + errorLine1=" <string name="tabs_menu_share_tabs">Share tabs</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="708" + column="13"/> + message="The resource `R.string.tab_share` appears to be unused" + errorLine1=" <string name="tab_share">Share tab</string>" + errorLine2=" ~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="714" + column="13"/> + message="The resource `R.string.current_session_delete` appears to be unused" + errorLine1=" <string name="current_session_delete">Delete</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="716" + column="13"/> + message="The resource `R.string.current_session_save` appears to be unused" + errorLine1=" <string name="current_session_save">Save</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="718" + column="13"/> + message="The resource `R.string.current_session_share` appears to be unused" + errorLine1=" <string name="current_session_share">Share</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="720" + column="13"/> + message="The resource `R.string.current_session_image` appears to be unused" + errorLine1=" <string name="current_session_image">Current session image</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="722" + column="13"/> + message="The resource `R.string.save_to_collection` appears to be unused" + errorLine1=" <string name="save_to_collection">Save to collection</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="724" + column="13"/> + message="The resource `R.string.preferences_delete_browsing_data_tabs_title` appears to be unused" + errorLine1=" <string name="preferences_delete_browsing_data_tabs_title">Tabbe udditiiɗe</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="725" + column="13"/> + message="The resource `R.string.tab_tray_save_to_collection` appears to be unused" + errorLine1=" <string name="tab_tray_save_to_collection">Save</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="742" + column="13"/> + message="The resource `R.string.history_delete_all_dialog` appears to be unused" + errorLine1=" <string name="history_delete_all_dialog">Are you sure you want to clear your history?</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="748" + column="13"/> + message="The resource `R.string.history_clear_dialog` appears to be unused" + errorLine1=" <string name="history_clear_dialog">Clear</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="754" + column="13"/> + message="The resource `R.string.preferences_delete_browsing_data_on_quit_browsing_history` appears to be unused" + errorLine1=" <string name="preferences_delete_browsing_data_on_quit_browsing_history">Aslol banngogol</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="756" + column="13"/> - - - - - + id="UnusedResources" + message="The resource `R.string.preference_accessibility_auto_size` appears to be unused" + errorLine1=" <string name="preference_accessibility_auto_size">Clómhéid Uathoibríoch</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="764" + column="13"/> + id="UnusedResources" + message="The resource `R.string.history_delete_some` appears to be unused" + errorLine1=" <string name="history_delete_some">Delete %1$d items</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="770" + column="13"/> + id="UnusedResources" + message="The resource `R.string.history_24_hours` appears to be unused" + errorLine1=" <string name="history_24_hours">Last 24 hours</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="776" + column="13"/> + id="UnusedResources" + message="The resource `R.string.tip_firefox_preview_moved_button` appears to be unused" + errorLine1=" <string name="tip_firefox_preview_moved_button">Heɓ wanngorde Mozilla Firefox</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="780" + column="13"/> + id="UnusedResources" + message="The resource `R.string.download_menu_open` appears to be unused" + errorLine1=" <string name="download_menu_open">Open</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="801" + column="13"/> + id="UnusedResources" + message="The resource `R.string.tab_crash_description` appears to be unused" + errorLine1=" <string name="tab_crash_description">You can attempt to restore or close this tab below.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="810" + column="13"/> + id="UnusedResources" + message="The resource `R.string.content_description_session_menu` appears to be unused" + errorLine1=" <string name="content_description_session_menu">Session options</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="819" + column="13"/> + id="UnusedResources" + message="The resource `R.string.content_description_session_share` appears to be unused" + errorLine1=" <string name="content_description_session_share">Share session</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="822" + column="13"/> + id="UnusedResources" + message="The resource `R.string.bookmark_menu_content_description` appears to be unused" + errorLine1=" <string name="bookmark_menu_content_description">Bookmark menu</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="826" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_header` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_header">Reen hoore maa</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="827" + column="13"/> + id="UnusedResources" + message="The resource `R.string.bookmark_edit` appears to be unused" + errorLine1=" <string name="bookmark_edit">Edit bookmark</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="828" + column="13"/> + id="UnusedResources" + message="The resource `R.string.bookmark_select_folder` appears to be unused" + errorLine1=" <string name="bookmark_select_folder">Select folder</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="830" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_description1` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_description1">%s ina walla haɗde lowe geese ndewindo maa e ceŋogol.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="830" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_standard_button` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_standard_button">Gaadanteejo</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="833" + column="13"/> + id="UnusedResources" + message="The resource `R.string.bookmark_delete_folder_snackbar` appears to be unused" + errorLine1=" <string name="bookmark_delete_folder_snackbar">Deleted %1$s</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="836" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_standard_button_description` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_standard_button_description">Ina daaƴa rewindotooɓe famarɓe kono ina yamira kelle yo loow no haanirta</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="836" + column="13"/> + id="UnusedResources" + message="The resource `R.string.bookmark_menu_select_button` appears to be unused" + errorLine1=" <string name="bookmark_menu_select_button">Select</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_strict_button_description` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_strict_button_description">Cuireann sé seo cosc ar níos mó lorgairí, rud a thugann cosaint agus feidhmíocht níos fearr, ach d\'fhéadfadh sé suímh áirithe a bhriseadh</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + id="UnusedResources" + message="The resource `R.string.permissions_header` appears to be unused" + errorLine1=" <string name="permissions_header">Permissions</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="896" + column="13"/> + id="UnusedResources" + message="The resource `R.string.quick_settings_sheet_manage_site_permissions` appears to be unused" + errorLine1=" <string name="quick_settings_sheet_manage_site_permissions">Manage site permissions</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="906" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_standard_option` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_standard_option">Gaadanteejo</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="911" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_standard` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_standard">Gaadanteejo (basiyaaɗo)</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ff/strings.xml" + line="913" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_standard_description` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_standard_description">Cothrom na Féinne do chosaint agus d\'fheidhmíocht.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="953" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_standard_description_2` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_standard_description_2">Lódálfar leathanaigh mar is gnáth, ach cuirfear cosc ar níos lú lorgairí.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="955" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_strict_default` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_strict_default">Dian (Réamhshocrú)</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="961" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_strict_default_description` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_strict_default_description">Cosaint níos láidre agus feidhmíocht níos sciobtha, ach ní fheidhmeoidh suímh áirithe mar ba cheart.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="963" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_strict_recommended` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_strict_recommended">Dian (molta)</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="965" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_strict_description` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_strict_description">Cosaint níos láidre, ach d\'fhéadfadh sé suímh nó ábhar a bhriseadh.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="967" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_custom_description` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_custom_description">Roghnaigh na lorgairí agus na scripteanna le cosc a chur orthu</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="973" + column="13"/> + id="UnusedResources" + message="The resource `R.string.etp_onboarding_message_2` appears to be unused" + errorLine1=" <string name="etp_onboarding_message_2">Cuireann %s cosc ar lorgairí ar an suíomh seo nuair atá an sciath corcra. Tapáil í chun na lorgairí coiscthe a fheiceáil.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-ga-rIE/strings.xml" + line="1001" + column="13"/> + id="UnusedResources" + message="The resource `R.string.share_header` appears to be unused" + errorLine1=" <string name="share_header">Send and Share</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1011" + column="13"/> + id="UnusedResources" + message="The resource `R.string.share_link_subheader` appears to be unused" + errorLine1=" <string name="share_link_subheader">Share a link</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1018" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_feature_section_header` appears to be unused" + errorLine1=" <string name="onboarding_feature_section_header">Conoixe a %s</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1043" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_account_sign_in_header` appears to be unused" + errorLine1=" <string name="onboarding_account_sign_in_header">Encomienza la sincronización d\'as adrezas d\'interés, las contrasenyas y muito mas con a tuya cuenta d\'o Firefox.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1052" + column="13"/> + id="UnusedResources" + message="The resource `R.string.notification_pbm_channel_name` appears to be unused" + errorLine1=" <string name="notification_pbm_channel_name">Private browsing session</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1054" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_manual_sign_in_learn_more` appears to be unused" + errorLine1=" <string name="onboarding_manual_sign_in_learn_more">Saber-ne mas</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1054" + column="13"/> + id="UnusedResources" + message="The resource `R.string.notification_pbm_delete_text` appears to be unused" + errorLine1=" <string name="notification_pbm_delete_text">Delete private tabs</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1056" + column="13"/> + id="UnusedResources" + message="The resource `R.string.notification_pbm_action_open` appears to be unused" + errorLine1=" <string name="notification_pbm_action_open">Open</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1060" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_firefox_account_sign_in` appears to be unused" + errorLine1=" <string name="onboarding_firefox_account_sign_in">Iniciar sesión en Firefox</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1060" + column="13"/> + id="UnusedResources" + message="The resource `R.string.notification_pbm_action_delete_and_open` appears to be unused" + errorLine1=" <string name="notification_pbm_action_delete_and_open">Delete and Open</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1062" + column="13"/> + id="UnusedResources" + message="The resource `R.string.notification_powered_by_channel_name` appears to be unused" + errorLine1=" <string name="notification_powered_by_channel_name">Powered By</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1064" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_header_2` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_header_2">Privacidat automatica</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1068" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_description_2` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_description_2">La configuración de privacidat y seguranza bloca los elementos de seguimiento, los programas maliciosos y las companyías que te siguen.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1071" + column="13"/> + + + + + id="UnusedResources" + message="The resource `R.string.snackbar_tab_deleted` appears to be unused" + errorLine1=" <string name="snackbar_tab_deleted">Tab deleted</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1080" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_strict_button_description_2` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_strict_button_description_2">Bloca mas elementos de seguimiento, anuncios y finestras emerchents. Las pachinas se cargan mas rapido, pero se puede perder bella funcionalidat.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1081" + column="13"/> + id="UnusedResources" + message="The resource `R.string.snackbar_tabs_deleted` appears to be unused" + errorLine1=" <string name="snackbar_tabs_deleted">Tabs deleted</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1082" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_toolbar_position_header` appears to be unused" + errorLine1=" <string name="onboarding_toolbar_position_header">Prene una posición</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1085" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_toolbar_position_description` appears to be unused" + errorLine1=" <string name="onboarding_toolbar_position_description">Preba la navegación con una sola man con a barra de ferramientas inferior u mueve-la a la parte superior.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1087" + column="13"/> + id="UnusedResources" + message="The resource `R.string.snackbar_message_tabs_closed` appears to be unused" + errorLine1=" <string name="snackbar_message_tabs_closed">Tabs closed!</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1088" + column="13"/> + id="UnusedResources" + message="The resource `R.string.snackbar_message_bookmarks_view` appears to be unused" + errorLine1=" <string name="snackbar_message_bookmarks_view">View</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1092" + column="13"/> + id="UnusedResources" + message="The resource `R.string.snackbar_private_tabs_deleted` appears to be unused" + errorLine1=" <string name="snackbar_private_tabs_deleted">Private tabs deleted</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1100" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_privacy_notice_description` appears to be unused" + errorLine1=" <string name="onboarding_privacy_notice_description">Hemos disenyau %s pa dar-te control sobre lo que compartes" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1101" + column="13"/> + id="UnusedResources" + message="The resource `R.string.snackbar_top_site_removed` appears to be unused" + errorLine1=" <string name="snackbar_top_site_removed">Site removed</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1104" + column="13"/> + id="UnusedResources" + message="The resource `R.string.a11y_dialog_deleted_undo` appears to be unused" + errorLine1=" <string name="a11y_dialog_deleted_undo">Undo</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1106" + column="13"/> + id="UnusedResources" + message="The resource `R.string.a11y_dialog_deleted_confirm` appears to be unused" + errorLine1=" <string name="a11y_dialog_deleted_confirm">Confirm</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1108" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_theme_picker_description1` appears to be unused" + errorLine1=" <string name="onboarding_theme_picker_description1">Cabida un poquet de batería y descansa la vista activando lo modo fosco.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1115" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_delete_browsing_data_browsing_history_title` appears to be unused" + errorLine1=" <string name="preferences_delete_browsing_data_browsing_history_title">History</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1154" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_delete_browsing_data_browsing_history_subtitle` appears to be unused" + errorLine1=" <string name="preferences_delete_browsing_data_browsing_history_subtitle">%d pages</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1157" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_standard_description_3` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_standard_description_3">Bloca menos elementos de seguimiento. Las pachinas se cargarán con normalidat.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1168" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_strict_description_2` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_strict_description_2">Bloca mas elementos de seguimiento, anuncios y finestras emerchents. Las pachinas se cargan mas rapido, pero se puede perder bella funcionalidat.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1174" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_summary_delete_browsing_data_on_quit` appears to be unused" + errorLine1=" <string name="preference_summary_delete_browsing_data_on_quit">Automatically deletes browsing data when you select "Quit" from the main menu</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1175" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_fxa_section_header` appears to be unused" + errorLine1=" <string name="onboarding_fxa_section_header">Already have an account?</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1227" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_firefox_account_stay_signed_out` appears to be unused" + errorLine1=" <string name="onboarding_firefox_account_stay_signed_out">Stay signed out</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1250" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_tracking_protection_strict_button` appears to be unused" + errorLine1=" <string name="onboarding_tracking_protection_strict_button">Strict (recommended)</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1264" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_passwords_sync_logins_on` appears to be unused" + errorLine1=" <string name="preferences_passwords_sync_logins_on">Activau</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1306" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_passwords_sync_logins_off` appears to be unused" + errorLine1=" <string name="preferences_passwords_sync_logins_off">Desactivau</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1308" + column="13"/> + id="UnusedResources" + message="The resource `R.string.sign_out_confirmation_message` appears to be unused" + errorLine1=" <string name="sign_out_confirmation_message">Firefox will stop syncing with your account, but won’t delete any of your browsing data on this device.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1330" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preference_enhanced_tracking_protection_custom_tracking_content_3` appears to be unused" + errorLine1=" <string name="preference_enhanced_tracking_protection_custom_tracking_content_3">Only in Custom tabs</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1387" + column="13"/> + id="UnusedResources" + message="The resource `R.string.synced_tabs_connect_to_sync_account` appears to be unused" + errorLine1=" <string name="synced_tabs_connect_to_sync_account">Tengjast við Firefox reikning.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-is/strings.xml" + line="1427" + column="13"/> + id="UnusedResources" + message="The resource `R.string.about_your_rights` appears to be unused" + errorLine1=" <string name="about_your_rights">Your rights</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1430" + column="13"/> + id="UnusedResources" + message="The resource `R.string.about_open_source_licenses` appears to be unused" + errorLine1=" <string name="about_open_source_licenses">Open source libraries we use</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1432" + column="13"/> + id="UnusedResources" + message="The resource `R.string.tab_counter_content_description_one_tab` appears to be unused" + errorLine1=" <string name="tab_counter_content_description_one_tab">1 tab</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1461" + column="13"/> + id="UnusedResources" + message="The resource `R.string.tab_counter_content_description_multi_tab` appears to be unused" + errorLine1=" <string name="tab_counter_content_description_multi_tab">%d tabs</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1463" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_passwords_sync_logins_reconnect` appears to be unused" + errorLine1=" <string name="preferences_passwords_sync_logins_reconnect">Reconnect</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1514" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_passwords_sync_logins_sign_in` appears to be unused" + errorLine1=" <string name="preferences_passwords_sync_logins_sign_in">Sign in to Sync</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1516" + column="13"/> + id="UnusedResources" + message="The resource `R.string.top_sites_max_limit_content` appears to be unused" + errorLine1=" <string name="top_sites_max_limit_content">Da dodate novu top stranicu, uklonite jednu. Pritisnite stranicu i držite, pa izaberite uklanjanje.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-bs/strings.xml" + line="1533" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_passwords_saved_logins_alphabetically` appears to be unused" + errorLine1=" <string name="preferences_passwords_saved_logins_alphabetically">Alphabetically</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1534" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_passwords_saved_logins_recently_used` appears to be unused" + errorLine1=" <string name="preferences_passwords_saved_logins_recently_used">Recently used</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1536" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_passwords_saved_logins_enter_pin` appears to be unused" + errorLine1=" <string name="preferences_passwords_saved_logins_enter_pin">Re-enter your PIN</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1544" + column="13"/> + id="UnusedResources" + message="The resource `R.string.preferences_passwords_saved_logins_enter_pin_description` appears to be unused" + errorLine1=" <string name="preferences_passwords_saved_logins_enter_pin_description">Unlock to view your saved logins</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1546" + column="13"/> + id="UnusedResources" + message="The resource `R.string.logins_insecure_connection_warning` appears to be unused" + errorLine1=" <string name="logins_insecure_connection_warning">This connection is not secure. Logins entered here could be compromised.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1548" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_firefox_account_header` appears to be unused" + errorLine1=" <string name="onboarding_firefox_account_header">Quita-le lo millor provecho a %s.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1549" + column="13"/> + id="UnusedResources" + message="The resource `R.string.logins_insecure_connection_warning_learn_more` appears to be unused" + errorLine1=" <string name="logins_insecure_connection_warning_learn_more">Learn more</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1550" + column="13"/> + id="UnusedResources" + message="The resource `R.string.logins_doorhanger_save` appears to be unused" + errorLine1=" <string name="logins_doorhanger_save">Do you want %s to save this login?</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1552" + column="13"/> + id="UnusedResources" + message="The resource `R.string.no_collections_header1` appears to be unused" + errorLine1=" <string name="no_collections_header1">Colecciona las cosetas que t’importan</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1552" + column="13"/> + id="UnusedResources" + message="The resource `R.string.logins_doorhanger_save_confirmation` appears to be unused" + errorLine1=" <string name="logins_doorhanger_save_confirmation">Save</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1554" + column="13"/> + id="UnusedResources" + message="The resource `R.string.no_collections_description1` appears to be unused" + errorLine1=" <string name="no_collections_description1">Agrupa busquedas, puestos y pestanyas semellants pa un acceso rapido mas tarde.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1554" + column="13"/> + id="UnusedResources" + message="The resource `R.string.logins_doorhanger_save_dont_save` appears to be unused" + errorLine1=" <string name="logins_doorhanger_save_dont_save">Don’t save</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1556" + column="13"/> + id="UnusedResources" + message="The resource `R.string.onboarding_firefox_account_auto_signin_header_2` appears to be unused" + errorLine1=" <string name="onboarding_firefox_account_auto_signin_header_2">Ya has iniciau sesión como %s en unatro navegador Firefox d’este telefono. Quiers iniciar sesión con esta cuenta?</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1556" + column="13"/> + id="UnusedResources" + message="The resource `R.string.add_to_homescreen_description` appears to be unused" + errorLine1=" <string name="add_to_homescreen_description">Puetz anyadir facilment este puesto web a la tuya pachina d’inicio pa tener-ie acceso instantanio y navegar rapidament como si estase una aplicación.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values-an/strings.xml" + line="1558" + column="13"/> + id="UnusedResources" + message="The resource `R.string.logins_site_copied` appears to be unused" + errorLine1=" <string name="logins_site_copied">Site copied to clipboard</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1562" + column="13"/> + id="UnusedResources" + message="The resource `R.string.saved_login_copy_site` appears to be unused" + errorLine1=" <string name="saved_login_copy_site">Copy site</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1574" + column="13"/> + id="UnusedResources" + message="The resource `R.string.credit_cards_card_nickname` appears to be unused" + errorLine1=" <string name="credit_cards_card_nickname">Card Nickname</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1634" + column="13"/> + id="UnusedResources" + message="The resource `R.string.search_add_custom_engine_learn_more_label` appears to be unused" + errorLine1=" <string name="search_add_custom_engine_learn_more_label">Learn More</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1688" + column="13"/> + id="UnusedResources" + message="The resource `R.string.search_add_custom_engine_learn_more_description` appears to be unused" + errorLine1=" <string name="search_add_custom_engine_learn_more_description">Learn more link</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1692" + column="13"/> + id="UnusedResources" + message="The resource `R.string.search_add_custom_engine_error_existing_name` appears to be unused" + errorLine1=" <string name="search_add_custom_engine_error_existing_name">Search engine with name “%s” already exists.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1697" + column="13"/> + id="UnusedResources" + message="The resource `R.string.pocket_top_articles` appears to be unused" + errorLine1=" <string name="pocket_top_articles">Top Articles</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1750" + column="13"/> + id="UnusedResources" + message="The resource `R.string.discard_changes` appears to be unused" + errorLine1=" <string name="discard_changes">Discard changes</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="1777" + column="13"/> + id="UnusedResources" + message="The resource `R.style.Header12TextStyle` appears to be unused" + errorLine1=" <style name="Header12TextStyle" parent="TextAppearance.MaterialComponents.Body2">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/styles.xml" + line="417" + column="12"/> + id="UnusedResources" + message="The resource `R.style.SubtitleTextStyle` appears to be unused" + errorLine1=" <style name="SubtitleTextStyle" parent="TextAppearance.MaterialComponents.Body1">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/styles.xml" + line="445" + column="12"/> + id="UnusedResources" + message="The resource `R.style.SearchEngineShortcutsLabelStyle` appears to be unused" + errorLine1=" <style name="SearchEngineShortcutsLabelStyle">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/styles.xml" + line="528" + column="12"/> + id="UnusedResources" + message="The resource `R.drawable.sync_error_background_with_ripple` appears to be unused" + errorLine1="<ripple xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.layout.synced_tabs_tray_list` appears to be unused" + errorLine1="<androidx.recyclerview.widget.RecyclerView" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.layout.tab_header` appears to be unused" + errorLine1="<androidx.constraintlayout.widget.ConstraintLayout" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.layout.tab_history_list_item` appears to be unused" + errorLine1="<org.mozilla.fenix.library.LibrarySiteItemView" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.layout.tab_list_row` appears to be unused" + errorLine1="<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.menu.tab_tray_menu` appears to be unused" + errorLine1="<menu xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.layout.tabs_tray_tab_counter` appears to be unused" + errorLine1="<FrameLayout" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.layout.top_sites_header` appears to be unused" + errorLine1="<androidx.constraintlayout.widget.ConstraintLayout" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.xml.turn_on_sync_preferences` appears to be unused" + errorLine1="<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.drawable.url_background` appears to be unused" + errorLine1="<shape xmlns:android="http://schemas.android.com/apk/res/android">" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.anim.zoom_in_fade` appears to be unused" + errorLine1="<set xmlns:android="http://schemas.android.com/apk/res/android">" + errorLine2="^"> + id="UnusedResources" + message="The resource `R.anim.zoom_out_fade` appears to be unused" + errorLine1="<set xmlns:android="http://schemas.android.com/apk/res/android">" + errorLine2="^"> + id="IconXmlAndPng" + message="The following images appear both as density independent `.xml` files and as bitmap files: /Users/rotbolt/AndroidStudioProjects/fenix/app/src/main/res/drawable-hdpi/ic_logo_wordmark_normal.png, /Users/rotbolt/AndroidStudioProjects/fenix/app/src/main/res/drawable-night/ic_logo_wordmark_normal.xml"> + file="src/main/res/drawable-xxxhdpi/ic_logo_wordmark_normal.png"/> + + + + + + id="ConvertToWebp" + message="One or more images in this project can be converted to the WebP format which typically results in smaller file sizes, even for lossless conversion"> + file="src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png"/> + id="ConvertToWebp" + message="One or more images in this project can be converted to the WebP format which typically results in smaller file sizes, even for lossless conversion"> + file="src/main/res/drawable-xxxhdpi/ic_logo_wordmark_private.png"/> + id="IconLocation" + message="Found bitmap drawable `res/drawable/ic_baidu.png` in densityless folder"> + file="src/main/res/drawable/ic_baidu.png"/> + id="IconLocation" + message="Found bitmap drawable `res/drawable/ic_jd.png` in densityless folder"> + file="src/main/res/drawable/ic_jd.png"/> + id="IconLocation" + message="Found bitmap drawable `res/drawable/ic_pdd.png` in densityless folder"> + file="src/main/res/drawable/ic_pdd.png"/> + id="IconDensities" + message="Missing the following drawables in `drawable-mdpi`: fenix_search_widget.png (found in drawable-hdpi)"> + file="src/main/res/drawable-mdpi"/> + id="IconDensities" + message="Missing the following drawables in `drawable-xhdpi`: fenix_search_widget.png (found in drawable-hdpi)"> + file="src/main/res/drawable-xhdpi"/> + id="IconDensities" + message="Missing the following drawables in `drawable-xxhdpi`: fenix_search_widget.png (found in drawable-hdpi)"> + file="src/main/res/drawable-xxhdpi"/> + id="SmallSp" + message="Avoid using sizes smaller than `11sp`: `10sp`" + errorLine1=" android:textSize="10sp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/account_share_list_item.xml" + line="39" + column="9"/> + id="SmallSp" + message="Avoid using sizes smaller than `11sp`: `10sp`" + errorLine1=" android:textSize="10sp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/app_share_list_item.xml" + line="37" + column="9"/> + id="SmallSp" + message="Avoid using sizes smaller than `11sp`: `10sp`" + errorLine1=" android:textSize="10sp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/list_element.xml" + line="54" + column="9"/> + id="ClickableViewAccessibility" + message="Custom view ``BrowserAwesomeBar`` has `setOnTouchListener` called on it but does not override `performClick`" + errorLine1=" binding.awesomeBar.setOnTouchListener { _, _ ->" + errorLine2=" ^"> + file="src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt" + line="207" + column="9"/> + id="ClickableViewAccessibility" + message="`onTouch` lambda should call `View#performClick` when a click is detected" + errorLine1=" binding.awesomeBar.setOnTouchListener { _, _ ->" + errorLine2=" ^"> + file="src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt" + line="207" + column="47"/> + id="ClickableViewAccessibility" + message="`onTouch` lambda should call `View#performClick` when a click is detected" + errorLine1=" dialog?.window?.decorView?.setOnTouchListener { _, event ->" + errorLine2=" ^"> + file="src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt" + line="223" + column="59"/> + id="ClickableViewAccessibility" + message="Custom view `SwipeGestureLayout` overrides `onTouchEvent` but not `performClick`" + errorLine1=" override fun onTouchEvent(event: MotionEvent?): Boolean {" + errorLine2=" ~~~~~~~~~~~~"> + file="src/main/java/org/mozilla/fenix/browser/SwipeGestureLayout.kt" + line="120" + column="18"/> + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + file="src/main/res/layout/default_browser_experiment_preference.xml" + line="13" + column="6"/> + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + file="src/main/res/layout/history_metadata_group.xml" + line="46" + column="6"/> + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + file="src/main/res/layout/library_site_item.xml" + line="31" + column="10"/> + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + file="src/main/res/layout/sign_in_preference.xml" + line="17" + column="6"/> + id="RtlHardcoded" + message="Consider replacing `android:layout_marginLeft` with `android:layout_marginStart="10dp"` to better support right-to-left layouts" + errorLine1=" android:layout_marginLeft="10dp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/activity_privacy_content_display.xml" + line="21" + column="21"/> diff --git a/app/lint.xml b/app/lint.xml index 33a1a4d1bd..c490f2dbe3 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -50,7 +50,11 @@ - + + + + diff --git a/app/metrics.yaml b/app/metrics.yaml index f6631e170a..0ec8984c3a 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -26,12 +26,14 @@ events: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - telemetry-client-dev@mozilla.com - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never search_bar_tapped: type: event description: | @@ -49,11 +51,13 @@ events: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never entered_url: type: event description: | @@ -71,11 +75,13 @@ events: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never performed_search: type: event description: | @@ -99,15 +105,19 @@ events: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never browser_menu_action: type: event description: | - A browser menu item was tapped + A browser menu item was tapped. + The name of the item that the user tapped is stored in extras with the + key `item`. extra_keys: item: description: | @@ -130,11 +140,12 @@ events: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-12-01" default_browser_changed: type: event description: | @@ -145,11 +156,13 @@ events: - https://github.com/mozilla-mobile/fenix/pull/18982#pullrequestreview-635098629 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/21076#issuecomment-909237275 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-04-01" + - erichards@mozilla.com + expires: never default_browser_notif_tapped: type: event description: | @@ -193,11 +206,13 @@ events: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/17935 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-08-01" + - erichards@mozilla.com + expires: never preference_toggled: type: event description: | @@ -236,12 +251,14 @@ events: - https://github.com/mozilla-mobile/fenix/pull/6746 - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never whats_new_tapped: type: event description: | @@ -254,11 +271,12 @@ events: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-12-01" opened_link: type: event description: | @@ -277,11 +295,13 @@ events: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never tab_counter_menu_action: type: event description: @@ -308,7 +328,7 @@ events: expires: "2021-12-01" synced_tab_opened: type: event - description: > + description: | An event that indicates that a synced tab was opened. bugs: - https://github.com/mozilla-mobile/fenix/issues/15369 @@ -316,11 +336,13 @@ events: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/16727 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-10" + - erichards@mozilla.com + expires: never recently_closed_tabs_opened: type: event description: | @@ -332,11 +354,13 @@ events: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/16739 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-10" + - erichards@mozilla.com + expires: never copy_url_tapped: type: event description: | @@ -348,11 +372,13 @@ events: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/16915 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-10" + - erichards@mozilla.com + expires: never browser_toolbar_home_tapped: type: event description: | @@ -363,11 +389,12 @@ events: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/19936 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-10" + expires: "2022-12-10" tab_view_changed: type: event description: | @@ -401,12 +428,14 @@ onboarding: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - erichards@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never fxa_manual_signin: type: event description: @@ -418,12 +447,14 @@ onboarding: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - erichards@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never privacy_notice: type: event description: @@ -435,29 +466,13 @@ onboarding: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - erichards@mozilla.com - expires: "2022-02-01" - pref_toggled_private_browsing: - type: event - description: - The private browsing preference was selected from the onboarding card. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/10824 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/11867 - - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 - data_sensitivity: - - interaction - notification_emails: - - android-probes@mozilla.com - - erichards@mozilla.com - expires: "2022-02-01" + expires: never pref_toggled_toolbar_position: type: event description: @@ -474,12 +489,14 @@ onboarding: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - erichards@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never pref_toggled_tracking_prot: type: event description: @@ -496,12 +513,14 @@ onboarding: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - erichards@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never pref_toggled_theme_picker: type: event description: @@ -518,12 +537,14 @@ onboarding: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - erichards@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never finish: type: event description: @@ -535,12 +556,14 @@ onboarding: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - erichards@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never search_shortcuts: selected: @@ -665,11 +688,13 @@ context_menu: - https://github.com/mozilla-mobile/fenix/issues/16076#issuecomment-726216734 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never login_dialog: displayed: @@ -748,11 +773,13 @@ metrics: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never distribution_id: type: string lifetime: application @@ -789,11 +816,13 @@ metrics: - https://github.com/mozilla-mobile/fenix/pull/16942#issuecomment-742794701 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never has_mobile_bookmarks: type: boolean lifetime: application @@ -808,11 +837,13 @@ metrics: - https://github.com/mozilla-mobile/fenix/pull/16942#issuecomment-742794701 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never desktop_bookmarks_count: type: counter lifetime: application @@ -831,11 +862,13 @@ metrics: - https://github.com/mozilla-mobile/fenix/pull/16942#issuecomment-742794701 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never has_desktop_bookmarks: type: boolean lifetime: application @@ -873,11 +906,13 @@ metrics: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never has_top_sites: type: boolean lifetime: application @@ -892,11 +927,13 @@ metrics: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never recently_used_pwa_count: type: counter lifetime: application @@ -969,12 +1006,14 @@ metrics: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never mozilla_products: type: string_list lifetime: application @@ -1203,11 +1242,13 @@ metrics: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never has_open_tabs: type: boolean lifetime: application @@ -1263,11 +1304,181 @@ metrics: - perf-android-fe@mozilla.com - mcomella@mozilla.com expires: "2022-02-01" + install_source: + type: string + lifetime: application + description: | + Used to identify the source the app was installed from. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22138 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22224#issuecomment-956749994 + data_sensitivity: + - technical + notification_emails: + - android-probes@mozilla.com + expires: "2022-02-01" + inactive_tabs_count: + type: quantity + lifetime: application + description: | + How many inactive tabs does the user have. + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22155 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22163#issuecomment-957636802 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-02-01" + unit: integer + +customize_home: + most_visited_sites: + type: boolean + description: | + An indication of whether the most visited sites + are enabled to be displayed + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21239 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21344 + - https://github.com/mozilla-mobile/fenix/pull/21344#issuecomment-923198787 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-20" + jump_back_in: + type: boolean + description: | + An indication of whether the Jump back + in section is enabled to be displayed + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21239 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21344 + - https://github.com/mozilla-mobile/fenix/pull/21344#issuecomment-923198787 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-20" + recently_saved: + type: boolean + description: | + An indication of whether the recently + saved section is enabled to be displayed + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21239 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21344 + - https://github.com/mozilla-mobile/fenix/pull/21344#issuecomment-923198787 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-20" + recently_visited: + type: boolean + description: | + An indication of whether the Recently + visited section is enabled to be displayed + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21239 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21344 + - https://github.com/mozilla-mobile/fenix/pull/21344#issuecomment-923198787 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-20" + pocket: + type: boolean + description: | + An indication of whether Pocket is enabled to be displayed + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21239 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21344 + - https://github.com/mozilla-mobile/fenix/pull/21344#issuecomment-923198787 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-20" + preference_toggled: + type: event + description: | + A user toggles the preference for the home screen items. + extra_keys: + preference_key: + description: | + The preference key for the boolean (true/false) preference the user + toggled. + + We currently track: most_visited_sites, jump_back_in, + recently_visited, recently_saved, and pocket. + enabled: + description: "Whether or not the preference is *now* enabled" + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21095 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/1896 + data_sensitivity: + - technical + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-20" preferences: - search_suggestions_enabled: + studies_enabled: type: boolean description: > + A metric indicating whether or not the user has studies enabled + default: true + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22192 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22193 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + studies_preference_enabled: + type: event + description: > + An event indicating whether or not the user has studies enabled. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22192 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22193 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + search_suggestions_enabled: + type: boolean + description: | Whether or not the user has search suggestions enabled default: true send_in_pings: @@ -1286,7 +1497,7 @@ preferences: expires: "2022-02-01" remote_debugging_enabled: type: boolean - description: > + description: | Whether or not the user has remote debugging enabled default: false send_in_pings: @@ -1305,7 +1516,7 @@ preferences: expires: "2022-02-01" telemetry_enabled: type: boolean - description: > + description: | Whether or not the user has telemetry enabled. Note we should never receive a "false" value for this since telemetry would not send in that case. @@ -1326,7 +1537,7 @@ preferences: expires: "2022-02-01" enhanced_tracking_protection: type: string - description: > + description: | What type of enhanced tracking protection the user has enabled. "standard," "strict," "custom," or "" (if disabled) default: "standard" @@ -1346,7 +1557,7 @@ preferences: expires: "2022-02-01" bookmarks_suggestion: type: boolean - description: > + description: | Whether or not the user has enabled bookmark search suggestions default: true send_in_pings: @@ -1365,7 +1576,7 @@ preferences: expires: "2022-02-01" browsing_history_suggestion: type: boolean - description: > + description: | Whether or not the user has enabled browsing history suggestions. default: true send_in_pings: @@ -1384,7 +1595,7 @@ preferences: expires: "2022-02-01" clipboard_suggestions_enabled: type: boolean - description: > + description: | Whether or not the user has enabled clipboard search suggestions. default: true send_in_pings: @@ -1403,7 +1614,7 @@ preferences: expires: "2022-02-01" search_shortcuts_enabled: type: boolean - description: > + description: | Whether or not the user has enabled search shortcuts. default: true send_in_pings: @@ -1422,7 +1633,7 @@ preferences: expires: "2022-02-01" signed_in_sync: type: boolean - description: > + description: | Whether or not the user is signed into FxA default: false send_in_pings: @@ -1441,7 +1652,7 @@ preferences: expires: "2022-02-01" sync_items: type: string_list - description: > + description: | The list of items the user has chosen to sync with FxA. default: "" if the user is signed out. Otherwise defaults to whatever is set in their FxA account. New accounts set: @@ -1462,7 +1673,7 @@ preferences: expires: "2022-02-01" voice_search_enabled: type: boolean - description: > + description: | Whether or not the user has enabled the voice search button. default: true send_in_pings: @@ -1481,7 +1692,7 @@ preferences: expires: "2022-02-01" toolbar_position_setting: type: string - description: > + description: | The position of the toolbar default: bottom (defaults to top if the user has accessibility services) send_in_pings: @@ -1500,7 +1711,7 @@ preferences: expires: "2022-02-01" accessibility_services: type: string_list - description: > + description: | Whether or not the user has touch exploration or switch services enabled. These are built into the Android OS, not Fenix prefs. default: "" @@ -1520,7 +1731,7 @@ preferences: expires: "2022-02-01" open_links_in_app_enabled: type: boolean - description: > + description: | Whether or not the user has the open links in apps feature enabled. default: false send_in_pings: @@ -1539,7 +1750,7 @@ preferences: expires: "2022-02-01" user_theme: type: string - description: > + description: | The theme the user has enabled. "light," "dark," "system," or "battery" default: "system" for API 28+, else "light" send_in_pings: @@ -1556,32 +1767,82 @@ preferences: notification_emails: - android-probes@mozilla.com expires: "2022-02-01" - -search.default_engine: - code: - type: string - lifetime: application + inactive_tabs_enabled: + type: boolean description: | - If the search engine is pre-loaded with Fenix this value - will be the search engine identifier. If it's a custom search engine - (defined: https://github.com/mozilla-mobile/fenix/issues/1607) the - value will be "custom" + Whether or not the user has the inactive tabs feature enabled. + default: true send_in_pings: - metrics bugs: - - https://github.com/mozilla-mobile/fenix/issues/800 + - https://github.com/mozilla-mobile/fenix/issues/21903 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/1606 - - https://github.com/mozilla-mobile/fenix/pull/5216 - - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21908 data_sensitivity: - - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + expires: "2022-11-01" + inactive_tabs_survey_opened: + type: event + description: > + A survey for asking the user why she intends to turn off the + inactive tabs feature is shown. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21732 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21862#issuecomment-949598042 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-02-01" + turn_off_inactive_tabs_survey: + type: event + description: > + The user has disabled inactive tabs feature and responded + to our request for feedback. + extra_keys: + feedback: + description: | + The user's feedback regarding inactive tabs feature. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21732 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21862#issuecomment-946977614 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-02-01" + +search.default_engine: + code: + type: string + lifetime: application + description: | + If the search engine is pre-loaded with Fenix this value + will be the search engine identifier. If it's a custom search engine + (defined: https://github.com/mozilla-mobile/fenix/issues/1607) the + value will be "custom" + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/800 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/1606 + - https://github.com/mozilla-mobile/fenix/pull/5216 + - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 + - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 + data_sensitivity: + - technical + - interaction + notification_emails: + - android-probes@mozilla.com + - erichards@mozilla.com + expires: never name: type: string lifetime: application @@ -1600,12 +1861,14 @@ search.default_engine: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never submission_url: type: string lifetime: application @@ -1658,11 +1921,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never open_in_new_tabs: type: event description: | @@ -1674,11 +1939,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never open_in_private_tab: type: event description: | @@ -1691,11 +1958,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never open_in_private_tabs: type: event description: | @@ -1708,11 +1977,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never edited: type: event description: | @@ -1725,11 +1996,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never moved: type: event description: | @@ -1742,11 +2015,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never removed: type: event description: | @@ -1759,11 +2034,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never multi_removed: type: event description: | @@ -1776,11 +2053,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never shared: type: event description: | @@ -1793,11 +2072,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never copied: type: event description: | @@ -1810,11 +2091,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never folder_add: type: event description: | @@ -1827,11 +2110,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never folder_remove: type: event description: | @@ -1844,11 +2129,13 @@ bookmarks_management: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never custom_tab: closed: @@ -1863,11 +2150,12 @@ custom_tab: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-06-01" action_button: type: event description: | @@ -1880,11 +2168,12 @@ custom_tab: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-06-01" menu: type: event description: | @@ -1897,11 +2186,12 @@ custom_tab: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-06-01" activation: identifier: @@ -1921,11 +2211,13 @@ activation: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21788#issuecomment-950022224 data_sensitivity: - highly_sensitive notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never activation_id: type: uuid lifetime: user @@ -1942,11 +2234,13 @@ activation: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21788#issuecomment-950022224 data_sensitivity: - highly_sensitive notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never no_lint: - USER_LIFETIME_EXPIRATION @@ -1966,11 +2260,12 @@ error_page: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-12-01" sync_auth: opened: @@ -2050,11 +2345,13 @@ sync_auth: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never sign_out: type: event description: | @@ -2067,11 +2364,13 @@ sync_auth: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never sign_up: type: event description: | @@ -2083,12 +2382,14 @@ sync_auth: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never paired: type: event description: | @@ -2300,11 +2601,13 @@ history: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/18261 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-08-01" + - erichards@mozilla.com + expires: never opened_item_in_new_tab: type: event description: | @@ -2314,11 +2617,13 @@ history: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/18261 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-08-01" + - erichards@mozilla.com + expires: never opened_items_in_new_tabs: type: event description: | @@ -2329,11 +2634,13 @@ history: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/18261 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-08-01" + - erichards@mozilla.com + expires: never opened_item_in_private_tab: type: event description: | @@ -2343,11 +2650,13 @@ history: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/18261 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-08-01" + - erichards@mozilla.com + expires: never opened_items_in_private_tabs: type: event description: | @@ -2357,11 +2666,32 @@ history: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/18261 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-08-01" + - erichards@mozilla.com + expires: never + recent_searches_tapped: + type: event + description: | + User has tapped on a recent searches card in home. + extra_keys: + page_number: + description: | + The page number in the homescreen carousel that the recent searches + card was on. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22172 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22173 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + reader_mode: available: @@ -2375,11 +2705,13 @@ reader_mode: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never opened: type: event description: | @@ -2391,11 +2723,13 @@ reader_mode: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never closed: type: event description: | @@ -2407,11 +2741,13 @@ reader_mode: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never appearance: type: event description: | @@ -2423,11 +2759,15 @@ reader_mode: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + - erichards@mozilla.com + - erichards@mozilla.com + expires: never tabs_tray: opened: @@ -2441,11 +2781,13 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never closed: type: event description: | @@ -2457,15 +2799,21 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never opened_existing_tab: type: event description: | - A user opened an existing tab + A user opened an existing tab from a particular app feature. + extra_keys: + source: + description: | + From which app feature was an existing tab opened. bugs: - https://github.com/mozilla-mobile/fenix/issues/11273 data_reviews: @@ -2473,15 +2821,22 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/20508#issuecomment-902160532 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never closed_existing_tab: type: event description: | - A user closed an existing tab + A user closed an existing tab from a particular app feature. + extra_keys: + source: + description: | + From which app feature was an existing tab closed. bugs: - https://github.com/mozilla-mobile/fenix/issues/11273 data_reviews: @@ -2489,11 +2844,14 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/20508#issuecomment-902160532 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never private_mode_tapped: type: event description: | @@ -2505,11 +2863,13 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never normal_mode_tapped: type: event description: | @@ -2521,11 +2881,13 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never synced_mode_tapped: type: event description: | @@ -2535,11 +2897,13 @@ tabs_tray: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/19004 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-08-01" + - erichards@mozilla.com + expires: never new_tab_tapped: type: event description: | @@ -2551,11 +2915,13 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never new_private_tab_tapped: type: event description: | @@ -2567,11 +2933,13 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never menu_opened: type: event description: | @@ -2583,11 +2951,13 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never save_to_collection: type: event description: | @@ -2599,11 +2969,13 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never share_all_tabs: type: event description: | @@ -2616,11 +2988,13 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never close_all_tabs: type: event description: | @@ -2633,121 +3007,270 @@ tabs_tray: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" - -collections: - renamed: + - erichards@mozilla.com + expires: never + inactive_tabs_recently_closed: type: event description: | - A user renamed a collection + A user tapped the "Recently closed" option of the inactive tabs menu. bugs: - - https://github.com/mozilla-mobile/fenix/issues/969 - - https://github.com/mozilla-mobile/fenix/issues/19923 + - https://github.com/mozilla-mobile/fenix/issues/20328 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/3935 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/issues/20328 data_sensitivity: - - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" - tab_restored: + expires: "2022-08-01" + inactive_tabs_expanded: type: event description: | - A user restored a tab from collection tab list + A user tapped the "Inactive tabs" header to expand + the group of inactive tabs. bugs: - - https://github.com/mozilla-mobile/fenix/issues/969 - - https://github.com/mozilla-mobile/fenix/issues/19923 + - https://github.com/mozilla-mobile/fenix/issues/20507 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/3935 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/20508#issuecomment-901336677 data_sensitivity: - - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" - all_tabs_restored: + expires: "2022-08-01" + inactive_tabs_collapsed: type: event description: | - A user tapped "open tabs" from collection menu + A user tapped the "Inactive tabs" header to collapse + the group of inactive tabs. bugs: - - https://github.com/mozilla-mobile/fenix/issues/969 - - https://github.com/mozilla-mobile/fenix/issues/19923 + - https://github.com/mozilla-mobile/fenix/issues/20507 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/3935 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/20508#issuecomment-901336677 data_sensitivity: - - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" - tab_removed: + expires: "2022-08-01" + has_inactive_tabs: type: event description: | - A user tapped remove tab from collection tab list + A boolean that indicates if the user has any INACTIVE tabs. + extra_keys: + inactive_tabs_count: + description: "The number of inactive tabs the user currently has." bugs: - - https://github.com/mozilla-mobile/fenix/issues/969 - - https://github.com/mozilla-mobile/fenix/issues/19923 + - https://github.com/mozilla-mobile/fenix/issues/21903 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/3935 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21908 data_sensitivity: - - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" - shared: + expires: "2022-11-01" + close_all_inactive_tabs: type: event description: | - A user tapped share collection + A user tapped the close all inactive tabs button in the the tabs tray bugs: - - https://github.com/mozilla-mobile/fenix/issues/969 - - https://github.com/mozilla-mobile/fenix/issues/19923 + - https://github.com/mozilla-mobile/fenix/issues/21903 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/3935 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21908 data_sensitivity: - - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" - removed: + expires: "2022-11-01" + auto_close_seen: type: event description: | - A user tapped delete collection from collection menu + A user has seen the auto-close dialog for inactive tabs. bugs: - - https://github.com/mozilla-mobile/fenix/issues/969 - - https://github.com/mozilla-mobile/fenix/issues/19923 + - https://github.com/mozilla-mobile/fenix/issues/22170 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/3935 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/22171 data_sensitivity: - - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-11-01" + auto_close_turn_on_clicked: + type: event + description: | + A user has clicked the turn-on auto-close button for inactive tabs. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22170 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22171 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + auto_close_dimissed: + type: event + description: | + A user has dimissed the auto-close dialog for inactive tabs. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22170 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22171 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + close_inactive_tab: + type: counter + description: | + A counter that indicates how many INACTIVE tabs a user has closed. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21903 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21908 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + open_inactive_tab: + type: counter + description: | + A counter that indicates how many INACTIVE tabs a user has opened. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21903 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21908 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + +collections: + renamed: + type: event + description: | + A user renamed a collection + bugs: + - https://github.com/mozilla-mobile/fenix/issues/969 + - https://github.com/mozilla-mobile/fenix/issues/19923 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/3935 + - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 + - https://github.com/mozilla-mobile/fenix/pull/18143 + - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 + data_sensitivity: + - technical + - interaction + notification_emails: + - android-probes@mozilla.com + - erichards@mozilla.com + expires: never + tab_restored: + type: event + description: | + A user restored a tab from collection tab list + bugs: + - https://github.com/mozilla-mobile/fenix/issues/969 + - https://github.com/mozilla-mobile/fenix/issues/19923 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/3935 + - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 + - https://github.com/mozilla-mobile/fenix/pull/18143 + - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 + data_sensitivity: + - technical + - interaction + notification_emails: + - android-probes@mozilla.com + - erichards@mozilla.com + expires: never + all_tabs_restored: + type: event + description: | + A user tapped "open tabs" from collection menu + bugs: + - https://github.com/mozilla-mobile/fenix/issues/969 + - https://github.com/mozilla-mobile/fenix/issues/19923 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/3935 + - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 + - https://github.com/mozilla-mobile/fenix/pull/18143 + - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 + data_sensitivity: + - technical + - interaction + notification_emails: + - android-probes@mozilla.com + - erichards@mozilla.com + expires: never + tab_removed: + type: event + description: | + A user tapped remove tab from collection tab list + bugs: + - https://github.com/mozilla-mobile/fenix/issues/969 + - https://github.com/mozilla-mobile/fenix/issues/19923 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/3935 + - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 + - https://github.com/mozilla-mobile/fenix/pull/18143 + - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 + data_sensitivity: + - technical + - interaction + notification_emails: + - android-probes@mozilla.com + - erichards@mozilla.com + expires: never + shared: + type: event + description: | + A user tapped share collection + bugs: + - https://github.com/mozilla-mobile/fenix/issues/969 + - https://github.com/mozilla-mobile/fenix/issues/19923 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/3935 + - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 + - https://github.com/mozilla-mobile/fenix/pull/18143 + - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 + data_sensitivity: + - technical + - interaction + notification_emails: + - android-probes@mozilla.com + - erichards@mozilla.com + expires: never + removed: + type: event + description: | + A user tapped delete collection from collection menu + bugs: + - https://github.com/mozilla-mobile/fenix/issues/969 + - https://github.com/mozilla-mobile/fenix/issues/19923 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/3935 + - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 + - https://github.com/mozilla-mobile/fenix/pull/18143 + - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 + data_sensitivity: + - technical + - interaction + notification_emails: + - android-probes@mozilla.com + - erichards@mozilla.com + expires: never saved: type: event description: | @@ -2765,12 +3288,14 @@ collections: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never tabs_added: type: event description: | @@ -2788,12 +3313,14 @@ collections: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never tab_select_opened: type: event description: | @@ -2807,12 +3334,14 @@ collections: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never add_tab_button: type: event description: | @@ -2825,11 +3354,13 @@ collections: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never long_press: type: event description: | @@ -2842,11 +3373,13 @@ collections: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never save_button: type: event description: | @@ -2861,11 +3394,13 @@ collections: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never extra_keys: from_screen: description: | @@ -2883,11 +3418,13 @@ collections: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never search_widget: new_tab_button: @@ -2924,77 +3461,6 @@ search_widget: - android-probes@mozilla.com expires: "2022-02-01" -contextual_hint.tracking_protection: - display: - type: event - description: | - The enhanced tracking protection contextual hint was - displayed. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/9625 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/11923 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - data_sensitivity: - - interaction - notification_emails: - - android-probes@mozilla.com - expires: "2021-07-01" - dismiss: - type: event - description: | - The enhanced tracking protection contextual hint was - dismissed - by pressing the close button - bugs: - - https://github.com/mozilla-mobile/fenix/issues/9625 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/11923 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - data_sensitivity: - - interaction - notification_emails: - - android-probes@mozilla.com - expires: "2021-07-01" - outside_tap: - type: event - description: | - The user tapped outside of the etp contextual hint - (which has no effect). - bugs: - - https://github.com/mozilla-mobile/fenix/issues/9625 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/11923 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - data_sensitivity: - - interaction - notification_emails: - - android-probes@mozilla.com - expires: "2021-07-01" - inside_tap: - type: event - description: | - The user tapped inside of the etp contextual hint - (which brings up the etp panel for this site). - bugs: - - https://github.com/mozilla-mobile/fenix/issues/9625 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/11923 - - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - - https://github.com/mozilla-mobile/fenix/pull/18143 - - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - data_sensitivity: - - interaction - notification_emails: - - android-probes@mozilla.com - expires: "2021-07-01" - tracking_protection: exception_added: type: event @@ -3263,11 +3729,13 @@ logins: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never open_individual_login: type: event description: | @@ -3416,11 +3884,13 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never open_google_search_attribution: type: event description: | @@ -3431,6 +3901,21 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/17637 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + - erichards@mozilla.com + expires: never + open_baidu_search_attribution: + type: event + description: | + A user opened the baidu top site + bugs: + - https://github.com/mozilla-mobile/fenix/issues/19490 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20705 data_sensitivity: - interaction notification_emails: @@ -3447,11 +3932,13 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never open_pinned: type: event description: | @@ -3463,11 +3950,13 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never swipe_carousel: type: event description: | @@ -3483,11 +3972,13 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never long_press: type: event description: | @@ -3503,11 +3994,13 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never open_in_new_tab: type: event description: | @@ -3519,11 +4012,13 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never open_in_private_tab: type: event description: | @@ -3535,11 +4030,13 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never remove: type: event description: | @@ -3551,18 +4048,46 @@ top_sites: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" - -app_theme: - dark_theme_selected: + - erichards@mozilla.com + expires: never + google_top_site_removed: type: event description: | - A user selected Dark Theme - extra_keys: + A user removed the default Google top site + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21841 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21845#issuecomment-944608568 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-02-01" + baidu_top_site_removed: + type: event + description: | + A user removed the default Baidu top site + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21841 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21845#issuecomment-944608568 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-02-01" + +app_theme: + dark_theme_selected: + type: event + description: | + A user selected Dark Theme + extra_keys: source: description: | The source from where dark theme was selected. The source can be @@ -3575,11 +4100,12 @@ app_theme: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-12-01" pocket: pocket_top_site_clicked: @@ -3593,11 +4119,13 @@ pocket: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never no_lint: - COMMON_PREFIX pocket_top_site_removed: @@ -3616,6 +4144,91 @@ pocket: notification_emails: - android-probes@mozilla.com expires: "2022-02-01" + home_recs_shown: + type: event + description: | + The Pocket recommended stories are shown on the home screen. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21593 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21625#issuecomment-936745506 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-10-01" + home_recs_story_clicked: + type: event + description: | + User tapped a Pocket recommended story to be opened. + extra_keys: + times_shown: + description: | + How many times was this story shown, including current. + position: + description: | + Position of the clicked story in the list shown. + Uses the [row x column] matrix notation. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21593 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21625#issuecomment-936745506 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-10-01" + home_recs_category_clicked: + type: event + description: | + User tapped a Pocket stories category to filter stories. + extra_keys: + category_name: + description: | + Pocket set topic name representing the just clicked category. + selected_total: + description: | + How many categories were selected before this being tapped. + new_state: + description: | + Category's new state after being tapped. + Possible values: [selected], [deselected]. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21593 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21625#issuecomment-936745506 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-10-01" + home_recs_discover_clicked: + type: event + description: | + User tapped the "Discover more" tile to open a new tab + for more Pocket stories. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21593 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21625#issuecomment-936745506 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-10-01" + home_recs_learn_more_clicked: + type: event + description: | + User tapped "Learn more" to open a new tab for Pocket. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/21593 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21625#issuecomment-936745506 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-10-01" first_session: campaign: @@ -3631,12 +4244,14 @@ first_session: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never network: type: string send_in_pings: @@ -3650,12 +4265,14 @@ first_session: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never adgroup: type: string send_in_pings: @@ -3669,12 +4286,14 @@ first_session: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never creative: send_in_pings: - first-session @@ -3688,12 +4307,14 @@ first_session: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never timestamp: send_in_pings: - first-session @@ -3709,12 +4330,14 @@ first_session: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never browser.search: with_ads: @@ -3731,11 +4354,13 @@ browser.search: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20230#issuecomment-879244938 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-07-01" + - erichards@mozilla.com + expires: never ad_clicks: type: labeled_counter description: | @@ -3750,11 +4375,13 @@ browser.search: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20230#issuecomment-879244938 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-07-01" + - erichards@mozilla.com + expires: never in_content: type: labeled_counter description: | @@ -3768,11 +4395,13 @@ browser.search: - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20230#issuecomment-879244938 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-07-01" + - erichards@mozilla.com + expires: never addons: open_addons_in_settings: @@ -3787,11 +4416,12 @@ addons: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-08-01" open_addon_in_toolbar_menu: type: event description: | @@ -3808,11 +4438,12 @@ addons: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + expires: "2022-08-01" open_addon_setting: type: event description: | @@ -3845,11 +4476,13 @@ addons: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never has_enabled_addons: type: boolean description: | @@ -3864,11 +4497,13 @@ addons: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never installed_addons: type: string_list description: | @@ -3883,11 +4518,13 @@ addons: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21788#issuecomment-950022224 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never enabled_addons: type: string_list description: | @@ -3902,134 +4539,12 @@ addons: - https://github.com/mozilla-mobile/fenix/pull/13958#issuecomment-676857877 - https://github.com/mozilla-mobile/fenix/pull/18143 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" - -startup.timeline: - framework_primary: - disabled: true - send_in_pings: - - startup-timeline - type: timespan - time_unit: millisecond - description: | - The duration the Android framework takes to start before letting us run - code in `*Application.init` when this device has - `clock_ticks_per_second_v2` equal to 100: if it's not equal to 100, then - this value is captured in `framework_secondary`. We split this into two - metrics to make it easier to analyze in GLAM. We split on 100 because - when we did our initial brief analysis - - https://sql.telemetry.mozilla.org/queries/75591 - the results for clocks - ticks were overwhelmingly 100. - - The duration is calculated from `appInitTimestamp - - processStartTimestamp`. `processStartTimestamp` is derived from the clock - tick time unit, which is expected to be less granular than nanoseconds. - Therefore, we convert and round our timestamps to clock ticks before - computing the difference and convert back to nanoseconds to report. - - For debugging purposes, `clock_ticks_per_second_v2`, which may vary - between devices, is also reported as a metric - bugs: - - https://github.com/mozilla-mobile/fenix/issues/8803 - - https://github.com/mozilla-mobile/fenix/issues/17972 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/9788#pullrequestreview-394228626 - - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - - https://github.com/mozilla-mobile/fenix/pull/18043#issue-575389284 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-01" - framework_secondary: - disabled: true - send_in_pings: - - startup-timeline - type: timespan - time_unit: millisecond - description: | - The duration the Android framework takes to start before letting us run - code in `*Application.init` when this device has - `clock_ticks_per_second_v2` not equal to 100. For more details on this - metric, see `framework_primary` - bugs: - - https://github.com/mozilla-mobile/fenix/issues/17972 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18043#issue-575389284 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-01" - framework_start_error: - disabled: true - send_in_pings: - - startup-timeline - type: boolean - description: | - An error when attempting to record `framework_primary/secondary` - the - application init timestamp returned a negative value - which is likely - indicative of a bug in the implementation. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/8803 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/9788#pullrequestreview-394228626 - - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-01" - framework_start_read_error: - disabled: true - send_in_pings: - - startup-timeline - type: boolean - description: | - An error when attempting to read stats from /proc pseudo-filesystem - - privacy managers can block access to reading these files - - the application will catch a file reading exception. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/10434 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/10481 - - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-01" - clock_ticks_per_second_v2: - disabled: true - send_in_pings: - - startup-timeline - type: quantity - unit: clock ticks per second - description: | - The number of clock tick time units that occur in one second on this - particular device. This value is expected to be used in conjunction with - the `framework_primary/secondary` metrics. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/8803 - - https://github.com/mozilla-mobile/fenix/issues/18157 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/9788#pullrequestreview-394228626 - - https://github.com/mozilla-mobile/fenix/pull/15713#issuecomment-703972068 - - https://github.com/mozilla-mobile/fenix/pull/18158#issue-579593943 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-01" + expires: "2022-08-01" perf.startup: cold_main_app_to_first_frame: @@ -4056,251 +4571,92 @@ perf.startup: The hope is that these cases will not have a significant impact on the end results but, if they appear to, we can replace it with a more complex implementation. -

- Around April 8, 2021 the implementation was refactored. Functionally, it - should be the same but it's noted just in case there are bugs. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/18426 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18632#issue-600193452 - - https://github.com/mozilla-mobile/fenix/pull/20623#issue-701630599 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2022-02-01" - cold_view_app_to_first_frame: - type: timing_distribution - time_unit: millisecond - description: | - The duration from `*Application`'s initializer to the first Android frame - being drawn in a [COLD VIEW start - up](https://wiki.mozilla.org/index.php?title=Performance/Fenix/Glossary). - The methodology for determining this measurement is imperfect to simplify - implementation. Issues may include: -
-Including VIEW intents that aren't valid so take code paths similar - to MAIN (this is speculative) -

- See the `cold_main_app_to_first_frame` probe docs for other possible - known issues and more details. -

- Around April 8, 2021 the implementation was refactored. Functionally, it - should be the same but it's noted just in case there are bugs. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/18426 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18632#issue-600193452 - - https://github.com/mozilla-mobile/fenix/pull/20623#issue-701630599 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2022-02-01" - cold_unknwn_app_to_first_frame: - type: timing_distribution - time_unit: millisecond - description: | - The duration from `*Application`'s initializer to the first Android frame - being drawn in a [COLD start - up](https://wiki.mozilla.org/index.php?title=Performance/Fenix/Glossary) - where we can't say it was a MAIN or VIEW start up. The methodology for - determining this measurement is imperfect to simplify implementation. -

- See the `cold_main_app_to_first_frame` probe docs for known issues and - more details. -

- Around April 8, 2021 the implementation was refactored. Functionally, it - should be the same but it's noted just in case there are bugs. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/18426 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18632#issue-600193452 - - https://github.com/mozilla-mobile/fenix/pull/20623#issue-701630599 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2022-02-01" - application_on_create: - type: timing_distribution - time_unit: millisecond - description: | - The duration of `FenixApplication.onCreate` in the main process. This does - not measure the duration of migration code (via - `MigratingFenixApplication` included in the Beta and Release channels. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/17969 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/17973#issue-572183889 - - https://github.com/mozilla-mobile/fenix/pull/20623#issue-701630599 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2022-02-01" - app_on_create_to_glean_init: - disabled: true - type: timing_distribution - time_unit: millisecond - description: | - A subsection of the duration of `FenixApplication.onCreate` and thus the - `application_on_create` probe from the start of the method through when - `initializeGlean` is called. Note: `initializeGlean` is a no-op for Beta - and Release builds which instead initialize it during - `MigratingFenixApplication`, which we don't currently measure. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/18426 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18525#issue-594961170 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-11" - app_on_create_to_megazord_init: - disabled: true - type: timing_distribution - time_unit: millisecond - description: | - A subsection of the duration of `FenixApplication.onCreate` and thus the - `application_on_create` probe from after the `app_on_create_to_glean_init` - probe until we block for the megazord to complete set up. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/18426 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18525#issue-594961170 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-11" - app_on_create_to_setup_in_main: - disabled: true - type: timing_distribution - time_unit: millisecond - description: | - A subsection of the duration of `FenixApplication.onCreate` and thus the - `application_on_create` probe from after the - `app_on_create_to_megazord_init` probe until the end of - `setupInMainProcessOnly`, which is expected to be the end of the - `onCreate` call (unless the implementation later changes). - bugs: - - https://github.com/mozilla-mobile/fenix/issues/18426 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18525#issue-594961170 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-11" - home_activity_on_create: - disabled: true - type: timing_distribution - time_unit: millisecond - description: | - The duration of `HomeActivity.onCreate`. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/17969 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/17973#issue-572183889 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-11" - home_activity_on_start: - disabled: true - type: timing_distribution - time_unit: millisecond - description: | - The duration of `HomeActivity.onStart`. This may encapsulate - `HomeFragment` or `BrowserFragment` creation, depending on the code path, - so we expect this to take varying amounts of time. As such, this probe may - not be easy to interpret directly but we believe collecting it may give us - more information about different patterns we might see in performance - data. - bugs: - - https://github.com/mozilla-mobile/fenix/issues/18426 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18558#issue-596791848 - data_sensitivity: - - technical - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com - expires: "2021-08-11" - home_fragment_on_create_view: - disabled: true - type: timing_distribution - time_unit: millisecond - description: | - The duration of `HomeFragment.onCreateView`. +

+ Around April 8, 2021 the implementation was refactored. Functionally, it + should be the same but it's noted just in case there are bugs. bugs: - https://github.com/mozilla-mobile/fenix/issues/18426 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18558#issue-596791848 + - https://github.com/mozilla-mobile/fenix/pull/18632#issue-600193452 + - https://github.com/mozilla-mobile/fenix/pull/20623#issue-701630599 data_sensitivity: - technical notification_emails: - perf-android-fe@mozilla.com - mcomella@mozilla.com - expires: "2021-08-11" - home_fragment_on_view_created: - disabled: true + expires: "2022-02-01" + cold_view_app_to_first_frame: type: timing_distribution time_unit: millisecond description: | - The duration of `HomeFragment.onViewCreated`. + The duration from `*Application`'s initializer to the first Android frame + being drawn in a [COLD VIEW start + up](https://wiki.mozilla.org/index.php?title=Performance/Fenix/Glossary). + The methodology for determining this measurement is imperfect to simplify + implementation. Issues may include: +
-Including VIEW intents that aren't valid so take code paths similar + to MAIN (this is speculative) +

+ See the `cold_main_app_to_first_frame` probe docs for other possible + known issues and more details. +

+ Around April 8, 2021 the implementation was refactored. Functionally, it + should be the same but it's noted just in case there are bugs. bugs: - https://github.com/mozilla-mobile/fenix/issues/18426 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18558#issue-596791848 + - https://github.com/mozilla-mobile/fenix/pull/18632#issue-600193452 + - https://github.com/mozilla-mobile/fenix/pull/20623#issue-701630599 data_sensitivity: - technical notification_emails: - perf-android-fe@mozilla.com - mcomella@mozilla.com - expires: "2021-08-11" - base_bfragment_on_create_view: - disabled: true + expires: "2022-02-01" + cold_unknwn_app_to_first_frame: type: timing_distribution time_unit: millisecond description: | - The duration of `BaseBrowserFragment.onCreateView`. + The duration from `*Application`'s initializer to the first Android frame + being drawn in a [COLD start + up](https://wiki.mozilla.org/index.php?title=Performance/Fenix/Glossary) + where we can't say it was a MAIN or VIEW start up. The methodology for + determining this measurement is imperfect to simplify implementation. +

+ See the `cold_main_app_to_first_frame` probe docs for known issues and + more details. +

+ Around April 8, 2021 the implementation was refactored. Functionally, it + should be the same but it's noted just in case there are bugs. bugs: - https://github.com/mozilla-mobile/fenix/issues/18426 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18558#issue-596791848 + - https://github.com/mozilla-mobile/fenix/pull/18632#issue-600193452 + - https://github.com/mozilla-mobile/fenix/pull/20623#issue-701630599 data_sensitivity: - technical notification_emails: - perf-android-fe@mozilla.com - mcomella@mozilla.com - expires: "2021-08-11" - base_bfragment_on_view_created: - disabled: true + expires: "2022-02-01" + application_on_create: type: timing_distribution time_unit: millisecond description: | - The duration of `BaseBrowserFragment.onViewCreated`. + The duration of `FenixApplication.onCreate` in the main process. This does + not measure the duration of migration code (via + `MigratingFenixApplication` included in the Beta and Release channels. bugs: - - https://github.com/mozilla-mobile/fenix/issues/18426 + - https://github.com/mozilla-mobile/fenix/issues/17969 data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/18558#issue-596791848 + - https://github.com/mozilla-mobile/fenix/pull/17973#issue-572183889 + - https://github.com/mozilla-mobile/fenix/pull/20623#issue-701630599 data_sensitivity: - technical notification_emails: - perf-android-fe@mozilla.com - mcomella@mozilla.com - expires: "2021-08-11" + expires: "2022-02-01" startup_type: type: labeled_counter description: | @@ -4384,134 +4740,141 @@ perf.awesomebar: - metrics type: timing_distribution time_unit: millisecond - description: > + description: | Duration of a history awesomebar suggestion query. bugs: - https://github.com/mozilla-mobile/android-components/issues/4992 data_reviews: - https://github.com/mozilla-mobile/fenix/pull/10276#pullrequestreview-411101979 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21315#issuecomment-920848442 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - gkruglov@mozilla.com - expires: "2021-11-15" + expires: "2022-11-15" bookmark_suggestions: send_in_pings: - metrics type: timing_distribution time_unit: millisecond - description: > + description: | Duration of a bookmarks awesomebar suggestion query. bugs: - https://github.com/mozilla-mobile/android-components/issues/4992 data_reviews: - https://github.com/mozilla-mobile/fenix/pull/10276#pullrequestreview-411101979 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21315#issuecomment-920848442 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - gkruglov@mozilla.com - expires: "2021-11-15" + expires: "2022-11-15" search_engine_suggestions: send_in_pings: - metrics type: timing_distribution time_unit: millisecond - description: > + description: | Duration of a search engine awesomebar suggestion query. bugs: - https://github.com/mozilla-mobile/android-components/issues/4992 data_reviews: - https://github.com/mozilla-mobile/fenix/pull/10276#pullrequestreview-411101979 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21315#issuecomment-920848442 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - gkruglov@mozilla.com - expires: "2021-11-15" + expires: "2022-11-15" session_suggestions: send_in_pings: - metrics type: timing_distribution time_unit: millisecond - description: > + description: | Duration of a session awesomebar suggestion query. bugs: - https://github.com/mozilla-mobile/android-components/issues/4992 data_reviews: - https://github.com/mozilla-mobile/fenix/pull/10276#pullrequestreview-411101979 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21315#issuecomment-920848442 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - gkruglov@mozilla.com - expires: "2021-11-15" + expires: "2022-11-15" synced_tabs_suggestions: send_in_pings: - metrics type: timing_distribution time_unit: millisecond - description: > + description: | Duration of a synced tabs awesomebar suggestion query. bugs: - https://github.com/mozilla-mobile/android-components/issues/4992 data_reviews: - https://github.com/mozilla-mobile/fenix/pull/10276#pullrequestreview-411101979 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21315#issuecomment-920848442 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - gkruglov@mozilla.com - expires: "2021-11-15" + expires: "2022-11-15" clipboard_suggestions: send_in_pings: - metrics type: timing_distribution time_unit: millisecond - description: > + description: | Duration of a clipboard awesomebar suggestion query. bugs: - https://github.com/mozilla-mobile/android-components/issues/4992 data_reviews: - https://github.com/mozilla-mobile/fenix/pull/10276#pullrequestreview-411101979 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21315#issuecomment-920848442 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - gkruglov@mozilla.com - expires: "2021-11-15" + expires: "2022-11-15" shortcuts_suggestions: send_in_pings: - metrics type: timing_distribution time_unit: millisecond - description: > + description: | Duration of a shortcuts awesomebar suggestion query. bugs: - https://github.com/mozilla-mobile/android-components/issues/4992 data_reviews: - https://github.com/mozilla-mobile/fenix/pull/10276#pullrequestreview-411101979 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21315#issuecomment-920848442 data_sensitivity: - technical - interaction notification_emails: - android-probes@mozilla.com - gkruglov@mozilla.com - expires: "2021-11-15" + expires: "2022-11-15" autoplay: visited_setting: @@ -4555,7 +4918,7 @@ storage.stats: send_in_pings: - metrics type: timing_distribution - description: > + description: | How long it took to query the device for the StorageStats that contain the file size information. The docs say it may be expensive so we want to ensure it's not too expensive. This value is only available on Android @@ -4579,7 +4942,7 @@ storage.stats: send_in_pings: - metrics type: memory_distribution - description: > + description: | The size of the app's APK and related files as installed: this is expected to be larger than download size. This is the output of [StorageStats.getAppBytes](https://developer.android.com/reference/android/app/usage/StorageStats#getAppBytes()) @@ -4606,7 +4969,7 @@ storage.stats: send_in_pings: - metrics type: memory_distribution - description: > + description: | The size of all cached data in the app. This is the output of [StorageStats.getCacheBytes](https://developer.android.com/reference/android/app/usage/StorageStats#getCacheBytes()) so see that for details. This value is only available on Android 8+. @@ -4630,7 +4993,7 @@ storage.stats: send_in_pings: - metrics type: memory_distribution - description: > + description: | The size of all data minus `cache_bytes`. This is the output of [StorageStats.getDataBytes](https://developer.android.com/reference/android/app/usage/StorageStats#getDataBytes()) except we subtract the value of `cache_bytes` so the cache is not measured @@ -4742,11 +5105,13 @@ tabs: - https://github.com/mozilla-mobile/fenix/pull/15811#issuecomment-706402952 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never contextual_menu: copy_tapped: @@ -4759,11 +5124,13 @@ contextual_menu: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/16968 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never search_tapped: type: event description: | @@ -4774,11 +5141,13 @@ contextual_menu: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/16968 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never select_all_tapped: type: event description: | @@ -4789,11 +5158,13 @@ contextual_menu: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/16968 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never share_tapped: type: event description: | @@ -4804,11 +5175,13 @@ contextual_menu: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/16968 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2021-12-01" + - erichards@mozilla.com + expires: never engine_tab: kills: @@ -4824,12 +5197,13 @@ engine_tab: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/17864 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21779#issuecomment-938089467 data_sensitivity: - technical notification_emails: - android-probes@mozilla.com - skaspari@mozilla.com - expires: "2021-12-31" + expires: "2022-07-01" kill_foreground_age: type: timing_distribution time_unit: millisecond @@ -4841,12 +5215,13 @@ engine_tab: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/17864 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21779#issuecomment-938089467 data_sensitivity: - technical notification_emails: - android-probes@mozilla.com - skaspari@mozilla.com - expires: "2021-12-31" + expires: "2022-07-01" kill_background_age: type: timing_distribution time_unit: millisecond @@ -4858,12 +5233,13 @@ engine_tab: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/17864 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21779#issuecomment-938089467 data_sensitivity: - technical notification_emails: - android-probes@mozilla.com - skaspari@mozilla.com - expires: "2021-12-31" + expires: "2022-07-01" foreground_metrics: type: event description: | @@ -4874,12 +5250,13 @@ engine_tab: data_reviews: - https://github.com/mozilla-mobile/fenix/pull/18747#issuecomment-815731764 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 + - https://github.com/mozilla-mobile/fenix/pull/21779#issuecomment-938089467 data_sensitivity: - technical notification_emails: - android-probes@mozilla.com - skaspari@mozilla.com - expires: "2021-12-31" + expires: "2022-07-01" extra_keys: background_active_tabs: description: | @@ -4934,11 +5311,13 @@ awesomebar: - https://github.com/mozilla-mobile/fenix/pull/18090 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never clipboard_suggestion_clicked: type: event description: | @@ -4949,11 +5328,13 @@ awesomebar: - https://github.com/mozilla-mobile/fenix/pull/18090 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never history_suggestion_clicked: type: event description: | @@ -4964,11 +5345,13 @@ awesomebar: - https://github.com/mozilla-mobile/fenix/pull/18090 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never search_action_clicked: type: event description: | @@ -4979,11 +5362,13 @@ awesomebar: - https://github.com/mozilla-mobile/fenix/pull/18090 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never search_suggestion_clicked: type: event description: | @@ -4994,11 +5379,13 @@ awesomebar: - https://github.com/mozilla-mobile/fenix/pull/18090 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never opened_tab_suggestion_clicked: type: event description: | @@ -5009,11 +5396,13 @@ awesomebar: - https://github.com/mozilla-mobile/fenix/pull/18090 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 - https://github.com/mozilla-mobile/fenix/pull/20517#pullrequestreview-718069041 + - https://github.com/mozilla-mobile/fenix/pull/21038#issuecomment-906757301 data_sensitivity: - interaction notification_emails: - android-probes@mozilla.com - expires: "2022-02-01" + - erichards@mozilla.com + expires: never android_autofill: supported: @@ -5024,7 +5413,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - technical notification_emails: @@ -5039,7 +5428,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5054,7 +5443,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5069,7 +5458,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5084,7 +5473,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5098,7 +5487,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5113,7 +5502,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5127,7 +5516,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5142,7 +5531,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5156,7 +5545,7 @@ android_autofill: bugs: - https://github.com/mozilla-mobile/android-components/issues/10301 data_reviews: - - TBD + - https://github.com/mozilla-mobile/fenix/pull/20547#issuecomment-889051503 data_sensitivity: - interaction notification_emails: @@ -5183,9 +5572,9 @@ home_menu: home_screen: home_screen_displayed: type: event - description: The user clicked the settings option in home menu. + description: The home screen was displayed. bugs: - - https://github.com/mozilla-mobile/fenix/issues/18856 + - https://github.com/mozilla-mobile/fenix/issues/18854 data_reviews: - https://github.com/mozilla-mobile/fenix/pull/19025 - https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789 @@ -5195,6 +5584,19 @@ home_screen: notification_emails: - android-probes@mozilla.com expires: "2022-04-01" + customize_home_clicked: + type: event + description: A user clicked on Customize home from the home screen menu. + bugs: + - https://github.com/mozilla-mobile/fenix/pull/21344 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/21344 + - https://github.com/mozilla-mobile/fenix/pull/21344#issuecomment-923198787 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-20" android_keystore_experiment: experiment_failure: @@ -5424,3 +5826,236 @@ recent_tabs: notification_emails: - android-probes@mozilla.com expires: "2022-06-23" + section_visible: + type: boolean + description: | + An indication of whether the recent tabs / + Jump Back In section is visible on the homepage. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22107 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22166 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + +recent_bookmarks: + shown: + type: event + description: | + Recent bookmarks section was shown to the user. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22103 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22104 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + bookmark_clicked: + type: counter + lifetime: application + description: | + A counter that indicates the number of times that a user + has clicked on a recently saved bookmark from the home + screen. + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/19931 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20316#issuecomment-888291843 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-02-01" + show_all_bookmarks: + type: counter + lifetime: application + description: | + A counter that indicates the number of times that a user + has clicked the show all button for recently saved bookmarks + on the home screen. + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/19931 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20316#issuecomment-888291843 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-02-01" + recent_bookmarks_count: + type: quantity + description: | + The number of bookmarked items appearing in the + Recently Saved section on the home page. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22075 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/22293 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-11-01" + unit: integer + +recent_searches: + group_deleted: + type: event + description: | + A user has deleted a search term group from the + "Recent searches" section on the homescreen using + the long-press menu "Remove" option. This removes + the item from the homescreen, but does not delete + the item from history. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/22175 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/TBD + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-04-01" + +credit_cards: + saved: + type: counter + description: | + A counter of the number of credit cards that have been saved + manually by the user. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + deleted: + type: counter + description: | + A counter of the number of credit cards that have been deleted by + the user. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + modified: + type: event + description: | + A credit card has been modified by the user. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + form_detected: + type: event + description: | + A credit card form was detected. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + autofilled: + type: event + description: | + User has autofilled a credit card. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + autofill_prompt_shown: + type: event + description: | + Credit card autofill prompt was shown. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + autofill_prompt_expanded: + type: event + description: | + Credit card autofill prompt was expanded. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + autofill_prompt_dismissed: + type: event + description: | + Credit card autofill prompt was dismissed. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + management_add_tapped: + type: event + description: | + User has tapped the add button through credit card management settings. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" + management_card_tapped: + type: event + description: | + User has tapped on a saved card through credit card management settings. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/18711 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/20909 + data_sensitivity: + - interaction + notification_emails: + - android-probes@mozilla.com + expires: "2022-09-01" diff --git a/app/pings.yaml b/app/pings.yaml index 8839d189f5..34b33a968c 100644 --- a/app/pings.yaml +++ b/app/pings.yaml @@ -30,24 +30,3 @@ first-session: - https://github.com/mozilla-mobile/fenix/pull/8074#issuecomment-586512202 notification_emails: - android-probes@mozilla.com - -startup-timeline: - description: | - This ping is intended to provide an understanding of startup performance. - - In addition to being captured on real devices, the ping data was prematurely - optimized into this separate ping to be isolated from other metrics to be - more easily captured by performance testing automation but that hasn't - happened in practice. We would have removed it but implementation - details don't make that possible: - https://github.com/mozilla-mobile/fenix/issues/17972#issuecomment-781002987 - include_client_id: true - bugs: - - https://github.com/mozilla-mobile/fenix/issues/8803 - - https://github.com/mozilla-mobile/fenix/issues/17972 - data_reviews: - - https://github.com/mozilla-mobile/fenix/pull/9788#pullrequestreview-394228626 - - https://github.com/mozilla-mobile/fenix/pull/18043#issue-575389284 - notification_emails: - - perf-android-fe@mozilla.com - - mcomella@mozilla.com diff --git a/app/src/androidTest/java/org/mozilla/fenix/helpers/Constants.kt b/app/src/androidTest/java/org/mozilla/fenix/helpers/Constants.kt index 4e6e06509f..6e2829912c 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/helpers/Constants.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/helpers/Constants.kt @@ -5,6 +5,7 @@ object Constants { object PackageName { const val GOOGLE_PLAY_SERVICES = "com.android.vending" const val GOOGLE_APPS_PHOTOS = "com.google.android.apps.photos" + const val YOUTUBE_APP = "com.google.android.youtube" } const val LONG_CLICK_DURATION: Long = 5000 diff --git a/app/src/androidTest/java/org/mozilla/fenix/helpers/TestHelper.kt b/app/src/androidTest/java/org/mozilla/fenix/helpers/TestHelper.kt index 7df7d88cb4..b5a873f47c 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/helpers/TestHelper.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/helpers/TestHelper.kt @@ -8,6 +8,7 @@ import android.app.PendingIntent import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -21,11 +22,14 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until @@ -33,6 +37,7 @@ import kotlinx.coroutines.runBlocking import mozilla.components.support.ktx.android.content.appName import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers.allOf +import org.junit.Assert import org.mozilla.fenix.R import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.ext.waitNotNull @@ -190,4 +195,40 @@ object TestHelper { canvas.drawColor(Color.GREEN) return bitmap } + + fun isPackageInstalled(packageName: String): Boolean { + return try { + val packageManager = InstrumentationRegistry.getInstrumentation().context.packageManager + packageManager.getApplicationInfo(packageName, 0).enabled + } catch (exception: PackageManager.NameNotFoundException) { + false + } + } + + fun assertExternalAppOpens(appPackageName: String) { + if (isPackageInstalled(appPackageName)) { + Intents.intended(IntentMatchers.toPackage(appPackageName)) + } else { + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + mDevice.waitNotNull( + Until.findObject(By.text("Could not open file")), + waitingTime + ) + } + } + + fun returnToBrowser() { + val urlBar = + mDevice.findObject(UiSelector().resourceId("$packageName:id/mozac_browser_toolbar_url_view")) + do { + mDevice.pressBack() + } while ( + !urlBar.waitForExists(waitingTime) + ) + } + + fun UiDevice.waitForObjects(obj: UiObject, waitingTime: Long = TestAssetHelper.waitingTime) { + this.waitForIdle() + Assert.assertNotNull(obj.waitForExists(waitingTime)) + } } diff --git a/app/src/androidTest/java/org/mozilla/fenix/helpers/assertions/AwesomeBarAssertion.kt b/app/src/androidTest/java/org/mozilla/fenix/helpers/assertions/AwesomeBarAssertion.kt deleted file mode 100644 index 9e30988d91..0000000000 --- a/app/src/androidTest/java/org/mozilla/fenix/helpers/assertions/AwesomeBarAssertion.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.mozilla.fenix.helpers.assertions - -import android.view.View -import androidx.test.espresso.ViewAssertion -import mozilla.components.browser.awesomebar.BrowserAwesomeBar - -class AwesomeBarAssertion { - companion object { - fun suggestionsAreGreaterThan(minimumSuggestions: Int): ViewAssertion { - return ViewAssertion { view, noViewFoundException -> - if (noViewFoundException != null) throw noViewFoundException - - val suggestionsCount = getSuggestionCountFromView(view) - - if (suggestionsCount <= minimumSuggestions) - throw AssertionError("The suggestion count is less than or equal to the minimum suggestions") - } - } - - fun suggestionsAreEqualTo(expectedItemCount: Int): ViewAssertion { - return ViewAssertion { view, noViewFoundException -> - if (noViewFoundException != null) throw noViewFoundException - - val suggestionsCount = getSuggestionCountFromView(view) - - if (suggestionsCount != expectedItemCount) - throw AssertionError("The expected item count is $expectedItemCount, and the suggestions count within the AwesomeBar is $suggestionsCount") - } - } - - private fun getSuggestionCountFromView(view: View): Int { - return (view as BrowserAwesomeBar).adapter?.itemCount - ?: throw AssertionError("This view is not of type BrowserAwesomeBar") - } - } -} diff --git a/app/src/androidTest/java/org/mozilla/fenix/perf/StartupExcessiveResourceUseTest.kt b/app/src/androidTest/java/org/mozilla/fenix/perf/StartupExcessiveResourceUseTest.kt index 5640d9166c..c39dfd9971 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/perf/StartupExcessiveResourceUseTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/perf/StartupExcessiveResourceUseTest.kt @@ -4,26 +4,26 @@ package org.mozilla.fenix.perf -import android.util.Log import android.view.View import android.view.ViewGroup +import android.widget.LinearLayout import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice -import kotlinx.android.synthetic.main.activity_home.* import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.R import org.mozilla.fenix.ext.components import org.mozilla.fenix.helpers.HomeActivityTestRule // BEFORE INCREASING THESE VALUES, PLEASE CONSULT WITH THE PERF TEAM. -private const val EXPECTED_SUPPRESSION_COUNT = 11 -private const val EXPECTED_RUNBLOCKING_COUNT = 3 -private const val EXPECTED_COMPONENT_INIT_COUNT = 42 -private const val EXPECTED_VIEW_HIERARCHY_DEPTH = 12 +private const val EXPECTED_SUPPRESSION_COUNT = 19 +@Suppress("TopLevelPropertyNaming") // it's silly this would have a different naming convention b/c no const +private val EXPECTED_RUNBLOCKING_RANGE = 0..1 // CI has +1 counts compared to local runs: increment these together private const val EXPECTED_RECYCLER_VIEW_CONSTRAINT_LAYOUT_CHILDREN = 4 private const val EXPECTED_NUMBER_OF_INFLATION = 12 @@ -37,17 +37,6 @@ private val failureMsgRunBlocking = getErrorMessage( implications = "using runBlocking may block the main thread and have other negative performance implications?" ) -private val failureMsgComponentInit = getErrorMessage( - shortName = "Component init", - implications = "initializing new components on start up may be an indication that we're doing more work than necessary on start up?" -) - -private val failureMsgViewHierarchyDepth = getErrorMessage( - shortName = "view hierarchy depth", - implications = "having a deep view hierarchy can slow down measure/layout performance?" -) + "Please note that we're not sure if this is a useful metric to assert: with your feedback, " + - "we'll find out over time if it is or is not." - private val failureMsgRecyclerViewConstraintLayoutChildren = getErrorMessage( shortName = "ConstraintLayout being a common direct descendant of a RecyclerView", implications = "ConstraintLayouts are slow to inflate and are primarily used to flatten deep " + @@ -90,18 +79,14 @@ class StartupExcessiveResourceUseTest { // causing this number to fluctuate depending on device speed. We'll deal with it if it occurs. val actualSuppresionCount = activityTestRule.activity.components.strictMode.suppressionCount.get().toInt() val actualRunBlocking = RunBlockingCounter.count.get() - val actualComponentInitCount = ComponentInitCount.count.get() - val rootView = activityTestRule.activity.rootContainer - val actualViewHierarchyDepth = countAndLogViewHierarchyDepth(rootView, 1) + val rootView = activityTestRule.activity.findViewById(R.id.rootContainer) val actualRecyclerViewConstraintLayoutChildren = countRecyclerViewConstraintLayoutChildren(rootView, null) val actualNumberOfInflations = InflationCounter.inflationCount.get() assertEquals(failureMsgStrictMode, EXPECTED_SUPPRESSION_COUNT, actualSuppresionCount) - assertEquals(failureMsgRunBlocking, EXPECTED_RUNBLOCKING_COUNT, actualRunBlocking) - assertEquals(failureMsgComponentInit, EXPECTED_COMPONENT_INIT_COUNT, actualComponentInitCount) - assertEquals(failureMsgViewHierarchyDepth, EXPECTED_VIEW_HIERARCHY_DEPTH, actualViewHierarchyDepth) + assertTrue(failureMsgRunBlocking + "actual: $actualRunBlocking", actualRunBlocking in EXPECTED_RUNBLOCKING_RANGE) assertEquals( failureMsgRecyclerViewConstraintLayoutChildren, EXPECTED_RECYCLER_VIEW_CONSTRAINT_LAYOUT_CHILDREN, @@ -111,19 +96,6 @@ class StartupExcessiveResourceUseTest { } } -private fun countAndLogViewHierarchyDepth(view: View, level: Int): Int { - // Log for debugging purposes: not sure if this is actually helpful. - val indent = "| ".repeat(level - 1) - Log.d("Startup...Test", "${indent}$view") - - return if (view !is ViewGroup) { - level - } else { - val maxDepth = view.children.map { countAndLogViewHierarchyDepth(it, level + 1) }.maxOrNull() - maxDepth ?: level - } -} - private fun countRecyclerViewConstraintLayoutChildren(view: View, parent: View?): Int { val viewValue = if (parent is RecyclerView && view is ConstraintLayout) { 1 diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/BookmarksTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/BookmarksTest.kt index 747aed42d9..98be189808 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/BookmarksTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/BookmarksTest.kt @@ -16,6 +16,7 @@ import org.junit.Rule import org.junit.Test import org.mozilla.fenix.R import org.mozilla.fenix.ext.bookmarkStorage +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityTestRule import org.mozilla.fenix.helpers.RecyclerViewIdlingResource @@ -51,6 +52,8 @@ class BookmarksTest { dispatcher = AndroidAssetDispatcher() start() } + val settings = activityTestRule.activity.settings() + settings.shouldShowJumpBackInCFR = false } @After @@ -68,6 +71,25 @@ class BookmarksTest { } } + @Test + fun verifyEmptyBookmarksMenuTest() { + homeScreen { + }.openThreeDotMenu { + }.openBookmarks { + bookmarksListIdlingResource = + RecyclerViewIdlingResource( + activityTestRule.activity.findViewById(R.id.bookmark_list), + 1 + ) + IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) + + verifyBookmarksMenuView() + verifyAddFolderButton() + verifyCloseButton() + verifyBookmarkTitle("Desktop Bookmarks") + } + } + @Test fun defaultDesktopBookmarksFoldersTest() { homeScreen { @@ -187,6 +209,15 @@ class BookmarksTest { }.openThreeDotMenu(defaultWebPage.url) { }.clickCopy { verifyCopySnackBarText() + navigateUp() + } + + navigationToolbar { + }.clickUrlbar { + clickClearButton() + longClickToolbar() + clickPasteText() + verifyPastedToolbarText(defaultWebPage.url.toString()) } } @@ -316,6 +347,8 @@ class BookmarksTest { @Test fun openSelectionInNewTabTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) browserScreen { @@ -394,6 +427,38 @@ class BookmarksTest { } } + @Test + fun undoDeleteMultipleSelectionTest() { + val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + val secondWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 2) + + browserScreen { + createBookmark(firstWebPage.url) + createBookmark(secondWebPage.url) + }.openThreeDotMenu { + }.openBookmarks { + bookmarksListIdlingResource = + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 3) + IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) + + longTapSelectItem(firstWebPage.url) + longTapSelectItem(secondWebPage.url) + IdlingRegistry.getInstance().unregister(bookmarksListIdlingResource!!) + openActionBarOverflowOrOptionsMenu(activityTestRule.activity) + } + + multipleSelectionToolbar { + clickMultiSelectionDelete() + } + + bookmarksMenu { + verifyDeleteMultipleBookmarksSnackBar() + clickUndoDeleteButton() + verifyBookmarkedURL(firstWebPage.url.toString()) + verifyBookmarkedURL(secondWebPage.url.toString()) + } + } + @Test fun multipleSelectionShareButtonTest() { val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) @@ -432,12 +497,12 @@ class BookmarksTest { }.openThreeDotMenu("1") { }.clickDelete { verifyDeleteFolderConfirmationMessage() - confirmFolderDeletion() + confirmDeletion() verifyDeleteSnackBarText() }.openThreeDotMenu("2") { }.clickDelete { verifyDeleteFolderConfirmationMessage() - confirmFolderDeletion() + confirmDeletion() verifyDeleteSnackBarText() verifyFolderTitle("3") }.closeMenu { @@ -528,4 +593,24 @@ class BookmarksTest { verifyHomeScreen() } } + + @Test + fun deleteBookmarkInEditModeTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + browserScreen { + createBookmark(defaultWebPage.url) + }.openThreeDotMenu { + }.openBookmarks { + bookmarksListIdlingResource = + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) + IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) + }.openThreeDotMenu(defaultWebPage.url) { + IdlingRegistry.getInstance().unregister(bookmarksListIdlingResource!!) + }.clickEdit { + clickDeleteInEditModeButton() + confirmDeletion() + verifyDeleteSnackBarText() + } + } } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/CollectionTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/CollectionTest.kt index 68af35c58c..3062db3909 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/CollectionTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/CollectionTest.kt @@ -12,6 +12,7 @@ import org.junit.Before import org.junit.Ignore import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityTestRule import org.mozilla.fenix.helpers.TestAssetHelper.getGenericAsset @@ -39,6 +40,8 @@ class CollectionTest { @Before fun setUp() { + activityTestRule.activity.applicationContext.settings().shouldShowJumpBackInCFR = false + mockWebServer = MockWebServer().apply { dispatcher = AndroidAssetDispatcher() start() @@ -78,6 +81,7 @@ class CollectionTest { } @Test + @Ignore("https://github.com/mozilla-mobile/fenix/issues/21397") fun verifyAddTabButtonOfCollectionMenu() { val firstWebPage = getGenericAsset(mockWebServer, 1) val secondWebPage = getGenericAsset(mockWebServer, 2) @@ -104,6 +108,7 @@ class CollectionTest { } @Test + @Ignore("https://github.com/mozilla-mobile/fenix/issues/21397") fun renameCollectionTest() { val webPage = getGenericAsset(mockWebServer, 1) @@ -153,8 +158,10 @@ class CollectionTest { }.openTabDrawer { createCollection(webPage.title, firstCollectionName) verifySnackBarText("Collection saved!") - }.closeTabDrawer { - }.goToHomescreen { + closeTab() + } + + homeScreen { }.expandCollection(firstCollectionName) { removeTabFromCollection(webPage.title) verifyTabSavedInCollection(webPage.title, false) @@ -166,7 +173,6 @@ class CollectionTest { } @Test - @Ignore("To be fixed in https://github.com/mozilla-mobile/fenix/issues/20702") fun swipeToRemoveTabFromCollectionTest() { val firstWebPage = getGenericAsset(mockWebServer, 1) val secondWebPage = getGenericAsset(mockWebServer, 2) @@ -184,10 +190,13 @@ class CollectionTest { }.openThreeDotMenu { }.openSaveToCollection { }.selectExistingCollection(firstCollectionName) { - }.goToHomescreen {} + }.openTabDrawer { + closeTab() + } homeScreen { }.expandCollection(firstCollectionName) { + swipeToBottom() swipeCollectionItemLeft(firstWebPage.title) verifyTabSavedInCollection(firstWebPage.title, false) swipeCollectionItemRight(secondWebPage.title) @@ -209,6 +218,7 @@ class CollectionTest { }.openTabDrawer { }.openNewTab { }.submitQuery(secondWebPage.url.toString()) { + mDevice.waitForIdle() }.openTabDrawer { longClickTab(firstWebPage.title) verifyTabsMultiSelectionCounter(1) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt index 5c5fcbeb97..094080b47b 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix.ui import android.content.Context import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu import androidx.test.espresso.IdlingRegistry +import androidx.test.platform.app.InstrumentationRegistry import kotlinx.coroutines.runBlocking import mozilla.components.browser.storage.sync.PlacesHistoryStorage import okhttp3.mockwebserver.MockWebServer @@ -15,6 +16,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mozilla.fenix.R +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityTestRule import org.mozilla.fenix.helpers.RecyclerViewIdlingResource @@ -40,6 +42,9 @@ class HistoryTest { @Before fun setUp() { + InstrumentationRegistry.getInstrumentation().targetContext.settings() + .shouldShowJumpBackInCFR = false + mockWebServer = MockWebServer().apply { dispatcher = AndroidAssetDispatcher() start() @@ -95,87 +100,6 @@ class HistoryTest { } } - @Test - fun copyHistoryItemURLTest() { - val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - navigationToolbar { - }.enterURLAndEnterToBrowser(firstWebPage.url) { - mDevice.waitForIdle() - }.openThreeDotMenu { - }.openHistory { - verifyHistoryListExists() - historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) - IdlingRegistry.getInstance().register(historyListIdlingResource!!) - }.openThreeDotMenu { - }.clickCopy { - verifyCopySnackBarText() - } - } - - @Test - fun shareHistoryItemTest() { - val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - navigationToolbar { - }.enterURLAndEnterToBrowser(firstWebPage.url) { - mDevice.waitForIdle() - }.openThreeDotMenu { - }.openHistory { - verifyHistoryListExists() - historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) - IdlingRegistry.getInstance().register(historyListIdlingResource!!) - }.openThreeDotMenu { - }.clickShare { - verifyShareOverlay() - verifyShareTabFavicon() - verifyShareTabTitle() - verifyShareTabUrl() - } - } - - @Test - fun openHistoryItemInNewTabTest() { - val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - navigationToolbar { - }.enterURLAndEnterToBrowser(firstWebPage.url) { - mDevice.waitForIdle() - }.openThreeDotMenu { - }.openHistory { - verifyHistoryListExists() - historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) - IdlingRegistry.getInstance().register(historyListIdlingResource!!) - }.openThreeDotMenu { - }.clickOpenInNormalTab { - verifyTabTrayIsOpened() - verifyNormalModeSelected() - } - } - - @Test - fun openHistoryItemInNewPrivateTabTest() { - val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - navigationToolbar { - }.enterURLAndEnterToBrowser(firstWebPage.url) { - mDevice.waitForIdle() - }.openThreeDotMenu { - }.openHistory { - verifyHistoryListExists() - historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) - IdlingRegistry.getInstance().register(historyListIdlingResource!!) - }.openThreeDotMenu { - }.clickOpenInPrivateTab { - verifyTabTrayIsOpened() - verifyPrivateModeSelected() - } - } - @Test fun deleteHistoryItemTest() { val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) @@ -189,9 +113,8 @@ class HistoryTest { historyListIdlingResource = RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) - }.openThreeDotMenu { + clickDeleteHistoryButton() IdlingRegistry.getInstance().unregister(historyListIdlingResource!!) - }.clickDelete { verifyDeleteSnackbarText("Deleted") verifyEmptyHistoryView() } @@ -210,7 +133,7 @@ class HistoryTest { historyListIdlingResource = RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) - clickDeleteHistoryButton() + clickDeleteAllHistoryButton() IdlingRegistry.getInstance().unregister(historyListIdlingResource!!) verifyDeleteConfirmationMessage() confirmDeleteAllHistory() @@ -304,15 +227,18 @@ class HistoryTest { navigationToolbar { }.enterURLAndEnterToBrowser(firstWebPage.url) { - }.openTabDrawer { - }.openNewTab { - }.submitQuery(secondWebPage.url.toString()) { + }.openNavigationToolbar { + }.enterURLAndEnterToBrowser(secondWebPage.url) { + mDevice.waitForIdle() + verifyUrl(secondWebPage.url.toString()) }.openThreeDotMenu { }.openHistory { verifyHistoryListExists() historyListIdlingResource = RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 2) IdlingRegistry.getInstance().register(historyListIdlingResource!!) + verifyHistoryItemExists(firstWebPage.url.toString()) + verifyHistoryItemExists(secondWebPage.url.toString()) longTapSelectItem(firstWebPage.url) longTapSelectItem(secondWebPage.url) openActionBarOverflowOrOptionsMenu(activityTestRule.activity) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/HomeScreenTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/HomeScreenTest.kt index 9932c9aa39..74f35899d4 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/HomeScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/HomeScreenTest.kt @@ -4,15 +4,16 @@ package org.mozilla.fenix.ui +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.HomeActivityTestRule -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.By -import androidx.test.uiautomator.Until -import org.mozilla.fenix.helpers.ext.waitNotNull import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime +import org.mozilla.fenix.helpers.ext.waitNotNull import org.mozilla.fenix.ui.robots.homeScreen /** @@ -85,4 +86,57 @@ class HomeScreenTest { verifyHomeComponent() } } + + @Test + fun dismissOnboardingUsingSettingsTest() { + homeScreen { + verifyWelcomeHeader() + }.openThreeDotMenu { + }.openSettings { + verifyBasicsHeading() + }.goBack { + verifyExistingTopSitesList() + } + } + + @Test + fun dismissOnboardingUsingBookmarksTest() { + homeScreen { + verifyWelcomeHeader() + }.openThreeDotMenu { + }.openBookmarks { + verifyBookmarksMenuView() + navigateUp() + } + homeScreen { + verifyExistingTopSitesList() + } + } + + @Test + fun dismissOnboardingUsingHelpTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false + homeScreen { + verifyWelcomeHeader() + }.openThreeDotMenu { + }.openHelp { + verifyHelpUrl() + }.goBack { + verifyExistingTopSitesList() + } + } + + @Test + fun toolbarTapDoesntDismissOnboardingTest() { + homeScreen { + verifyWelcomeHeader() + }.openSearch { + verifyScanButton() + verifySearchEngineButton() + verifyKeyboardVisibility() + }.dismissSearchBar { + verifyWelcomeHeader() + } + } } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/NavigationToolbarTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/NavigationToolbarTest.kt index c0efe42f71..0fdaa1810b 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/NavigationToolbarTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/NavigationToolbarTest.kt @@ -12,6 +12,7 @@ import org.junit.Before import org.junit.Ignore import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityTestRule import org.mozilla.fenix.helpers.TestAssetHelper @@ -41,6 +42,8 @@ class NavigationToolbarTest { dispatcher = AndroidAssetDispatcher() start() } + val settings = activityTestRule.activity.settings() + settings.shouldShowJumpBackInCFR = false } @After diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/NoNetworkAccessStartupTests.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/NoNetworkAccessStartupTests.kt index c7bcbb7ada..0720497078 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/NoNetworkAccessStartupTests.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/NoNetworkAccessStartupTests.kt @@ -5,10 +5,12 @@ package org.mozilla.fenix.ui import androidx.core.net.toUri +import androidx.test.platform.app.InstrumentationRegistry import org.junit.After import org.junit.Rule import org.junit.Test import org.mozilla.fenix.R +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.HomeActivityTestRule import org.mozilla.fenix.helpers.TestHelper.packageName import org.mozilla.fenix.helpers.TestHelper.setNetworkEnabled @@ -53,6 +55,8 @@ class NoNetworkAccessStartupTests { // Based on STR from https://github.com/mozilla-mobile/fenix/issues/16886 fun networkInterruptedFromBrowserToHomeTest() { val url = "example.com" + val settings = InstrumentationRegistry.getInstrumentation().targetContext.settings() + settings.shouldShowJumpBackInCFR = false activityTestRule.launchActivity(null) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/SearchTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/SearchTest.kt index 06ac71c14b..b1074b3296 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SearchTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SearchTest.kt @@ -4,6 +4,7 @@ package org.mozilla.fenix.ui +import androidx.compose.ui.test.junit4.AndroidComposeTestRule import org.junit.Ignore import org.junit.Rule import org.junit.Test @@ -23,7 +24,10 @@ import org.mozilla.fenix.ui.robots.homeScreen class SearchTest { /* ktlint-disable no-blank-line-before-rbrace */ // This imposes unreadable grouping. @get:Rule - val activityTestRule = HomeActivityTestRule() + val activityTestRule = AndroidComposeTestRule( + HomeActivityTestRule(), + { it.activity } + ) @Test fun searchScreenItemsTest() { @@ -58,11 +62,11 @@ class SearchTest { }.goBack { }.goBack { }.openSearch { -// verifySearchWithText() - clickSearchEngineButton("DuckDuckGo") + verifySearchBarEmpty() + clickSearchEngineButton(activityTestRule, "DuckDuckGo") typeSearch("mozilla") - verifySearchEngineResults("DuckDuckGo") - clickSearchEngineResult("DuckDuckGo") + verifySearchEngineResults(activityTestRule, "DuckDuckGo", 4) + clickSearchEngineResult(activityTestRule, "DuckDuckGo") verifySearchEngineURL("DuckDuckGo") } } @@ -77,8 +81,8 @@ class SearchTest { }.goBack { }.goBack { }.openSearch { - scrollToSearchEngineSettings() - clickSearchEngineSettings() + scrollToSearchEngineSettings(activityTestRule) + clickSearchEngineSettings(activityTestRule) verifySearchSettings() } } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsAboutTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsAboutTest.kt index 8ef45be4bb..5e2a09bf03 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsAboutTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsAboutTest.kt @@ -12,6 +12,7 @@ import org.junit.Rule import org.junit.Before import org.junit.After import org.junit.Test +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityIntentTestRule import org.mozilla.fenix.ui.robots.clickRateButtonGooglePlay @@ -75,6 +76,8 @@ class SettingsAboutTest { @Test fun verifyAboutFirefoxPreview() { + val settings = activityIntentTestRule.activity.settings() + settings.shouldShowJumpBackInCFR = false homeScreen { }.openThreeDotMenu { }.openSettings { diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsBasicsTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsBasicsTest.kt index 3681f64872..1b70724990 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsBasicsTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsBasicsTest.kt @@ -14,6 +14,7 @@ import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.mozilla.fenix.FenixApplication +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityIntentTestRule import org.mozilla.fenix.helpers.TestAssetHelper.getGenericAsset @@ -42,6 +43,8 @@ class SettingsBasicsTest { dispatcher = AndroidAssetDispatcher() start() } + val settings = activityIntentTestRule.activity.settings() + settings.shouldShowJumpBackInCFR = false } @After @@ -150,22 +153,13 @@ class SettingsBasicsTest { } } - @Test - fun changeCloseTabsSetting() { - // Goes through the settings and verified the close tabs setting options. - homeScreen { - }.openThreeDotMenu { - }.openSettings { - }.openTabsSubMenu { - verifyOptions() - } - } - @Test fun changeAccessibiltySettings() { // Goes through the settings and changes the default text on a webpage, then verifies if the text has changed. val fenixApp = activityIntentTestRule.activity.applicationContext as FenixApplication val webpage = getLoremIpsumAsset(mockWebServer).url + val settings = fenixApp.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false // This value will represent the text size percentage the webpage will scale to. The default value is 100%. val textSizePercentage = 180 @@ -183,9 +177,6 @@ class SettingsBasicsTest { }.openNavigationToolbar { }.enterURLAndEnterToBrowser(webpage) { checkTextSizeOnWebsite(textSizePercentage, fenixApp.components) - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { }.openThreeDotMenu { }.openSettings { }.openAccessibilitySubMenu { diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsPrivacyTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsPrivacyTest.kt index ebe3333d2d..cc58dbfd69 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsPrivacyTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsPrivacyTest.kt @@ -9,9 +9,9 @@ import androidx.test.uiautomator.UiDevice import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Before -import org.junit.Ignore import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityIntentTestRule import org.mozilla.fenix.helpers.TestAssetHelper @@ -22,6 +22,7 @@ import org.mozilla.fenix.ui.robots.addToHomeScreen import org.mozilla.fenix.ui.robots.browserScreen import org.mozilla.fenix.ui.robots.homeScreen import org.mozilla.fenix.ui.robots.navigationToolbar +import org.mozilla.fenix.ui.robots.settingsScreen /** * Tests for verifying the main three dot menu options @@ -44,6 +45,9 @@ class SettingsPrivacyTest { dispatcher = AndroidAssetDispatcher() start() } + + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false } @After @@ -200,9 +204,8 @@ class SettingsPrivacyTest { verifySaveLoginPromptIsShown() // Click save to save the login saveLoginFromPrompt("Save") - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { + } + browserScreen { }.openThreeDotMenu { }.openSettings { TestHelper.scrollToElementByText("Logins and passwords") @@ -219,15 +222,14 @@ class SettingsPrivacyTest { @Test fun neverSaveLoginFromPromptTest() { val saveLoginTest = TestAssetHelper.getSaveLoginAsset(mockWebServer) + val settings = activityTestRule.activity.settings() + settings.shouldShowJumpBackInCFR = false navigationToolbar { }.enterURLAndEnterToBrowser(saveLoginTest.url) { verifySaveLoginPromptIsShown() // Don't save the login, add to exceptions saveLoginFromPrompt("Never save") - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { }.openThreeDotMenu { }.openSettings { }.openLoginsAndPasswordSubMenu { @@ -327,6 +329,8 @@ class SettingsPrivacyTest { @Test fun launchLinksInPrivateToggleOffStateDoesntChangeTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) setOpenLinksInPrivateOn() @@ -364,6 +368,7 @@ class SettingsPrivacyTest { }.openThreeDotMenu { }.openSettings { }.openPrivateBrowsingSubMenu { + cancelPrivateShortcutAddition() addPrivateShortcutToHomescreen() verifyPrivateBrowsingShortcutIcon() }.openPrivateBrowsingShortcut { @@ -374,175 +379,140 @@ class SettingsPrivacyTest { } } - @Ignore("This is a stub test, ignore for now") @Test - fun toggleTrackingProtection() { - // Open static test website to verify TP is turned on (default): https://github.com/rpappalax/testapp - // (static content needs to be migrated to assets folder) - // Open 3dot (main) menu - // Select settings - // Toggle Tracking Protection to 'off' - // Back arrow to Home - // Open static test website to verify TP is now off: https://github.com/rpappalax/testapp - } + fun deleteBrowsingDataOptionStatesTest() { + homeScreen { + }.openThreeDotMenu { + }.openSettings { + }.openSettingsSubMenuDeleteBrowsingData { + verifyAllCheckBoxesAreChecked() + switchBrowsingHistoryCheckBox() + switchCachedFilesCheckBox() + verifyOpenTabsCheckBox(true) + verifyBrowsingHistoryDetails(false) + verifyCookiesCheckBox(true) + verifyCachedFilesCheckBox(false) + verifySitePermissionsCheckBox(true) + verifyDownloadsCheckBox(true) + } - @Ignore("This is a stub test, ignore for now") - @Test - fun verifySitePermissions() { - // Open 3dot (main) menu - // Select settings - // Click on: "Site permissions" - // Verify sub-menu items... - // Click on: "Exceptions" - // Verify: "No site exceptions" - // TBD: create a site exception - // TBD: return to this UI and verify - - // - // Open browser to static test website: https://github.com/rpappalax/testapp - // Click on "Test site permissions: geolocation" - // Verify that geolocation permissions dialogue is opened - // Verify text: "Allow to use your geolocation? - // Verify toggle: 'Remember decision for this site?" - // Verify button: "Don't Allow" - // Verify button: "Allow" (default) - // Select "Remember decision for this site" - // Refresh page - // Click on "Test site permissions: geolocation" - // Verify that geolocation permissions dialogue is not opened - // - // - // Open browser to static test website: https://github.com/rpappalax/testapp - // Click on "Test site permissions: camera" - // Verify that camera permissions dialogue is opened - // Verify text: "Allow to use your camera? - // Verify toggle: 'Remember decision for this site?" - // Verify button: "Don't Allow" - // Verify button: "Allow" (default) - // Select "Remember decision for this site" - // Refresh page - // Click on "Test site permissions: camera" - // Verify that camera permissions dialogue is not opened - // - // - // Open browser to static test website: https://github.com/rpappalax/testapp - // Click on "Test site permissions: microphone" - // Verify that microphone permissions dialogue is opened - // Verify text: "Allow to use your microphone? - // Verify toggle: 'Remember decision for this site?" - // Verify button: "Don't Allow" - // Verify button: "Allow" (default) - // Select "Remember decision for this site" - // Refresh page - // Click on "Test site permissions: microphone" - // Verify that microphone permissions dialogue is not opened - // - // - // Open browser to static test website: https://github.com/rpappalax/testapp - // Click on "Test site permissions: notifications dialogue" - // Verify that notifications dialogue permissions dialogue is opened - // Verify text: "Allow to send notifications? - // Verify toggle: 'Remember decision for this site?" - // Verify button: "Never" - // Verify button: "Always" (default) - // Select "Remember decision for this site" - // Refresh page - // Click on "Test site permissions: notifications dialogue" - // Verify that notifications dialogue permissions dialogue is not opened - // - - // Open 3dot (main) menu - // Select settings - // Click on: "Site permissions" - // Select: Camera - // Switch from "ask to allow" (default) to "blocked" - // Click back arrow - // - // Select: Location - // Switch from "ask to allow" (default) to "blocked" - // Click back arrow - // - // Select: Microphone - // Switch from "ask to allow" (default) to "blocked" - // Click back arrow - // - // Select: Notification - // Switch from "ask to allow" (default) to "blocked" - // Click back arrow - // - - // Open browser to static test website: https://github.com/rpappalax/testapp - // Click on "Test site permissions: camera dialogue" - // Verify that notifications dialogue permissions dialogue is not opened - // - // Open browser to static test website: https://github.com/rpappalax/testapp - // Click on "Test site permissions: geolocation dialogue" - // Verify that notifications dialogue permissions dialogue is not opened - // - // Open browser to static test website: https://github.com/rpappalax/testapp - // Click on "Test site permissions: microphone dialogue" - // Verify that notifications dialogue permissions dialogue is not opened - // - // Open browser to static test website: https://github.com/rpappalax/testapp - // Click on "Test site permissions: notifications dialogue" - // Verify that notifications dialogue permissions dialogue is not opened - } + restartApp(activityTestRule) - @Ignore("This is a stub test, ignore for now") - @Test - fun deleteBrowsingData() { - // Setup: - // Open 2 websites as 2 tabs - // Save as 1 collection - // Open 2 more websites in 2 other tabs - // Save as a 2nd collection - - // Open 3dot (main) menu - // Select settings - // Click on "Delete browsing data" - // Verify correct number of tabs, addresses and collections are indicated - // Select all 3 checkboxes - // Click on "Delete browsing data button" - // Return to home screen and verify that all tabs, history and collection are gone - // - // Verify xxx - // - // New: If coming from tab -> settings -> delete browsing data - // then expect to return to home screen - // If coming from tab -> home -> settings -> delete browsing data - // then expect return to settings (after which you can return to home manually) + homeScreen { + }.openThreeDotMenu { + }.openSettings { + }.openSettingsSubMenuDeleteBrowsingData { + verifyOpenTabsCheckBox(true) + verifyBrowsingHistoryDetails(false) + verifyCookiesCheckBox(true) + verifyCachedFilesCheckBox(false) + verifySitePermissionsCheckBox(true) + verifyDownloadsCheckBox(true) + switchOpenTabsCheckBox() + switchBrowsingHistoryCheckBox() + switchCookiesCheckBox() + switchCachedFilesCheckBox() + switchSitePermissionsCheckBox() + switchDownloadsCheckBox() + verifyOpenTabsCheckBox(false) + verifyBrowsingHistoryDetails(true) + verifyCookiesCheckBox(false) + verifyCachedFilesCheckBox(true) + verifySitePermissionsCheckBox(false) + verifyDownloadsCheckBox(false) + } + + restartApp(activityTestRule) + + homeScreen { + }.openThreeDotMenu { + }.openSettings { + }.openSettingsSubMenuDeleteBrowsingData { + verifyOpenTabsCheckBox(false) + verifyBrowsingHistoryDetails(true) + verifyCookiesCheckBox(false) + verifyCachedFilesCheckBox(true) + verifySitePermissionsCheckBox(false) + verifyDownloadsCheckBox(false) + } } - @Ignore("This is a stub test, ignore for now") @Test - fun verifyDataCollection() { - // Open 3dot (main) menu - // Select settings - // Click on "Data collection" - // Verify header: "Usage and technical data" - // Verify text: "Shares performance, usage, hardware and customization data about your browser with Mozilla" - // " to help us make Firefox preview better" - // Verify toggle is on by default - // TBD: - // see: telemetry testcases + fun deleteTabsDataWithNoOpenTabsTest() { + homeScreen { + }.openThreeDotMenu { + }.openSettings { + }.openSettingsSubMenuDeleteBrowsingData { + verifyAllCheckBoxesAreChecked() + selectOnlyOpenTabsCheckBox() + clickDeleteBrowsingDataButton() + confirmDeletionAndAssertSnackbar() + } + settingsScreen { + verifyBasicsHeading() + } } - @Ignore("This is a stub test, ignore for now") @Test - fun openPrivacyNotice() { - // Open 3dot (main) menu - // Select settings - // Click on "Privacy notice" - // Verify redirect to: mozilla.org Privacy notice page" + fun deleteTabsDataTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { + mDevice.waitForIdle() + }.openThreeDotMenu { + }.openSettings { + }.openSettingsSubMenuDeleteBrowsingData { + verifyAllCheckBoxesAreChecked() + selectOnlyOpenTabsCheckBox() + clickDeleteBrowsingDataButton() + clickDialogCancelButton() + verifyOpenTabsCheckBox(true) + clickDeleteBrowsingDataButton() + confirmDeletionAndAssertSnackbar() + } + settingsScreen { + verifyBasicsHeading() + }.openSettingsSubMenuDeleteBrowsingData { + verifyOpenTabsDetails("0") + }.goBack { + }.goBack { + }.openTabDrawer { + verifyNoTabsOpened() + } } - @Ignore("This is a stub test, ignore for now") @Test - fun checkLeakCanary() { - // Open 3dot (main) menu - // Select settings - // Click on Leak Canary toggle - // Verify 'dump' message + fun deleteDeleteBrowsingHistoryDataTest() { + val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + val secondWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 2) + + navigationToolbar { + }.enterURLAndEnterToBrowser(firstWebPage.url) { + mDevice.waitForIdle() + }.openNavigationToolbar { + }.enterURLAndEnterToBrowser(secondWebPage.url) { + mDevice.waitForIdle() + }.openThreeDotMenu { + }.openSettings { + }.openSettingsSubMenuDeleteBrowsingData { + verifyBrowsingHistoryDetails("2") + selectOnlyBrowsingHistoryCheckBox() + clickDeleteBrowsingDataButton() + clickDialogCancelButton() + verifyBrowsingHistoryDetails(true) + clickDeleteBrowsingDataButton() + confirmDeletionAndAssertSnackbar() + verifyBrowsingHistoryDetails("0") + }.goBack { + verifyBasicsHeading() + }.goBack { + } + navigationToolbar { + }.openThreeDotMenu { + }.openHistory { + verifyEmptyHistoryView() + } } } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt index de579e9da7..a044ee7521 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt @@ -5,8 +5,8 @@ package org.mozilla.fenix.ui import android.view.View +import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.core.net.toUri -import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.IdlingRegistry import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ActivityTestRule @@ -25,22 +25,24 @@ import org.mozilla.fenix.R import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher +import org.mozilla.fenix.helpers.Constants.PackageName.YOUTUBE_APP import org.mozilla.fenix.helpers.HomeActivityIntentTestRule import org.mozilla.fenix.helpers.RecyclerViewIdlingResource import org.mozilla.fenix.helpers.TestAssetHelper import org.mozilla.fenix.helpers.TestHelper import org.mozilla.fenix.helpers.TestHelper.appName +import org.mozilla.fenix.helpers.TestHelper.assertExternalAppOpens import org.mozilla.fenix.helpers.TestHelper.createCustomTabIntent import org.mozilla.fenix.helpers.TestHelper.deleteDownloadFromStorage +import org.mozilla.fenix.helpers.TestHelper.isPackageInstalled import org.mozilla.fenix.helpers.TestHelper.restartApp +import org.mozilla.fenix.helpers.TestHelper.returnToBrowser import org.mozilla.fenix.helpers.TestHelper.scrollToElementByText import org.mozilla.fenix.helpers.ViewVisibilityIdlingResource import org.mozilla.fenix.ui.robots.browserScreen import org.mozilla.fenix.ui.robots.clickTabCrashedRestoreButton -import org.mozilla.fenix.ui.robots.clickUrlbar import org.mozilla.fenix.ui.robots.collectionRobot import org.mozilla.fenix.ui.robots.customTabScreen -import org.mozilla.fenix.ui.robots.dismissTrackingOnboarding import org.mozilla.fenix.ui.robots.downloadRobot import org.mozilla.fenix.ui.robots.enhancedTrackingProtection import org.mozilla.fenix.ui.robots.homeScreen @@ -63,7 +65,6 @@ class SmokeTest { private val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) private lateinit var mockWebServer: MockWebServer private var awesomeBar: ViewVisibilityIdlingResource? = null - private var searchSuggestionsIdlingResource: RecyclerViewIdlingResource? = null private var addonsListIdlingResource: RecyclerViewIdlingResource? = null private var recentlyClosedTabsListIdlingResource: RecyclerViewIdlingResource? = null private var readerViewNotification: ViewVisibilityIdlingResource? = null @@ -82,10 +83,14 @@ class SmokeTest { return searchDialogFragment?.view?.findViewById(R.id.awesome_bar) } - @get:Rule - val activityTestRule = HomeActivityIntentTestRule() private lateinit var browserStore: BrowserStore + @get:Rule + val activityTestRule = AndroidComposeTestRule( + HomeActivityIntentTestRule(), + { it.activity } + ) + @get: Rule val intentReceiverActivityTestRule = ActivityTestRule( IntentReceiverActivity::class.java, true, false @@ -103,6 +108,7 @@ class SmokeTest { // So we are initializing this here instead of in all related tests. browserStore = activityTestRule.activity.components.core.store + activityTestRule.activity.applicationContext.settings().shouldShowJumpBackInCFR = false mockWebServer = MockWebServer().apply { dispatcher = AndroidAssetDispatcher() start() @@ -117,10 +123,6 @@ class SmokeTest { IdlingRegistry.getInstance().unregister(awesomeBar!!) } - if (searchSuggestionsIdlingResource != null) { - IdlingRegistry.getInstance().unregister(searchSuggestionsIdlingResource!!) - } - if (addonsListIdlingResource != null) { IdlingRegistry.getInstance().unregister(addonsListIdlingResource!!) } @@ -239,10 +241,13 @@ class SmokeTest { @Test // Verifies the History menu opens from a tab's 3 dot menu fun openMainMenuHistoryItemTest() { - homeScreen { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.openHistory { - verifyHistoryMenuView() + verifyHistoryListExists() } } @@ -250,7 +255,10 @@ class SmokeTest { @Test // Verifies the Bookmarks menu opens from a tab's 3 dot menu fun openMainMenuBookmarksItemTest() { - homeScreen { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.openBookmarks { verifyBookmarksMenuView() @@ -261,10 +269,14 @@ class SmokeTest { // Verifies the Synced tabs menu or Sync Sign In menu opens from a tab's 3 dot menu. // The test is assuming we are NOT signed in. fun openMainMenuSyncItemTest() { - homeScreen { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { + mDevice.waitForIdle() }.openThreeDotMenu { }.openSyncSignIn { - verifySyncSignInMenuHeader() + verifyTurnOnSyncMenu() } } @@ -273,7 +285,10 @@ class SmokeTest { // caution when making changes to it, so they don't block the builds // Verifies the Settings menu opens from a tab's 3 dot menu fun openMainMenuSettingsItemTest() { - homeScreen { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.openSettings { verifySettingsView() @@ -296,6 +311,9 @@ class SmokeTest { @Test // Verifies the Add to top sites option in a tab's 3 dot menu fun openMainMenuAddTopSiteTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) navigationToolbar { @@ -304,9 +322,7 @@ class SmokeTest { expandMenu() }.addToFirefoxHome { verifySnackBarText("Added to top sites!") - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { + }.goToHomescreen { verifyExistingTopSitesTabs(defaultWebPage.title) } } @@ -363,6 +379,37 @@ class SmokeTest { } } + @Test + // Verifies the Open in app button when an app is installed + fun mainMenuOpenInAppTest() { + val youtubeUrl = "m.youtube.com" + if (isPackageInstalled(YOUTUBE_APP)) { + navigationToolbar { + }.enterURLAndEnterToBrowser(youtubeUrl.toUri()) { + verifyNotificationDotOnMainMenu() + }.openThreeDotMenu { + }.clickOpenInApp { + assertExternalAppOpens(YOUTUBE_APP) + returnToBrowser() + verifyUrl(youtubeUrl) + } + } + } + + @Test + // Verifies the Desktop site toggle in a tab's 3 dot menu + fun mainMenuDesktopSiteTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { + }.openThreeDotMenu { + }.switchDesktopSiteMode { + }.openThreeDotMenu { + verifyDesktopSiteModeEnabled(true) + } + } + @Test // Verifies the Share button in a tab's 3 dot menu fun mainMenuShareButtonTest() { @@ -391,34 +438,6 @@ class SmokeTest { } } - @Test - // Turns ETP toggle off from Settings and verifies the ETP shield is not displayed in the nav bar - fun verifyETPShieldNotDisplayedIfOFFGlobally() { - val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - homeScreen { - }.openThreeDotMenu { - }.openSettings { - }.openEnhancedTrackingProtectionSubMenu { - switchEnhancedTrackingProtectionToggle() - verifyEnhancedTrackingProtectionOptionsGrayedOut() - }.goBackToHomeScreen { - navigationToolbar { - }.enterURLAndEnterToBrowser(defaultWebPage.url) { - verifyEnhancedTrackingProtectionPanelNotVisible() - }.openThreeDotMenu { - }.openSettings { - }.openEnhancedTrackingProtectionSubMenu { - switchEnhancedTrackingProtectionToggle() - }.goBack { - }.goBackToBrowser { - clickEnhancedTrackingProtectionPanel() - verifyEnhancedTrackingProtectionSwitch() - clickEnhancedTrackingProtectionSwitchOffOn() - } - } - } - @Test fun customTrackingProtectionSettingsTest() { val genericWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) @@ -437,16 +456,16 @@ class SmokeTest { // browsing a basic page to allow GV to load on a fresh run }.enterURLAndEnterToBrowser(genericWebPage.url) { }.openNavigationToolbar { - }.openTrackingProtectionTestPage(trackingPage.url, true) { - dismissTrackingOnboarding() - } + }.enterURLAndEnterToBrowser(trackingPage.url) {} enhancedTrackingProtection { }.openEnhancedTrackingProtectionSheet { + }.openDetails { verifyTrackingCookiesBlocked() verifyCryptominersBlocked() verifyFingerprintersBlocked() - verifyBasicLevelTrackingContentBlocked() + verifyTrackingContentBlocked() + viewTrackingContentBlockList() } } @@ -459,43 +478,47 @@ class SmokeTest { }.openSearch { verifyKeyboardVisibility() clickSearchEngineShortcutButton() - verifySearchEngineList() - changeDefaultSearchEngine("Amazon.com") + verifySearchEngineList(activityTestRule) + changeDefaultSearchEngine(activityTestRule, "Amazon.com") verifySearchEngineIcon("Amazon.com") }.goToSearchEngine { + mDevice.waitForIdle() }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openTabDrawer { }.openNewTab { clickSearchEngineShortcutButton() mDevice.waitForIdle() - changeDefaultSearchEngine("Bing") + changeDefaultSearchEngine(activityTestRule, "Bing") verifySearchEngineIcon("Bing") }.goToSearchEngine { + mDevice.waitForIdle() }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openTabDrawer { }.openNewTab { clickSearchEngineShortcutButton() mDevice.waitForIdle() - changeDefaultSearchEngine("DuckDuckGo") + changeDefaultSearchEngine(activityTestRule, "DuckDuckGo") verifySearchEngineIcon("DuckDuckGo") }.goToSearchEngine { + mDevice.waitForIdle() }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openTabDrawer { }.openNewTab { clickSearchEngineShortcutButton() - changeDefaultSearchEngine("Wikipedia") + changeDefaultSearchEngine(activityTestRule, "Wikipedia") verifySearchEngineIcon("Wikipedia") }.goToSearchEngine { + mDevice.waitForIdle() }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openTabDrawer { // Checking whether the next search will be with default or not }.openNewTab { }.goToSearchEngine { + mDevice.waitForIdle() }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openNavigationToolbar { - clickUrlbar { - verifyDefaultSearchEngine("Google") - } + }.clickUrlbar { + verifyDefaultSearchEngine("Google") } } @@ -515,10 +538,45 @@ class SmokeTest { }.openSearch { verifyKeyboardVisibility() clickSearchEngineShortcutButton() - verifyEnginesListShortcutContains("YouTube") + mDevice.waitForIdle() + activityTestRule.waitForIdle() + verifyEnginesListShortcutContains(activityTestRule, "YouTube") + } + } + + @Ignore("Started failing: https://github.com/mozilla-mobile/fenix/issues/21540") + @Test + // Verifies setting as default a customized search engine name and URL + fun editCustomSearchEngineTest() { + val searchEngine = object { + var title = "Elefant" + var url = "https://www.elefant.ro/search?SearchTerm=%s" + var newTitle = "Test" + } + + homeScreen { + }.openThreeDotMenu { + }.openSettings { + }.openSearchSubMenu { + openAddSearchEngineMenu() + selectAddCustomSearchEngine() + typeCustomEngineDetails(searchEngine.title, searchEngine.url) + saveNewSearchEngine() + openEngineOverflowMenu(searchEngine.title) + clickEdit() + typeCustomEngineDetails(searchEngine.newTitle, searchEngine.url) + saveEditSearchEngine() + changeDefaultSearchEngine(searchEngine.newTitle) + }.goBack { + }.goBack { + }.openSearch { + verifyDefaultSearchEngine(searchEngine.newTitle) + clickSearchEngineShortcutButton() + verifyEnginesListShortcutContains(activityTestRule, searchEngine.newTitle) } } + @Ignore("Strated failing on Nighlty task: https://github.com/mozilla-mobile/fenix/issues/21620") @Test // Test running on beta/release builds in CI: // caution when making changes to it, so they don't block the builds @@ -526,32 +584,19 @@ class SmokeTest { fun toggleSearchSuggestions() { homeScreen { - }.openNavigationToolbar { - typeSearchTerm("mozilla") - val awesomeBarView = getAwesomebarView() - awesomeBarView?.let { - awesomeBar = ViewVisibilityIdlingResource(it, View.VISIBLE) - } - IdlingRegistry.getInstance().register(awesomeBar!!) - searchSuggestionsIdlingResource = - RecyclerViewIdlingResource(awesomeBarView as RecyclerView, 1) - IdlingRegistry.getInstance().register(searchSuggestionsIdlingResource!!) - verifySearchSuggestionsAreMoreThan(0) - IdlingRegistry.getInstance().unregister(searchSuggestionsIdlingResource!!) - }.goBack { + }.openSearch { + typeSearch("mozilla") + verifySearchEngineSuggestionResults(activityTestRule, "mozilla firefox") + }.dismissSearchBar { }.openThreeDotMenu { }.openSettings { }.openSearchSubMenu { disableShowSearchSuggestions() }.goBack { }.goBack { - }.openNavigationToolbar { - typeSearchTerm("mozilla") - searchSuggestionsIdlingResource = - RecyclerViewIdlingResource(getAwesomebarView() as RecyclerView) - IdlingRegistry.getInstance().register(searchSuggestionsIdlingResource!!) - verifySearchSuggestionsAreEqualTo(0) - IdlingRegistry.getInstance().unregister(searchSuggestionsIdlingResource!!) + }.openSearch { + typeSearch("mozilla") + verifyNoSuggestionsAreDisplayed(activityTestRule, "mozilla firefox") } } @@ -575,7 +620,6 @@ class SmokeTest { @Test // Saves a login, then changes it and verifies the update - @Ignore("To be fixed in https://github.com/mozilla-mobile/fenix/issues/20702") fun updateSavedLoginTest() { val saveLoginTest = TestAssetHelper.getSaveLoginAsset(mockWebServer) @@ -659,14 +703,13 @@ class SmokeTest { IdlingRegistry.getInstance().register(addonsListIdlingResource!!) clickInstallAddon(addonName) acceptInstallAddon() - verifyDownloadAddonPrompt(addonName, activityTestRule) + verifyDownloadAddonPrompt(addonName, activityTestRule.activityRule) IdlingRegistry.getInstance().unregister(addonsListIdlingResource!!) }.goBack { }.openNavigationToolbar { - }.openTrackingProtectionTestPage(trackingProtectionPage.url, true) {} - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionNotice() - }.closeNotificationPopup {} + }.enterURLAndEnterToBrowser(trackingProtectionPage.url) { + verifyPageContent(trackingProtectionPage.content) + } } @Test @@ -693,89 +736,6 @@ class SmokeTest { } } - @Test - // Verifies the items from the overflow menu of Recently Closed Tabs - fun recentlyClosedTabsMenuItemsTest() { - val website = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - homeScreen { - }.openNavigationToolbar { - }.enterURLAndEnterToBrowser(website.url) { - mDevice.waitForIdle() - }.openTabDrawer { - closeTab() - }.openTabDrawer { - }.openRecentlyClosedTabs { - waitForListToExist() - recentlyClosedTabsListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.recently_closed_list), 1) - IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!) - verifyRecentlyClosedTabsMenuView() - IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!) - openRecentlyClosedTabsThreeDotMenu() - verifyRecentlyClosedTabsMenuCopy() - verifyRecentlyClosedTabsMenuShare() - verifyRecentlyClosedTabsMenuNewTab() - verifyRecentlyClosedTabsMenuPrivateTab() - verifyRecentlyClosedTabsMenuDelete() - } - } - - @Test - // Verifies the Copy option from the Recently Closed Tabs overflow menu - fun copyRecentlyClosedTabsItemTest() { - val website = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - homeScreen { - }.openNavigationToolbar { - }.enterURLAndEnterToBrowser(website.url) { - mDevice.waitForIdle() - }.openTabDrawer { - closeTab() - }.openTabDrawer { - }.openRecentlyClosedTabs { - waitForListToExist() - recentlyClosedTabsListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.recently_closed_list), 1) - IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!) - verifyRecentlyClosedTabsMenuView() - IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!) - openRecentlyClosedTabsThreeDotMenu() - verifyRecentlyClosedTabsMenuCopy() - clickCopyRecentlyClosedTabs() - verifyCopyRecentlyClosedTabsSnackBarText() - } - } - - @Test - // Verifies the Share option from the Recently Closed Tabs overflow menu - fun shareRecentlyClosedTabsItemTest() { - val website = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - homeScreen { - }.openNavigationToolbar { - }.enterURLAndEnterToBrowser(website.url) { - mDevice.waitForIdle() - }.openTabDrawer { - closeTab() - }.openTabDrawer { - }.openRecentlyClosedTabs { - waitForListToExist() - recentlyClosedTabsListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.recently_closed_list), 1) - IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!) - verifyRecentlyClosedTabsMenuView() - IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!) - openRecentlyClosedTabsThreeDotMenu() - verifyRecentlyClosedTabsMenuShare() - clickShareRecentlyClosedTabs() - verifyShareOverlay() - verifyShareTabTitle("Test_Page_1") - verifyShareTabUrl(website.url) - verifyShareTabFavicon() - } - } - @Test // Verifies the Open in a new tab option from the Recently Closed Tabs overflow menu fun openRecentlyClosedTabsInNewTabTest() { @@ -795,8 +755,6 @@ class SmokeTest { IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!) verifyRecentlyClosedTabsMenuView() IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!) - openRecentlyClosedTabsThreeDotMenu() - verifyRecentlyClosedTabsMenuNewTab() }.clickOpenInNewTab { verifyUrl(website.url.toString()) }.openTabDrawer { @@ -805,35 +763,7 @@ class SmokeTest { } @Test - // Verifies the Open in a private tab option from the Recently Closed Tabs overflow menu - fun openRecentlyClosedTabsInNewPrivateTabTest() { - val website = TestAssetHelper.getGenericAsset(mockWebServer, 1) - - homeScreen { - }.openNavigationToolbar { - }.enterURLAndEnterToBrowser(website.url) { - mDevice.waitForIdle() - }.openTabDrawer { - closeTab() - }.openTabDrawer { - }.openRecentlyClosedTabs { - waitForListToExist() - recentlyClosedTabsListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.recently_closed_list), 1) - IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!) - verifyRecentlyClosedTabsMenuView() - IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!) - openRecentlyClosedTabsThreeDotMenu() - verifyRecentlyClosedTabsMenuPrivateTab() - }.clickOpenInPrivateTab { - verifyUrl(website.url.toString()) - }.openTabDrawer { - verifyPrivateModeSelected() - } - } - - @Test - // Verifies the delete option from the Recently Closed Tabs overflow menu + // Verifies the delete button from the Recently Closed Tabs fun deleteRecentlyClosedTabsItemTest() { val website = TestAssetHelper.getGenericAsset(mockWebServer, 1) @@ -851,9 +781,7 @@ class SmokeTest { IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!) verifyRecentlyClosedTabsMenuView() IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!) - openRecentlyClosedTabsThreeDotMenu() - verifyRecentlyClosedTabsMenuDelete() - clickDeleteCopyRecentlyClosedTabs() + clickDeleteRecentlyClosedTabs() verifyEmptyRecentlyClosedTabsList() } } @@ -893,7 +821,10 @@ class SmokeTest { } @Test + @Ignore("https://github.com/mozilla-mobile/fenix/issues/21397") fun createFirstCollectionTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val secondWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 2) @@ -924,7 +855,10 @@ class SmokeTest { } @Test + @Ignore("https://github.com/mozilla-mobile/fenix/issues/21397") fun verifyExpandedCollectionItemsTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val webPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) navigationToolbar { @@ -977,6 +911,9 @@ class SmokeTest { @Test fun shareCollectionTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false + val webPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) navigationToolbar { @@ -1000,6 +937,8 @@ class SmokeTest { // Test running on beta/release builds in CI: // caution when making changes to it, so they don't block the builds fun deleteCollectionTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val webPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) navigationToolbar { @@ -1013,10 +952,10 @@ class SmokeTest { }.expandCollection(collectionName) { clickCollectionThreeDotButton() selectDeleteCollection() - confirmDeleteCollection() } homeScreen { + verifySnackBarText("Collection deleted") verifyNoCollectionsText() } } @@ -1049,7 +988,7 @@ class SmokeTest { verifyFolderTitle("My Folder") }.openThreeDotMenu("My Folder") { }.clickDelete { - confirmFolderDeletion() + confirmDeletion() verifyDeleteSnackBarText() navigateUp() } @@ -1163,7 +1102,7 @@ class SmokeTest { @Test @Ignore("To be re-enabled later. See https://github.com/mozilla-mobile/fenix/issues/20716") fun mainMenuInstallPWATest() { - val pwaPage = "https://rpappalax.github.io/testapp/" + val pwaPage = "https://mozilla-mobile.github.io/testapp/" navigationToolbar { }.enterURLAndEnterToBrowser(pwaPage.toUri()) { @@ -1418,6 +1357,8 @@ class SmokeTest { @Test fun goToHomeScreenBottomToolbarTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val genericURL = TestAssetHelper.getGenericAsset(mockWebServer, 1) navigationToolbar { @@ -1430,6 +1371,9 @@ class SmokeTest { @Test fun goToHomeScreenTopToolbarTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false + val genericURL = TestAssetHelper.getGenericAsset(mockWebServer, 1) homeScreen { @@ -1484,17 +1428,21 @@ class SmokeTest { } @Test - fun startOnHomeSettingsMenuItemsTest() { + fun tabsSettingsMenuItemsTest() { homeScreen { }.openThreeDotMenu { }.openSettings { }.openTabsSubMenu { + verifyTabViewOptions() + verifyCloseTabsOptions() verifyStartOnHomeOptions() } } @Test fun alwaysStartOnHomeTest() { + val settings = activityTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val genericURL = TestAssetHelper.getGenericAsset(mockWebServer, 1) navigationToolbar { @@ -1506,7 +1454,7 @@ class SmokeTest { clickAlwaysStartOnHomeToggle() } - restartApp(activityTestRule) + restartApp(activityTestRule.activityRule) homeScreen { verifyHomeScreen() diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/StrictEnhancedTrackingProtectionTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/StrictEnhancedTrackingProtectionTest.kt index 09ef8b83c9..1a5320a1bb 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/StrictEnhancedTrackingProtectionTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/StrictEnhancedTrackingProtectionTest.kt @@ -4,7 +4,6 @@ package org.mozilla.fenix.ui -import androidx.test.platform.app.InstrumentationRegistry import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Before @@ -14,7 +13,6 @@ import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityTestRule import org.mozilla.fenix.helpers.TestAssetHelper -import org.mozilla.fenix.helpers.TestHelper import org.mozilla.fenix.ui.robots.enhancedTrackingProtection import org.mozilla.fenix.ui.robots.homeScreen import org.mozilla.fenix.ui.robots.navigationToolbar @@ -46,13 +44,9 @@ class StrictEnhancedTrackingProtectionTest { start() } - activityTestRule.activity.settings().setStrictETP() - - // Reset on-boarding notification for each test - TestHelper.setPreference( - InstrumentationRegistry.getInstrumentation().context, - "pref_key_tracking_protection_onboarding", 0 - ) + val settings = activityTestRule.activity.settings() + settings.setStrictETP() + settings.shouldShowJumpBackInCFR = false } @After @@ -76,99 +70,37 @@ class StrictEnhancedTrackingProtectionTest { } } - @Test - fun testStrictVisitContentNotification() { - val trackingProtectionTest = - TestAssetHelper.getEnhancedTrackingProtectionAsset(mockWebServer) - - navigationToolbar { - }.openTrackingProtectionTestPage(trackingProtectionTest.url, true) {} - - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionNotice() - }.closeNotificationPopup {} - } - - @Test - fun testStrictVisitContentShield() { - val trackingProtectionTest = - TestAssetHelper.getEnhancedTrackingProtectionAsset(mockWebServer) - - navigationToolbar { - }.openTrackingProtectionTestPage(trackingProtectionTest.url, true) {} - - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionNotice() - }.closeNotificationPopup {} - - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionShield() - } - } - @Test fun testStrictVisitProtectionSheet() { + val genericPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val trackingProtectionTest = TestAssetHelper.getEnhancedTrackingProtectionAsset(mockWebServer) + // browsing a generic page to allow GV to load on a fresh run navigationToolbar { - }.openTrackingProtectionTestPage(trackingProtectionTest.url, true) {} - - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionNotice() - }.closeNotificationPopup {} - - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionShield() - }.openEnhancedTrackingProtectionSheet { - verifyEnhancedTrackingProtectionSheetStatus("ON", true) - } - } - - @Test - fun testStrictVisitDisable() { - val trackingProtectionTest = - TestAssetHelper.getEnhancedTrackingProtectionAsset(mockWebServer) - - navigationToolbar { - }.openTrackingProtectionTestPage(trackingProtectionTest.url, true) {} - - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionNotice() - }.closeNotificationPopup {} + }.enterURLAndEnterToBrowser(genericPage.url) { + }.openNavigationToolbar { + }.enterURLAndEnterToBrowser(trackingProtectionTest.url) {} enhancedTrackingProtection { - verifyEnhancedTrackingProtectionShield() }.openEnhancedTrackingProtectionSheet { verifyEnhancedTrackingProtectionSheetStatus("ON", true) - }.disableEnhancedTrackingProtectionFromSheet { - verifyEnhancedTrackingProtectionSheetStatus("OFF", false) - }.closeEnhancedTrackingProtectionSheet {} - - // Verify that Enhanced Tracking Protection remains globally enabled - navigationToolbar { - }.openThreeDotMenu { - verifyThreeDotMenuExists() - }.openSettings { - verifyEnhancedTrackingProtectionButton() - verifyEnhancedTrackingProtectionValue("On") } } @Test fun testStrictVisitDisableExceptionToggle() { + val genericPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val trackingProtectionTest = TestAssetHelper.getEnhancedTrackingProtectionAsset(mockWebServer) + // browsing a generic page to allow GV to load on a fresh run navigationToolbar { - }.openTrackingProtectionTestPage(trackingProtectionTest.url, true) {} - - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionNotice() - }.closeNotificationPopup {} + }.enterURLAndEnterToBrowser(genericPage.url) { + }.openNavigationToolbar { + }.enterURLAndEnterToBrowser(trackingProtectionTest.url) {} enhancedTrackingProtection { - verifyEnhancedTrackingProtectionShield() }.openEnhancedTrackingProtectionSheet { verifyEnhancedTrackingProtectionSheetStatus("ON", true) }.disableEnhancedTrackingProtectionFromSheet { @@ -189,22 +121,26 @@ class StrictEnhancedTrackingProtectionTest { @Test fun testStrictVisitSheetDetails() { + val genericPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val trackingProtectionTest = TestAssetHelper.getEnhancedTrackingProtectionAsset(mockWebServer) + // browsing a generic page to allow GV to load on a fresh run navigationToolbar { - }.openTrackingProtectionTestPage(trackingProtectionTest.url, true) {} - - enhancedTrackingProtection { - verifyEnhancedTrackingProtectionNotice() - }.closeNotificationPopup {} + }.enterURLAndEnterToBrowser(genericPage.url) { + }.openNavigationToolbar { + }.enterURLAndEnterToBrowser(trackingProtectionTest.url) {} enhancedTrackingProtection { - verifyEnhancedTrackingProtectionShield() }.openEnhancedTrackingProtectionSheet { verifyEnhancedTrackingProtectionSheetStatus("ON", true) }.openDetails { verifyEnhancedTrackingProtectionDetailsStatus("Blocked") + verifyTrackingCookiesBlocked() + verifyCryptominersBlocked() + verifyFingerprintersBlocked() + verifyTrackingContentBlocked() + viewTrackingContentBlockList() } } } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/TabbedBrowsingTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/TabbedBrowsingTest.kt index 73a3a49771..abe2f4ce96 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/TabbedBrowsingTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/TabbedBrowsingTest.kt @@ -12,6 +12,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityTestRule import org.mozilla.fenix.helpers.TestAssetHelper @@ -47,6 +48,7 @@ class TabbedBrowsingTest { @Before fun setUp() { + activityTestRule.activity.applicationContext.settings().shouldShowJumpBackInCFR = false mockWebServer = MockWebServer().apply { dispatcher = AndroidAssetDispatcher() start() @@ -281,7 +283,6 @@ class TabbedBrowsingTest { navigationToolbar { }.enterURLAndEnterToBrowser(defaultWebPage.url) { - // verifyPageContent(defaultWebPage.content) }.openTabDrawer { verifyExistingTabList() verifyNewTabButton() @@ -289,7 +290,9 @@ class TabbedBrowsingTest { verifyExistingOpenTabs(defaultWebPage.title) verifyCloseTabsButton(defaultWebPage.title) }.openNewTab { - }.dismissSearchBar { } + verifySearchBarEmpty() + verifyKeyboardVisibility() + } } @Test @@ -298,14 +301,6 @@ class TabbedBrowsingTest { navigationToolbar { }.enterURLAndEnterToBrowser(defaultWebPage.url) { - // verifyPageContent(defaultWebPage.content) - }.openTabDrawer { - verifyExistingTabList() - verifyNewTabButton() - verifyTabTrayOverflowMenu(true) - verifyExistingOpenTabs(defaultWebPage.title) - verifyCloseTabsButton(defaultWebPage.title) - }.closeTabDrawer { }.openTabButtonShortcutsMenu { verifyTabButtonShortcutMenuItems() }.closeTabFromShortcutsMenu { @@ -316,28 +311,19 @@ class TabbedBrowsingTest { verifyFocusedNavigationToolbar() // dismiss search dialog homeScreen { }.pressBack() - verifyHomePrivateBrowsingButton() - verifyHomeMenu() - verifyHomeWordmark() - verifyTabButton() verifyPrivateSessionMessage() verifyHomeToolbar() - verifyHomeComponent() } navigationToolbar { }.enterURLAndEnterToBrowser(defaultWebPage.url) { - }.openTabButtonShortcutsMenu { }.openTabFromShortcutsMenu { verifyKeyboardVisible() verifyFocusedNavigationToolbar() // dismiss search dialog homeScreen { }.pressBack() - verifyHomeMenu() verifyHomeWordmark() - verifyTabButton() verifyHomeToolbar() - verifyHomeComponent() } } } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/ThreeDotMenuMainTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/ThreeDotMenuMainTest.kt index a992796f61..ac0d9e7daf 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/ThreeDotMenuMainTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/ThreeDotMenuMainTest.kt @@ -9,6 +9,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityTestRule import org.mozilla.fenix.ui.robots.homeScreen @@ -28,6 +29,7 @@ class ThreeDotMenuMainTest { @Before fun setUp() { + activityTestRule.activity.applicationContext.settings().shouldShowJumpBackInCFR = false mockWebServer = MockWebServer().apply { dispatcher = AndroidAssetDispatcher() start() diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/TopSitesTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/TopSitesTest.kt index 96eac6ace3..1e3616734f 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/TopSitesTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/TopSitesTest.kt @@ -11,6 +11,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityIntentTestRule import org.mozilla.fenix.helpers.TestAssetHelper @@ -48,6 +49,8 @@ class TopSitesTest { @Test fun verifyAddToFirefoxHome() { + val settings = activityIntentTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val defaultWebPageTitle = "Test_Page_1" @@ -58,9 +61,7 @@ class TopSitesTest { verifyAddToTopSitesButton() }.addToFirefoxHome { verifySnackBarText("Added to top sites!") - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { + }.goToHomescreen { verifyExistingTopSitesList() verifyExistingTopSitesTabs(defaultWebPageTitle) } @@ -68,6 +69,8 @@ class TopSitesTest { @Test fun verifyOpenTopSiteNormalTab() { + val settings = activityIntentTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val defaultWebPageTitle = "Test_Page_1" @@ -78,16 +81,12 @@ class TopSitesTest { verifyAddToTopSitesButton() }.addToFirefoxHome { verifySnackBarText("Added to top sites!") - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { + }.goToHomescreen { verifyExistingTopSitesList() verifyExistingTopSitesTabs(defaultWebPageTitle) }.openTopSiteTabWithTitle(title = defaultWebPageTitle) { verifyUrl(defaultWebPage.url.toString().replace("http://", "")) - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { + }.goToHomescreen { verifyExistingTopSitesList() verifyExistingTopSitesTabs(defaultWebPageTitle) }.openContextMenuOnTopSitesWithTitle(defaultWebPageTitle) { @@ -100,6 +99,8 @@ class TopSitesTest { @Test fun verifyOpenTopSitePrivateTab() { + val settings = activityIntentTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val defaultWebPageTitle = "Test_Page_1" @@ -110,9 +111,7 @@ class TopSitesTest { verifyAddToTopSitesButton() }.addToFirefoxHome { verifySnackBarText("Added to top sites!") - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { + }.goToHomescreen { verifyExistingTopSitesList() verifyExistingTopSitesTabs(defaultWebPageTitle) }.openContextMenuOnTopSitesWithTitle(defaultWebPageTitle) { @@ -124,6 +123,8 @@ class TopSitesTest { @Test fun verifyRenameTopSite() { + val settings = activityIntentTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val defaultWebPageTitle = "Test_Page_1" val defaultWebPageTitleNew = "Test_Page_2" @@ -135,9 +136,7 @@ class TopSitesTest { verifyAddToTopSitesButton() }.addToFirefoxHome { verifySnackBarText("Added to top sites!") - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { + }.goToHomescreen { verifyExistingTopSitesList() verifyExistingTopSitesTabs(defaultWebPageTitle) }.openContextMenuOnTopSitesWithTitle(defaultWebPageTitle) { @@ -150,6 +149,8 @@ class TopSitesTest { @Test fun verifyRemoveTopSite() { + val settings = activityIntentTestRule.activity.applicationContext.settings() + settings.shouldShowJumpBackInCFR = false val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) val defaultWebPageTitle = "Test_Page_1" @@ -160,9 +161,7 @@ class TopSitesTest { verifyAddToTopSitesButton() }.addToFirefoxHome { verifySnackBarText("Added to top sites!") - }.openTabDrawer { - }.openNewTab { - }.dismissSearchBar { + }.goToHomescreen { verifyExistingTopSitesList() verifyExistingTopSitesTabs(defaultWebPageTitle) }.openContextMenuOnTopSitesWithTitle(defaultWebPageTitle) { diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/BookmarksRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/BookmarksRobot.kt index 6a222b9475..8155427bff 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/BookmarksRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/BookmarksRobot.kt @@ -52,6 +52,10 @@ class BookmarksRobot { assertBookmarksView() } + fun verifyAddFolderButton() = assertAddFolderButton() + + fun verifyCloseButton() = assertCloseButton() + fun verifyDeleteMultipleBookmarksSnackBar() = assertSnackBarText("Bookmarks deleted") fun verifyBookmarkFavicon(forUrl: Uri) = assertBookmarkFavicon(forUrl) @@ -197,13 +201,15 @@ class BookmarksRobot { fun longTapDesktopFolder(title: String) = onView(withText(title)).perform(longClick()) - fun confirmFolderDeletion() { + fun confirmDeletion() { onView(withText(R.string.delete_browsing_data_prompt_allow)) .inRoot(RootMatchers.isDialog()) .check(matches(isDisplayed())) .click() } + fun clickDeleteInEditModeButton() = deleteInEditModeButton().click() + class Transition { fun closeMenu(interact: HomeScreenRobot.() -> Unit): Transition { closeButton().click() @@ -290,6 +296,8 @@ private fun bookmarkURLEditBox() = onView(withId(R.id.bookmarkUrlEdit)) private fun saveBookmarkButton() = onView(withId(R.id.save_bookmark_button)) +private fun deleteInEditModeButton() = onView(withId(R.id.delete_bookmark_button)) + private fun signInToSyncButton() = onView(withId(R.id.bookmark_folders_sign_in)) private fun assertBookmarksView() { @@ -302,6 +310,11 @@ private fun assertBookmarksView() { .check(matches(isDisplayed())) } +private fun assertAddFolderButton() = + addFolderButton().check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + +private fun assertCloseButton() = closeButton().check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + private fun assertEmptyBookmarksList() = onView(withId(R.id.bookmarks_empty_view)).check(matches(withText("No bookmarks here"))) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/BrowserRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/BrowserRobot.kt index 0390756d85..5be3b47238 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/BrowserRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/BrowserRobot.kt @@ -26,7 +26,6 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withResourceName import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -39,7 +38,6 @@ import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.mediasession.MediaSession import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString -import org.hamcrest.Matchers.not import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.mozilla.fenix.R @@ -164,15 +162,9 @@ class BrowserRobot { fun verifyEnhancedTrackingProtectionSwitch() = assertEnhancedTrackingProtectionSwitch() - fun clickEnhancedTrackingProtectionSwitchOffOn() = - onView(withResourceName("switch_widget")).click() - - fun verifyProtectionSettingsButton() = assertProtectionSettingsButton() - fun verifyEnhancedTrackingOptions() { - clickEnhancedTrackingProtectionPanel() + onView(withId(R.id.mozac_browser_toolbar_security_indicator)).click() verifyEnhancedTrackingProtectionSwitch() - verifyProtectionSettingsButton() } fun verifyMenuButton() = assertMenuButton() @@ -214,11 +206,6 @@ class BrowserRobot { .perform(ViewActions.pressBack()) } - fun clickEnhancedTrackingProtectionPanel() = enhancedTrackingProtectionIndicator().click() - - fun verifyEnhancedTrackingProtectionPanelNotVisible() = - assertEnhancedTrackingProtectionIndicatorNotVisible() - fun clickContextOpenLinkInNewTab() { val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) mDevice.waitNotNull( @@ -419,7 +406,9 @@ class BrowserRobot { .className(EditText::class.java) ) passwordField.waitForExists(waitingTime) - passwordField.setText(password) + passwordField.click() + passwordField.clearTextField() + passwordField.text = password // wait until the password is hidden assertTrue(mDevice.findObject(UiSelector().text(password)).waitUntilGone(waitingTime)) } @@ -461,10 +450,10 @@ class BrowserRobot { fun swipeNavBarRight(tabUrl: String) { // failing to swipe on Firebase sometimes, so it tries again try { - navURLBar().perform(ViewActions.swipeRight()) + navURLBar().swipeRight(2) assertTrue(mDevice.findObject(UiSelector().text(tabUrl)).waitUntilGone(waitingTime)) } catch (e: AssertionError) { - navURLBar().perform(ViewActions.swipeRight()) + navURLBar().swipeRight(2) assertTrue(mDevice.findObject(UiSelector().text(tabUrl)).waitUntilGone(waitingTime)) } } @@ -472,10 +461,10 @@ class BrowserRobot { fun swipeNavBarLeft(tabUrl: String) { // failing to swipe on Firebase sometimes, so it tries again try { - navURLBar().perform(ViewActions.swipeLeft()) + navURLBar().swipeLeft(2) assertTrue(mDevice.findObject(UiSelector().text(tabUrl)).waitUntilGone(waitingTime)) } catch (e: AssertionError) { - navURLBar().perform(ViewActions.swipeLeft()) + navURLBar().swipeLeft(2) assertTrue(mDevice.findObject(UiSelector().text(tabUrl)).waitUntilGone(waitingTime)) } } @@ -499,7 +488,8 @@ class BrowserRobot { } fun openNavigationToolbar(interact: NavigationToolbarRobot.() -> Unit): NavigationToolbarRobot.Transition { - mDevice.waitForIdle(waitingTime) + mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar")) + .waitForExists(waitingTime) navURLBar().click() NavigationToolbarRobot().interact() @@ -507,23 +497,17 @@ class BrowserRobot { } fun openTabDrawer(interact: TabDrawerRobot.() -> Unit): TabDrawerRobot.Transition { - mDevice.waitForIdle(waitingTime) + mDevice.waitNotNull(Until.findObject(By.desc("Tabs"))) tabsCounter().click() - mDevice.waitNotNull( - Until.findObject(By.res("$packageName:id/tab_layout")), - waitingTime - ) + mDevice.waitNotNull(Until.findObject(By.res("$packageName:id/tab_layout"))) TabDrawerRobot().interact() return TabDrawerRobot.Transition() } fun openTabButtonShortcutsMenu(interact: NavigationToolbarRobot.() -> Unit): NavigationToolbarRobot.Transition { - mDevice.waitForIdle(waitingTime) - - tabsCounter().perform( - ViewActions.longClick() - ) + mDevice.waitNotNull(Until.findObject(By.desc("Tabs"))) + tabsCounter().click(LONG_CLICK_DURATION) NavigationToolbarRobot().interact() return NavigationToolbarRobot.Transition() @@ -540,6 +524,14 @@ class BrowserRobot { onView(withContentDescription("Home screen")) .check(matches(isDisplayed())) .click() + mDevice.waitForIdle() + + HomeScreenRobot().interact() + return HomeScreenRobot.Transition() + } + + fun goBack(interact: HomeScreenRobot.() -> Unit): HomeScreenRobot.Transition { + mDevice.pressBack() HomeScreenRobot().interact() return HomeScreenRobot.Transition() @@ -566,27 +558,11 @@ fun browserScreen(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { return BrowserRobot.Transition() } -private fun dismissOnboardingButton() = onView(withId(R.id.close_onboarding)) +fun navURLBar() = mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar")) -fun dismissTrackingOnboarding() { - mDevice.wait(Until.findObject(By.res("close_onboarding")), waitingTime) - dismissOnboardingButton().click() -} +private fun assertNavURLBar() = assertTrue(navURLBar().waitForExists(waitingTime)) -fun navURLBar() = onView(withId(R.id.toolbar)) - -private fun assertNavURLBar() = navURLBar() - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - -private fun assertNavURLBarHidden() = navURLBar() - .check(matches(not(isDisplayed()))) - -fun enhancedTrackingProtectionIndicator() = - onView(withId(R.id.mozac_browser_toolbar_tracking_protection_indicator)) - -private fun assertEnhancedTrackingProtectionIndicatorNotVisible() { - enhancedTrackingProtectionIndicator().check(matches(not(isDisplayed()))) -} +private fun assertNavURLBarHidden() = assertTrue(navURLBar().waitUntilGone(waitingTime)) private fun assertEnhancedTrackingProtectionSwitch() { withText(R.id.trackingProtectionSwitch) @@ -610,7 +586,7 @@ private fun assertMenuButton() { .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) } -private fun tabsCounter() = onView(withId(R.id.counter_box)) +private fun tabsCounter() = mDevice.findObject(By.desc("Tabs")) private fun mediaPlayerPlayButton() = mDevice.findObject( diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/CollectionRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/CollectionRobot.kt index cfc612565c..60ddb1d036 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/CollectionRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/CollectionRobot.kt @@ -6,6 +6,7 @@ import androidx.test.espresso.action.ViewActions.pressImeActionButton import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.action.ViewActions.swipeLeft import androidx.test.espresso.action.ViewActions.swipeRight +import androidx.test.espresso.action.ViewActions.swipeUp import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers @@ -131,7 +132,6 @@ class CollectionRobot { fun selectDeleteCollection() { onView(withText("Delete collection")).click() - mDevice.waitNotNull(Until.findObject(By.res("android:id/message")), waitingTime) } fun confirmDeleteCollection() { @@ -192,6 +192,8 @@ class CollectionRobot { fun goBackInCollectionFlow() = backButton().click() + fun swipeToBottom() = onView(withId(R.id.sessionControlRecyclerView)).perform(swipeUp()) + class Transition { fun collapseCollection( title: String, diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt index b2fdff296c..2e769d4ab6 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/DownloadRobot.kt @@ -28,6 +28,7 @@ import org.mozilla.fenix.helpers.Constants.PackageName.GOOGLE_APPS_PHOTOS import org.mozilla.fenix.helpers.TestAssetHelper import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.TestHelper +import org.mozilla.fenix.helpers.TestHelper.assertExternalAppOpens import org.mozilla.fenix.helpers.TestHelper.packageName import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull @@ -42,7 +43,7 @@ class DownloadRobot { fun verifyDownloadNotificationPopup() = assertDownloadNotificationPopup() - fun verifyPhotosAppOpens() = assertPhotosOpens() + fun verifyPhotosAppOpens() = assertExternalAppOpens(GOOGLE_APPS_PHOTOS) fun verifyDownloadedFileName(fileName: String) { mDevice.findObject(UiSelector().text(fileName)).waitForExists(waitingTime) @@ -144,18 +145,6 @@ private fun clickOpenButton() = matches(isDisplayed()) ) -private fun assertPhotosOpens() { - if (isPackageInstalled(GOOGLE_APPS_PHOTOS)) { - Intents.intended(IntentMatchers.toPackage(GOOGLE_APPS_PHOTOS)) - } else { - val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - mDevice.waitNotNull( - Until.findObject(By.text("Could not open file")), - TestAssetHelper.waitingTime - ) - } -} - private fun downloadedFile(fileName: String) = onView(withText(fileName)) private fun assertDownloadedFileIcon() = onView(withId(R.id.favicon)).check(matches(isDisplayed())) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/EnhancedTrackingProtectionRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/EnhancedTrackingProtectionRobot.kt index e9dde4763c..4fc1313fb7 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/EnhancedTrackingProtectionRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/EnhancedTrackingProtectionRobot.kt @@ -7,7 +7,6 @@ package org.mozilla.fenix.ui.robots import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed @@ -18,9 +17,9 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until +import junit.framework.TestCase.assertTrue import org.hamcrest.Matchers.containsString import org.mozilla.fenix.R -import org.mozilla.fenix.helpers.TestAssetHelper import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.TestHelper.packageName import org.mozilla.fenix.helpers.click @@ -34,10 +33,6 @@ class EnhancedTrackingProtectionRobot { val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())!! - fun verifyEnhancedTrackingProtectionNotice() = assertEnhancedTrackingProtectionNotice() - - fun verifyEnhancedTrackingProtectionShield() = assertEnhancedTrackingProtectionShield() - fun verifyEnhancedTrackingProtectionSheetStatus(status: String, state: Boolean) = assertEnhancedTrackingProtectionSheetStatus(status, state) @@ -50,29 +45,41 @@ class EnhancedTrackingProtectionRobot { fun verifyCryptominersBlocked() = assertCryptominersBlocked() - fun verifyBasicLevelTrackingContentBlocked() = assertBasicLevelTrackingContentBlocked() + fun verifyTrackingContentBlocked() = assertTrackingContentBlocked() + + fun viewTrackingContentBlockList() { + trackingContentBlockListButton() + .check(matches(isDisplayed())) + .click() + onView(withId(R.id.blocking_text_list)) + .check( + matches( + withText( + containsString( + "social-track-digest256.dummytracker.org\n" + + "ads-track-digest256.dummytracker.org\n" + + "analytics-track-digest256.dummytracker.org" + ) + ) + ) + ) + } class Transition { fun openEnhancedTrackingProtectionSheet(interact: EnhancedTrackingProtectionRobot.() -> Unit): Transition { + openEnhancedTrackingProtectionSheet().waitForExists(waitingTime) openEnhancedTrackingProtectionSheet().click() EnhancedTrackingProtectionRobot().interact() return Transition() } - fun closeNotificationPopup(interact: BrowserRobot.() -> Unit): Transition { - closeButton().click() - - BrowserRobot().interact() - return Transition() - } - - fun closeEnhancedTrackingProtectionSheet(interact: BrowserRobot.() -> Unit): Transition { + fun closeEnhancedTrackingProtectionSheet(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { // Back out of the Enhanced Tracking Protection sheet mDevice.pressBack() BrowserRobot().interact() - return Transition() + return BrowserRobot.Transition() } fun disableEnhancedTrackingProtectionFromSheet(interact: EnhancedTrackingProtectionRobot.() -> Unit): Transition { @@ -83,13 +90,16 @@ class EnhancedTrackingProtectionRobot { } fun openProtectionSettings(interact: SettingsSubMenuEnhancedTrackingProtectionRobot.() -> Unit): Transition { - openEnhancedTrackingProtectionSettings().click() + openEnhancedTrackingProtectionDetails().waitForExists(waitingTime) + openEnhancedTrackingProtectionDetails().click() + trackingProtectionSettingsButton().click() SettingsSubMenuEnhancedTrackingProtectionRobot().interact() return Transition() } fun openDetails(interact: EnhancedTrackingProtectionRobot.() -> Unit): Transition { + openEnhancedTrackingProtectionDetails().waitForExists(waitingTime) openEnhancedTrackingProtectionDetails().click() EnhancedTrackingProtectionRobot().interact() @@ -103,23 +113,10 @@ fun enhancedTrackingProtection(interact: EnhancedTrackingProtectionRobot.() -> U return EnhancedTrackingProtectionRobot.Transition() } -private fun assertEnhancedTrackingProtectionNotice() { - mDevice.waitNotNull( - Until.findObject(By.res("$packageName:id/onboarding_message")), - TestAssetHelper.waitingTime - ) -} - -private fun assertEnhancedTrackingProtectionShield() { - mDevice.waitNotNull( - Until.findObjects(By.descContains("Tracking Protection has blocked trackers")) - ) -} - private fun assertEnhancedTrackingProtectionSheetStatus(status: String, state: Boolean) { mDevice.waitNotNull(Until.findObjects(By.textContains(status))) onView(ViewMatchers.withResourceName("switch_widget")).check( - ViewAssertions.matches( + matches( isChecked( state ) @@ -131,25 +128,23 @@ private fun assertEnhancedTrackingProtectionDetailsStatus(status: String) { mDevice.waitNotNull(Until.findObjects(By.textContains(status))) } -private fun closeButton() = onView(ViewMatchers.withId(R.id.close_onboarding)) - private fun openEnhancedTrackingProtectionSheet() = - onView(ViewMatchers.withId(R.id.mozac_browser_toolbar_tracking_protection_indicator)) + mDevice.findObject(UiSelector().resourceId("$packageName:id/mozac_browser_toolbar_security_indicator")) private fun disableEnhancedTrackingProtection() = onView(ViewMatchers.withResourceName("switch_widget")) -private fun openEnhancedTrackingProtectionSettings() = - onView(ViewMatchers.withId(R.id.protection_settings)) +private fun trackingProtectionSettingsButton() = + onView(withId(R.id.protection_settings)) private fun openEnhancedTrackingProtectionDetails() = - onView(ViewMatchers.withId(R.id.tracking_content)) + mDevice.findObject(UiSelector().resourceId("$packageName:id/trackingProtectionDetails")) private fun assertTrackingCookiesBlocked() { mDevice.findObject(UiSelector().resourceId("$packageName:id/cross_site_tracking")) .waitForExists(waitingTime) onView(withId(R.id.blocking_header)).check(matches(isDisplayed())) - onView(withId(R.id.cross_site_tracking)).check(matches(isDisplayed())) + onView(withId(R.id.tracking_content)).check(matches(isDisplayed())) } private fun assertFingerprintersBlocked() { @@ -166,23 +161,11 @@ private fun assertCryptominersBlocked() { onView(withId(R.id.cryptominers)).check(matches(isDisplayed())) } -private fun assertBasicLevelTrackingContentBlocked() { - mDevice.findObject(UiSelector().resourceId("$packageName:id/tracking_content")) - .waitForExists(waitingTime) - - onView(withId(R.id.tracking_content)) - .check(matches(isDisplayed())) - .click() - onView(withId(R.id.blocking_text_list)) - .check( - matches( - withText( - containsString( - "social-track-digest256.dummytracker.org\n" + - "ads-track-digest256.dummytracker.org\n" + - "analytics-track-digest256.dummytracker.org" - ) - ) - ) - ) +private fun assertTrackingContentBlocked() { + assertTrue( + mDevice.findObject(UiSelector().resourceId("$packageName:id/tracking_content")) + .waitForExists(waitingTime) + ) } + +private fun trackingContentBlockListButton() = onView(withId(R.id.tracking_content)) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HistoryRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HistoryRobot.kt index 430d6e3105..a9731051fd 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HistoryRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HistoryRobot.kt @@ -19,8 +19,10 @@ import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import org.hamcrest.Matchers import org.hamcrest.Matchers.allOf +import org.junit.Assert.assertTrue import org.mozilla.fenix.R import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime +import org.mozilla.fenix.helpers.TestHelper.waitForObjects import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull @@ -51,6 +53,8 @@ class HistoryRobot { assertVisitedTimeTitle() } + fun verifyHistoryItemExists(url: String) = assertHistoryItemExists(url) + fun verifyFirstTestPageTitle(title: String) = assertTestPageTitle(title) fun verifyTestPageUrl(expectedUrl: Uri) = assertPageUrl(expectedUrl) @@ -61,21 +65,12 @@ class HistoryRobot { fun verifyHomeScreen() = HomeScreenRobot().verifyHomeScreen() - fun openOverflowMenu() { - mDevice.waitNotNull( - Until.findObject( - By.res("org.mozilla.fenix.debug:id/overflow_menu") - ), - waitingTime - ) - threeDotMenu().click() - } - fun clickDeleteHistoryButton() { - mDevice.waitNotNull(Until.findObject(By.text("Delete history")), waitingTime) - deleteAllHistoryButton().click() + deleteButton().click() } + fun clickDeleteAllHistoryButton() = deleteAllButton().click() + fun confirmDeleteAllHistory() { onView(withText("Delete")) .inRoot(isDialog()) @@ -92,15 +87,6 @@ class HistoryRobot { BrowserRobot().interact() return BrowserRobot.Transition() } - - fun openThreeDotMenu(interact: ThreeDotMenuHistoryItemRobot.() -> Unit): - ThreeDotMenuHistoryItemRobot.Transition { - - threeDotMenu().click() - - ThreeDotMenuHistoryItemRobot().interact() - return ThreeDotMenuHistoryItemRobot.Transition() - } } } @@ -113,11 +99,11 @@ private fun testPageTitle() = onView(allOf(withId(R.id.title), withText("Test_Pa private fun pageUrl() = onView(withId(R.id.url)) -private fun threeDotMenu() = onView(withId(R.id.overflow_menu)) +private fun deleteButton() = onView(withId(R.id.overflow_menu)) -private fun snackBarText() = onView(withId(R.id.snackbar_text)) +private fun deleteAllButton() = onView(withId(R.id.history_delete_all)) -private fun deleteAllHistoryButton() = onView(withId(R.id.delete_button)) +private fun snackBarText() = onView(withId(R.id.snackbar_text)) private fun assertHistoryMenuView() { onView( @@ -138,6 +124,11 @@ private fun assertEmptyHistoryView() = private fun assertHistoryListExists() = mDevice.findObject(UiSelector().resourceId("R.id.history_list")).waitForExists(waitingTime) +private fun assertHistoryItemExists(url: String) { + mDevice.waitForObjects(mDevice.findObject(UiSelector().textContains(url))) + assertTrue(mDevice.findObject(UiSelector().textContains(url)).waitForExists(waitingTime)) +} + private fun assertVisitedTimeTitle() = onView(withId(R.id.header_title)).check(matches(withText("Today"))) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HomeScreenRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HomeScreenRobot.kt index 20abd2e87c..c1006a420f 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HomeScreenRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HomeScreenRobot.kt @@ -170,8 +170,17 @@ class HomeScreenRobot { } fun openThreeDotMenu(interact: ThreeDotMenuMainRobot.() -> Unit): ThreeDotMenuMainRobot.Transition { - mDevice.waitNotNull(Until.findObject(By.res("$packageName:id/menuButton")), waitingTime) - threeDotButton().perform(click()) + // Issue: https://github.com/mozilla-mobile/fenix/issues/21578 + try { + mDevice.waitNotNull( + Until.findObject(By.res("$packageName:id/menuButton")), + waitingTime + ) + } catch (e: AssertionError) { + mDevice.pressBack() + } finally { + threeDotButton().perform(click()) + } ThreeDotMenuMainRobot().interact() return ThreeDotMenuMainRobot.Transition() @@ -180,7 +189,7 @@ class HomeScreenRobot { fun openSearch(interact: SearchRobot.() -> Unit): SearchRobot.Transition { mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar")) .waitForExists(waitingTime) - navigationToolbar().perform(click()) + navigationToolbar().click() SearchRobot().interact() return SearchRobot.Transition() @@ -229,7 +238,7 @@ class HomeScreenRobot { fun openNavigationToolbar(interact: NavigationToolbarRobot.() -> Unit): NavigationToolbarRobot.Transition { mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar")) .waitForExists(waitingTime) - navigationToolbar().perform(click()) + navigationToolbar().click() NavigationToolbarRobot().interact() return NavigationToolbarRobot.Transition() @@ -344,10 +353,9 @@ private fun assertKeyboardVisibility(isExpectedToBeVisible: Boolean) = .contains("mInputShown=true") ) -private fun navigationToolbar() = onView(withId(R.id.toolbar)) +private fun navigationToolbar() = mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar")) -private fun assertNavigationToolbar() = - navigationToolbar().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun assertNavigationToolbar() = assertTrue(navigationToolbar().waitForExists(waitingTime)) private fun assertFocusedNavigationToolbar() = onView(allOf(withHint("Search or enter address"))) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/NavigationToolbarRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/NavigationToolbarRobot.kt index d8ee7b7f14..bcda5524c3 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/NavigationToolbarRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/NavigationToolbarRobot.kt @@ -12,15 +12,11 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.IdlingResource import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.action.ViewActions.pressImeActionButton -import androidx.test.espresso.action.ViewActions.replaceText -import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasDescendant -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withId @@ -32,17 +28,14 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until -import junit.framework.TestCase.assertTrue import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.anyOf -import org.hamcrest.CoreMatchers.containsString import org.hamcrest.CoreMatchers.not +import org.junit.Assert.assertTrue import org.mozilla.fenix.R import org.mozilla.fenix.helpers.SessionLoadedIdlingResource import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.TestHelper.packageName -import org.mozilla.fenix.helpers.assertions.AwesomeBarAssertion.Companion.suggestionsAreEqualTo -import org.mozilla.fenix.helpers.assertions.AwesomeBarAssertion.Companion.suggestionsAreGreaterThan import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull @@ -51,12 +44,6 @@ import org.mozilla.fenix.helpers.ext.waitNotNull */ class NavigationToolbarRobot { - fun verifySearchSuggestionsAreMoreThan(suggestionSize: Int) = - assertSuggestionsAreMoreThan(suggestionSize) - - fun verifySearchSuggestionsAreEqualTo(suggestionSize: Int) = - assertSuggestionsAreEqualTo(suggestionSize) - fun verifyNoHistoryBookmarks() = assertNoHistoryBookmarks() fun verifyTabButtonShortcutMenuItems() = assertTabButtonShortcutMenuItems() @@ -67,7 +54,7 @@ class NavigationToolbarRobot { fun verifyCloseReaderViewDetected(visible: Boolean = false) = assertCloseReaderViewDetected(visible) - fun typeSearchTerm(searchTerm: String) = awesomeBar().perform(typeText(searchTerm)) + fun typeSearchTerm(searchTerm: String) = awesomeBar().setText(searchTerm) fun toggleReaderView() { mDevice.findObject( @@ -87,7 +74,14 @@ class NavigationToolbarRobot { fun goBackToWebsite(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { openEditURLView() clearAddressBar().click() - awesomeBar().check((matches(withText(containsString(""))))) + assertTrue( + mDevice.findObject( + UiSelector() + .resourceId("$packageName:id/mozac_browser_toolbar_edit_url_view") + .textContains("") + ).waitForExists(waitingTime) + ) + goBackButton() BrowserRobot().interact() @@ -102,13 +96,13 @@ class NavigationToolbarRobot { openEditURLView() - awesomeBar().perform(replaceText(url.toString()), pressImeActionButton()) + awesomeBar().setText(url.toString()) + mDevice.pressEnter() runWithIdleRes(sessionLoadedIdlingResource) { onView( anyOf( withResourceName("browserLayout"), - withResourceName("onboarding_message"), // Req ETP dialog withResourceName("download_button") ) ).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) @@ -118,47 +112,6 @@ class NavigationToolbarRobot { return BrowserRobot.Transition() } - fun openTrackingProtectionTestPage( - url: Uri, - etpEnabled: Boolean, - interact: BrowserRobot.() -> Unit - ): BrowserRobot.Transition { - openEditURLView() - - awesomeBar().perform(replaceText(url.toString()), pressImeActionButton()) - - val onboardingMessage = - mDevice.findObject(UiSelector().resourceId("$packageName:id/onboarding_message")) - - val onboardingDisplayed = onboardingMessage.waitForExists(waitingTime) - - when (etpEnabled) { - true -> - try { - assertTrue( - "Onboarding message not displayed", - onboardingDisplayed - ) - } catch (e: AssertionError) { - openThreeDotMenu { - }.stopPageLoad { - if (!onboardingDisplayed) { - openThreeDotMenu { - }.refreshPage { - assertTrue(onboardingDisplayed) - } - } - } - } - - false -> - onView(withResourceName("browserLayout")).check(matches(isDisplayed())) - } - - BrowserRobot().interact() - return BrowserRobot.Transition() - } - fun openTabCrashReporter(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { val crashUrl = "about:crashcontent" @@ -166,7 +119,8 @@ class NavigationToolbarRobot { openEditURLView() - awesomeBar().perform(replaceText(crashUrl), pressImeActionButton()) + awesomeBar().setText(crashUrl) + mDevice.pressEnter() runWithIdleRes(sessionLoadedIdlingResource) { mDevice.findObject(UiSelector().resourceId("$packageName:id/crash_tab_image")) @@ -203,15 +157,11 @@ class NavigationToolbarRobot { sessionLoadedIdlingResource = SessionLoadedIdlingResource() mDevice.waitNotNull(Until.findObject(By.res("$packageName:id/toolbar")), waitingTime) urlBar().click() - awesomeBar().perform(replaceText(url.toString()), pressImeActionButton()) + awesomeBar().setText(url.toString()) + mDevice.pressEnter() runWithIdleRes(sessionLoadedIdlingResource) { - onView( - anyOf( - ViewMatchers.withResourceName("browserLayout"), - ViewMatchers.withResourceName("onboarding_message") // Req for ETP dialog - ) - ) + onView(ViewMatchers.withResourceName("browserLayout")) .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } @@ -298,6 +248,17 @@ class NavigationToolbarRobot { HomeScreenRobot().interact() return HomeScreenRobot.Transition() } + + fun clickUrlbar(interact: SearchRobot.() -> Unit): SearchRobot.Transition { + urlBar().click() + + mDevice.findObject( + UiSelector().resourceId("$packageName:id/mozac_browser_toolbar_edit_url_view") + ).waitForExists(waitingTime) + + SearchRobot().interact() + return SearchRobot.Transition() + } } } @@ -306,12 +267,6 @@ fun navigationToolbar(interact: NavigationToolbarRobot.() -> Unit): NavigationTo return NavigationToolbarRobot.Transition() } -fun clickUrlbar(interact: SearchRobot.() -> Unit): SearchRobot.Transition { - urlBar().click() - SearchRobot().interact() - return SearchRobot.Transition() -} - fun openEditURLView() { mDevice.waitNotNull( Until.findObject(By.res("$packageName:id/toolbar")), @@ -324,16 +279,6 @@ fun openEditURLView() { ) } -private fun assertSuggestionsAreEqualTo(suggestionSize: Int) { - mDevice.waitForIdle() - onView(withId(R.id.awesome_bar)).check(suggestionsAreEqualTo(suggestionSize)) -} - -private fun assertSuggestionsAreMoreThan(suggestionSize: Int) { - mDevice.waitForIdle() - onView(withId(R.id.awesome_bar)).check(suggestionsAreGreaterThan(suggestionSize)) -} - private fun assertNoHistoryBookmarks() { onView(withId(R.id.container)) .check(matches(not(hasDescendant(withText("Test_Page_1"))))) @@ -348,13 +293,14 @@ private fun assertTabButtonShortcutMenuItems() { .check(matches(hasDescendant(withText("New tab")))) } -private fun dismissOnboardingButton() = onView(withId(R.id.close_onboarding)) -private fun urlBar() = onView(withId(R.id.toolbar)) -private fun awesomeBar() = onView(withId(R.id.mozac_browser_toolbar_edit_url_view)) +private fun urlBar() = mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar")) +private fun awesomeBar() = + mDevice.findObject(UiSelector().resourceId("$packageName:id/mozac_browser_toolbar_edit_url_view")) private fun threeDotButton() = onView(withId(R.id.mozac_browser_toolbar_menu)) private fun tabTrayButton() = onView(withId(R.id.tab_button)) private fun fillLinkButton() = onView(withId(R.id.fill_link_from_clipboard)) -private fun clearAddressBar() = onView(withId(R.id.mozac_browser_toolbar_clear_view)) +private fun clearAddressBar() = + mDevice.findObject(UiSelector().resourceId("$packageName:id/mozac_browser_toolbar_clear_view")) private fun goBackButton() = mDevice.pressBack() private fun readerViewToggle() = onView(withParent(withId(R.id.mozac_browser_toolbar_page_actions))) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/RecentlyClosedTabsRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/RecentlyClosedTabsRobot.kt index b112db7e2e..d3984227c5 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/RecentlyClosedTabsRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/RecentlyClosedTabsRobot.kt @@ -41,44 +41,11 @@ class RecentlyClosedTabsRobot { fun verifyRecentlyClosedTabsUrl(expectedUrl: Uri) = assertPageUrl(expectedUrl) - fun openRecentlyClosedTabsThreeDotMenu() = recentlyClosedTabsThreeDotButton().click() - - fun verifyRecentlyClosedTabsMenuCopy() = assertRecentlyClosedTabsMenuCopy() - - fun verifyRecentlyClosedTabsMenuShare() = assertRecentlyClosedTabsMenuShare() - - fun verifyRecentlyClosedTabsMenuNewTab() = assertRecentlyClosedTabsOverlayNewTab() - - fun verifyRecentlyClosedTabsMenuPrivateTab() = assertRecentlyClosedTabsMenuPrivateTab() - - fun verifyRecentlyClosedTabsMenuDelete() = assertRecentlyClosedTabsMenuDelete() - - fun clickCopyRecentlyClosedTabs() = recentlyClosedTabsCopyButton().click() - - fun clickShareRecentlyClosedTabs() = recentlyClosedTabsShareButton().click() - - fun clickDeleteCopyRecentlyClosedTabs() = recentlyClosedTabsDeleteButton().click() - - fun verifyCopyRecentlyClosedTabsSnackBarText() = assertCopySnackBarText() - - fun verifyShareOverlay() = assertRecentlyClosedShareOverlay() - - fun verifyShareTabFavicon() = assertRecentlyClosedShareFavicon() - - fun verifyShareTabTitle(title: String) = assetRecentlyClosedShareTitle(title) - - fun verifyShareTabUrl(expectedUrl: Uri) = assertRecentlyClosedShareUrl(expectedUrl) + fun clickDeleteRecentlyClosedTabs() = recentlyClosedTabsDeleteButton().click() class Transition { fun clickOpenInNewTab(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { - recentlyClosedTabsNewTabButton().click() - - BrowserRobot().interact() - return BrowserRobot.Transition() - } - - fun clickOpenInPrivateTab(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { - recentlyClosedTabsNewPrivateTabButton().click() + recentlyClosedTabsPageTitle().click() BrowserRobot().interact() return BrowserRobot.Transition() @@ -138,7 +105,7 @@ private fun assertRecentlyClosedTabsPageTitle(title: String) { ) } -private fun recentlyClosedTabsThreeDotButton() = +private fun recentlyClosedTabsDeleteButton() = onView( allOf( withId(R.id.overflow_menu), @@ -147,93 +114,3 @@ private fun recentlyClosedTabsThreeDotButton() = ) ) ) - -private fun assertRecentlyClosedTabsMenuCopy() = - onView(withText("Copy")) - .check( - matches( - withEffectiveVisibility(Visibility.VISIBLE) - ) - ) - -private fun assertRecentlyClosedTabsMenuShare() = - onView(withText("Share")) - .check( - matches( - withEffectiveVisibility(Visibility.VISIBLE) - ) - ) - -private fun assertRecentlyClosedTabsOverlayNewTab() = - onView(withText("Open in new tab")) - .check( - matches( - withEffectiveVisibility(Visibility.VISIBLE) - ) - ) - -private fun assertRecentlyClosedTabsMenuPrivateTab() = - onView(withText("Open in private tab")) - .check( - matches( - withEffectiveVisibility(Visibility.VISIBLE) - ) - ) - -private fun assertRecentlyClosedTabsMenuDelete() = - onView(withText("Delete")) - .check( - matches( - withEffectiveVisibility(Visibility.VISIBLE) - ) - ) - -private fun recentlyClosedTabsCopyButton() = onView(withText("Copy")) - -private fun copySnackBarText() = onView(withId(R.id.snackbar_text)) - -private fun assertCopySnackBarText() = copySnackBarText() - .check( - matches - (withText("URL copied")) - ) - -private fun recentlyClosedTabsShareButton() = onView(withText("Share")) - -private fun assertRecentlyClosedShareOverlay() = - onView(withId(R.id.shareWrapper)) - .check( - matches(ViewMatchers.isDisplayed()) - ) - -private fun assetRecentlyClosedShareTitle(title: String) = - onView(withId(R.id.share_tab_title)) - .check( - matches(ViewMatchers.isDisplayed()) - ) - .check( - matches(withText(title)) - ) - -private fun assertRecentlyClosedShareFavicon() = - onView(withId(R.id.share_tab_favicon)) - .check( - matches(ViewMatchers.isDisplayed()) - ) - -private fun assertRecentlyClosedShareUrl(expectedUrl: Uri) = - onView( - allOf( - withId(R.id.share_tab_url), - withEffectiveVisibility(Visibility.VISIBLE) - ) - ) - .check( - matches(withText(Matchers.containsString(expectedUrl.toString()))) - ) - -private fun recentlyClosedTabsNewTabButton() = onView(withText("Open in new tab")) - -private fun recentlyClosedTabsNewPrivateTabButton() = onView(withText("Open in private tab")) - -private fun recentlyClosedTabsDeleteButton() = onView(withText("Delete")) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SearchRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SearchRobot.kt index 3048f1876c..35f317781b 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SearchRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SearchRobot.kt @@ -6,19 +6,23 @@ package org.mozilla.fenix.ui.robots -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex import androidx.test.espresso.Espresso.onView import androidx.test.espresso.ViewInteraction -import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.closeSoftKeyboard -import androidx.test.espresso.action.ViewActions.swipeDown -import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.Visibility -import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withId @@ -31,16 +35,17 @@ import androidx.test.uiautomator.UiObject import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import org.hamcrest.CoreMatchers.allOf -import org.hamcrest.CoreMatchers.anyOf import org.hamcrest.CoreMatchers.startsWith -import org.hamcrest.Matchers import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.mozilla.fenix.R import org.mozilla.fenix.helpers.Constants.LONG_CLICK_DURATION import org.mozilla.fenix.helpers.SessionLoadedIdlingResource import org.mozilla.fenix.helpers.TestAssetHelper import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime +import org.mozilla.fenix.helpers.TestAssetHelper.waitingTimeShort import org.mozilla.fenix.helpers.TestHelper.packageName +import org.mozilla.fenix.helpers.TestHelper.waitForObjects import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull @@ -53,24 +58,28 @@ class SearchRobot { fun verifyScanButton() = assertScanButton() fun verifySearchEngineButton() = assertSearchEngineButton() fun verifySearchWithText() = assertSearchWithText() - fun verifySearchEngineResults(searchEngineName: String) = - assertSearchEngineResults(searchEngineName) + fun verifySearchEngineResults(rule: ComposeTestRule, searchEngineName: String, count: Int) = + assertSearchEngineResults(rule, searchEngineName, count) + fun verifySearchEngineSuggestionResults(rule: ComposeTestRule, searchSuggestion: String) = + assertSearchEngineSuggestionResults(rule, searchSuggestion) + fun verifyNoSuggestionsAreDisplayed(rule: ComposeTestRule, searchSuggestion: String) = + assertNoSuggestionsAreDisplayed(rule, searchSuggestion) fun verifySearchEngineURL(searchEngineName: String) = assertSearchEngineURL(searchEngineName) fun verifySearchSettings() = assertSearchSettings() fun verifySearchBarEmpty() = assertSearchBarEmpty() fun verifyKeyboardVisibility() = assertKeyboardVisibility(isExpectedToBeVisible = true) - fun verifySearchEngineList() = assertSearchEngineList() + fun verifySearchEngineList(rule: ComposeTestRule) = rule.assertSearchEngineList() fun verifySearchEngineIcon(expectedText: String) { onView(withContentDescription(expectedText)) } fun verifyDefaultSearchEngine(expectedText: String) = assertDefaultSearchEngine(expectedText) - fun verifyEnginesListShortcutContains(searchEngineName: String) = assertEngineListShortcutContains(searchEngineName) + fun verifyEnginesListShortcutContains(rule: ComposeTestRule, searchEngineName: String) = assertEngineListShortcutContains(rule, searchEngineName) - fun changeDefaultSearchEngine(searchEngineName: String) = - selectDefaultSearchEngine(searchEngineName) + fun changeDefaultSearchEngine(rule: ComposeTestRule, searchEngineName: String) = + rule.selectDefaultSearchEngine(searchEngineName) fun clickSearchEngineShortcutButton() { val searchEnginesShortcutButton = mDevice.findObject( @@ -94,40 +103,61 @@ class SearchRobot { } fun typeSearch(searchTerm: String) { - browserToolbarEditView().perform(typeText(searchTerm)) + browserToolbarEditView().setText(searchTerm) + mDevice.waitForIdle() } - fun clickSearchEngineButton(searchEngineName: String) { - searchEngineButton(searchEngineName).perform(click()) + fun clickSearchEngineButton(rule: ComposeTestRule, searchEngineName: String) { + rule.waitForIdle() + + mDevice.waitForObjects( + mDevice.findObject( + UiSelector().textContains(searchEngineName) + ) + ) + + rule.onNodeWithText(searchEngineName) + .assertExists() + .assertHasClickAction() + .performClick() } - fun clickSearchEngineResult(searchEngineName: String) { + fun clickSearchEngineResult(rule: ComposeTestRule, searchEngineName: String) { mDevice.waitNotNull( Until.findObjects(By.text(searchEngineName)), TestAssetHelper.waitingTime ) - awesomeBar().perform( - RecyclerViewActions.actionOnItemAtPosition( - 0, - click() - ) - ) + + rule.onAllNodesWithText(searchEngineName) + .onFirst() + .assertIsDisplayed() + .assertHasClickAction() + .performClick() } - fun scrollToSearchEngineSettings() { + @OptIn(ExperimentalTestApi::class) + fun scrollToSearchEngineSettings(rule: ComposeTestRule) { // Soft keyboard is visible on screen on view access; hide it onView(allOf(withId(R.id.search_wrapper))).perform( closeSoftKeyboard() ) - onView(allOf(withId(R.id.awesome_bar))).perform(ViewActions.swipeUp()) + + mDevice.findObject(UiSelector().text("Google")) + .waitForExists(waitingTime) + + rule.onNodeWithTag("mozac.awesomebar.suggestions") + .performScrollToIndex(5) } - fun clickSearchEngineSettings() { - onView(withText("Search engine settings")).perform(click()) + fun clickSearchEngineSettings(rule: ComposeTestRule) { + rule.onNodeWithText("Search engine settings") + .assertIsDisplayed() + .assertHasClickAction() + .performClick() } fun clickClearButton() { - clearButton().perform(click()) + clearButton().click() } fun longClickToolbar() { @@ -154,14 +184,23 @@ class SearchRobot { fun dismissSearchBar(interact: HomeScreenRobot.() -> Unit): HomeScreenRobot.Transition { mDevice.waitForIdle() + closeSoftKeyboard() mDevice.pressBack() + try { + assertTrue(searchWrapper().waitUntilGone(waitingTimeShort)) + } catch (e: AssertionError) { + mDevice.pressBack() + assertTrue(searchWrapper().waitUntilGone(waitingTimeShort)) + } + HomeScreenRobot().interact() return HomeScreenRobot.Transition() } fun openBrowser(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { mDevice.waitForIdle() - browserToolbarEditView().perform(typeText("mozilla\n")) + browserToolbarEditView().setText("mozilla\n") + mDevice.pressEnter() BrowserRobot().interact() return BrowserRobot.Transition() @@ -170,16 +209,15 @@ class SearchRobot { fun submitQuery(query: String, interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { sessionLoadedIdlingResource = SessionLoadedIdlingResource() mDevice.waitForIdle() - browserToolbarEditView().perform(typeText(query + "\n")) + browserToolbarEditView().setText(query) + mDevice.pressEnter() runWithIdleRes(sessionLoadedIdlingResource) { - onView( - anyOf( - ViewMatchers.withResourceName("browserLayout"), - ViewMatchers.withResourceName("onboarding_message") // Req ETP dialog - ) + assertTrue( + mDevice.findObject( + UiSelector().resourceId("$packageName:id/browserLayout") + ).waitForExists(waitingTime) ) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) } BrowserRobot().interact() @@ -193,15 +231,8 @@ class SearchRobot { } } -private fun awesomeBar() = onView(withId(R.id.awesome_bar)) - private fun browserToolbarEditView() = - onView(Matchers.allOf(withId(R.id.mozac_browser_toolbar_edit_url_view))) - -private fun searchEngineButton(searchEngineName: String): ViewInteraction { - mDevice.waitNotNull(Until.findObject(By.text(searchEngineName)), TestAssetHelper.waitingTime) - return onView(Matchers.allOf(withText(searchEngineName))) -} + mDevice.findObject(UiSelector().resourceId("$packageName:id/mozac_browser_toolbar_edit_url_view")) private fun denyPermissionButton(): UiObject { mDevice.waitNotNull(Until.findObjects(By.text("Deny")), TestAssetHelper.waitingTime) @@ -215,12 +246,13 @@ private fun allowPermissionButton(): UiObject { private fun scanButton(): ViewInteraction { mDevice.waitNotNull(Until.findObject(By.res("org.mozilla.fenix.debug:id/search_scan_button")), TestAssetHelper.waitingTime) - return onView(allOf(withId(R.id.search_scan_button))) + return onView(allOf(withId(R.id.qr_scan_button))) } -private fun clearButton() = onView(withId(R.id.mozac_browser_toolbar_clear_view)) +private fun clearButton() = + mDevice.findObject(UiSelector().resourceId("$packageName:id/mozac_browser_toolbar_clear_view")) -private fun searchWrapper() = onView(withId(R.id.search_wrapper)) +private fun searchWrapper() = mDevice.findObject(UiSelector().resourceId("$packageName:id/search_wrapper")) private fun assertSearchEngineURL(searchEngineName: String) { mDevice.waitNotNull( @@ -231,27 +263,66 @@ private fun assertSearchEngineURL(searchEngineName: String) { .check(matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } -private fun assertSearchEngineResults(searchEngineName: String) { - val count = - mDevice.wait(Until.findObjects(By.text((searchEngineName))), TestAssetHelper.waitingTime) - assert(count.size > 1) +private fun assertSearchEngineResults(rule: ComposeTestRule, searchEngineName: String, count: Int) { + rule.waitForIdle() + + mDevice.waitForObjects( + mDevice.findObject( + UiSelector().textContains(searchEngineName) + ) + ) + + rule.onAllNodesWithText(searchEngineName) + .assertCountEquals(count) +} + +private fun assertSearchEngineSuggestionResults(rule: ComposeTestRule, searchResult: String) { + rule.waitForIdle() + + mDevice.waitForObjects( + mDevice.findObject( + UiSelector().textContains(searchResult) + ) + ) + + rule.onNodeWithText(searchResult) + .assertExists() } -private fun assertSearchView() { - onView(withId(R.id.search_wrapper)).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun assertNoSuggestionsAreDisplayed(rule: ComposeTestRule, searchTerm: String) { + rule.waitForIdle() + + rule.onNodeWithText(searchTerm) + .assertDoesNotExist() } +private fun assertSearchView() = + assertTrue( + mDevice.findObject( + UiSelector().resourceId("$packageName:id/search_wrapper") + ).waitForExists(waitingTime) + ) + private fun assertBrowserToolbarEditView() = - onView(Matchers.allOf(withId(R.id.mozac_browser_toolbar_edit_url_view))) - .check(matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + assertTrue( + mDevice.findObject( + UiSelector().resourceId("$packageName:id/mozac_browser_toolbar_edit_url_view") + ).waitForExists(waitingTime) + ) private fun assertScanButton() = - onView(allOf(withText("Scan"))) - .check(matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + assertTrue( + mDevice.findObject( + UiSelector().resourceId("$packageName:id/qr_scan_button") + ).waitForExists(waitingTime) + ) private fun assertSearchEngineButton() = - onView(withId(R.id.search_engines_shortcut_button)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + assertTrue( + mDevice.findObject( + UiSelector().resourceId("$packageName:id/search_engines_shortcut_button") + ).waitForExists(waitingTime) + ) private fun assertSearchWithText() = onView(allOf(withText("THIS TIME, SEARCH WITH:"))) @@ -261,7 +332,14 @@ private fun assertSearchSettings() = onView(allOf(withText("Default search engine"))) .check(matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) -private fun assertSearchBarEmpty() = browserToolbarEditView().check(matches(withText(""))) +private fun assertSearchBarEmpty() = + assertTrue( + mDevice.findObject( + UiSelector() + .resourceId("$packageName:id/mozac_browser_toolbar_edit_url_view") + .textContains("") + ).waitForExists(waitingTime) + ) fun searchScreen(interact: SearchRobot.() -> Unit): SearchRobot.Transition { SearchRobot().interact() @@ -283,41 +361,67 @@ private fun assertKeyboardVisibility(isExpectedToBeVisible: Boolean) = { ) } -private fun assertSearchEngineList() { +private fun ComposeTestRule.assertSearchEngineList() { onView(withId(R.id.mozac_browser_toolbar_edit_icon)).click() - onView(withText("Google")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - onView(withText("Amazon.com")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - onView(withText("Bing")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - onView(withText("DuckDuckGo")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - onView(withText("Wikipedia")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + + onNodeWithText("Google") + .assertExists() + .assertIsDisplayed() + + onNodeWithText("Amazon.com") + .assertExists() + .assertIsDisplayed() + + onNodeWithText("Bing") + .assertExists() + .assertIsDisplayed() + + onNodeWithText("DuckDuckGo") + .assertExists() + .assertIsDisplayed() + + onNodeWithText("Wikipedia") + .assertExists() + .assertIsDisplayed() } -private fun assertEngineListShortcutContains(searchEngineName: String) { - mDevice.findObject(UiSelector().resourceId("$packageName:id/awesome_bar")) - .waitForExists(waitingTime) +@OptIn(ExperimentalTestApi::class) +private fun assertEngineListShortcutContains(rule: ComposeTestRule, searchEngineName: String) { + rule.waitForIdle() + + mDevice.waitForObjects( + mDevice.findObject( + UiSelector().textContains("Google") + ) + ) + + rule.onNodeWithTag("mozac.awesomebar.suggestions") + .performScrollToIndex(5) - onView(withId(R.id.awesome_bar)) - .perform(swipeDown()) - .check(matches(hasDescendant(withText(searchEngineName)))) + rule.onNodeWithText(searchEngineName) + .assertExists() + .assertIsDisplayed() + .assertHasClickAction() } -private fun selectDefaultSearchEngine(searchEngine: String) { +private fun ComposeTestRule.selectDefaultSearchEngine(searchEngine: String) { onView(withId(R.id.mozac_browser_toolbar_edit_icon)).click() - onView(withText(searchEngine)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - .perform(click()) -} -private fun assertDefaultSearchEngine(expectedText: String) { - onView(allOf(withId(R.id.mozac_browser_toolbar_edit_icon), withContentDescription(expectedText))) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + onNodeWithText(searchEngine) + .assertExists() + .assertIsDisplayed() + .performClick() } +private fun assertDefaultSearchEngine(expectedText: String) = + assertTrue( + mDevice.findObject( + UiSelector() + .resourceId("$packageName:id/mozac_browser_toolbar_edit_icon") + .descriptionContains(expectedText) + ).waitForExists(waitingTime) + ) + private fun assertPastedToolbarText(expectedText: String) { mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar")) .waitForExists(waitingTime) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsRobot.kt index 411795c4af..f232a9561e 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsRobot.kt @@ -6,7 +6,6 @@ package org.mozilla.fenix.ui.robots -import android.content.pm.PackageManager import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.ViewInteraction @@ -34,6 +33,7 @@ import org.mozilla.fenix.R import org.mozilla.fenix.helpers.Constants.PackageName.GOOGLE_PLAY_SERVICES import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.TestHelper.appName +import org.mozilla.fenix.helpers.TestHelper.isPackageInstalled import org.mozilla.fenix.helpers.TestHelper.scrollToElementByText import org.mozilla.fenix.helpers.assertIsEnabled import org.mozilla.fenix.helpers.click @@ -272,6 +272,11 @@ class SettingsRobot { } } +fun settingsScreen(interact: SettingsRobot.() -> Unit): SettingsRobot.Transition { + SettingsRobot().interact() + return SettingsRobot.Transition() +} + private fun assertSettingsView() { // verify that we are in the correct library view assertGeneralHeading() @@ -493,15 +498,6 @@ private fun assertGooglePlayRedirect() { } } -fun isPackageInstalled(packageName: String): Boolean { - return try { - val packageManager = InstrumentationRegistry.getInstrumentation().context.packageManager - packageManager.getApplicationInfo(packageName, 0).enabled - } catch (exception: PackageManager.NameNotFoundException) { - false - } -} - private fun addonsManagerButton() = onView(withText(R.string.preferences_addons)) private fun goBackButton() = diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAboutRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAboutRobot.kt index 0c651c8576..76d57e6f38 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAboutRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuAboutRobot.kt @@ -20,10 +20,18 @@ import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withSubstring import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice +import java.text.SimpleDateFormat +import java.time.LocalDateTime +import java.time.format.DateTimeFormatterBuilder +import java.time.temporal.ChronoField +import java.util.Calendar +import java.util.Date import org.hamcrest.CoreMatchers +import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.R @@ -31,12 +39,6 @@ import org.mozilla.fenix.helpers.TestHelper import org.mozilla.fenix.helpers.TestHelper.appName import org.mozilla.fenix.helpers.isVisibleForUser import org.mozilla.fenix.settings.SupportUtils -import java.text.SimpleDateFormat -import java.time.LocalDateTime -import java.time.format.DateTimeFormatterBuilder -import java.time.temporal.ChronoField -import java.util.Calendar -import java.util.Date /** * Implementation of Robot Pattern for the settings search sub menu. @@ -79,14 +81,24 @@ private fun navigateBackToAboutPage(itemToInteract: () -> Unit) { } private fun verifyListElements() { + assertAboutToolbar() assertWhatIsNewInFirefoxPreview() navigateBackToAboutPage(::assertSupport) + assertCrashes() navigateBackToAboutPage(::assertPrivacyNotice) navigateBackToAboutPage(::assertKnowYourRights) navigateBackToAboutPage(::assertLicensingInformation) navigateBackToAboutPage(::assertLibrariesUsed) } +private fun assertAboutToolbar() = + onView( + allOf( + withId(R.id.navigationToolbar), + hasDescendant(withText("About $appName")) + ) + ).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + private fun assertVersionNumber() { val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -130,16 +142,6 @@ private fun assertWhatIsNewInFirefoxPreview() { onView(withText("What’s new in $appName")) .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) .perform(click()) - - // Commenting out since the Text to verify in the web site seems to be different now - /* - TestHelper.verifyUrl( - SupportUtils.SumoTopic.WHATS_NEW.topicStr, - "org.mozilla.fenix.debug:id/mozac_browser_toolbar_url_view", - R.id.mozac_browser_toolbar_url_view - )*/ - - Espresso.pressBack() } private fun assertSupport() { @@ -156,8 +158,30 @@ private fun assertSupport() { "org.mozilla.fenix.debug:id/mozac_browser_toolbar_url_view", R.id.mozac_browser_toolbar_url_view ) +} - Espresso.pressBack() +private fun assertCrashes() { + + browserScreen { + }.openThreeDotMenu { + }.openSettings { + }.openAboutFirefoxPreview { + } + + if (!onView(withText("Crashes")).isVisibleForUser()) { + onView(withId(R.id.about_layout)).perform(ViewActions.swipeUp()) + } + + onView(withText("Crashes")) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + .perform(click()) + + onView(withSubstring("No crash reports have been submitted")) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + + for (i in 1..3) { + Espresso.pressBack() + } } private fun assertPrivacyNotice() { @@ -174,8 +198,6 @@ private fun assertPrivacyNotice() { "org.mozilla.fenix.debug:id/mozac_browser_toolbar_url_view", R.id.mozac_browser_toolbar_url_view ) - - Espresso.pressBack() } private fun assertKnowYourRights() { @@ -192,8 +214,6 @@ private fun assertKnowYourRights() { "org.mozilla.fenix.debug:id/mozac_browser_toolbar_url_view", R.id.mozac_browser_toolbar_url_view ) - - Espresso.pressBack() } private fun assertLicensingInformation() { @@ -210,8 +230,6 @@ private fun assertLicensingInformation() { "org.mozilla.fenix.debug:id/mozac_browser_toolbar_url_view", R.id.mozac_browser_toolbar_url_view ) - - Espresso.pressBack() } private fun assertLibrariesUsed() { diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDeleteBrowsingDataOnQuitRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDeleteBrowsingDataOnQuitRobot.kt index ffc8060a6c..7030ea2ac4 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDeleteBrowsingDataOnQuitRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDeleteBrowsingDataOnQuitRobot.kt @@ -36,9 +36,9 @@ class SettingsSubMenuDeleteBrowsingDataOnQuitRobot { fun clickDeleteBrowsingOnQuitButtonSwitchDefaultChange() = verifyDeleteBrowsingOnQuitButtonSwitchDefault().click() - fun verifyAllTheCheckBoxesText() = assertAllTheCheckBoxesText() + fun verifyAllTheCheckBoxesText() = assertAllOptionsAndCheckBoxes() - fun verifyAllTheCheckBoxesChecked() = assertAllTheCheckBoxesChecked() + fun verifyAllTheCheckBoxesChecked() = assertAllCheckBoxesAreChecked() fun verifyDeleteBrowsingDataOnQuitSubMenuItems() { verifyDeleteBrowsingOnQuitButton() @@ -88,7 +88,7 @@ private fun assertDeleteBrowsingOnQuitButtonSummary() = onView( private fun assertDeleteBrowsingOnQuitButtonSwitchDefault() = onView(withResourceName("switch_widget")) .check(matches(isChecked(false))) -private fun assertAllTheCheckBoxesText() { +private fun assertAllOptionsAndCheckBoxes() { onView(withText(R.string.preferences_delete_browsing_data_tabs_title_2)) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) @@ -111,6 +111,6 @@ private fun assertAllTheCheckBoxesText() { .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) } -private fun assertAllTheCheckBoxesChecked() { +private fun assertAllCheckBoxesAreChecked() { // Only verifying the options, checkboxes default value can't be verified due to issue #9471 } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDeleteBrowsingDataRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDeleteBrowsingDataRobot.kt index 53a86f37e1..8569cc97c9 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDeleteBrowsingDataRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDeleteBrowsingDataRobot.kt @@ -20,8 +20,10 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import org.hamcrest.CoreMatchers.allOf +import org.junit.Assert.assertTrue import org.mozilla.fenix.R import org.mozilla.fenix.helpers.TestAssetHelper +import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.TestHelper.appName import org.mozilla.fenix.helpers.assertIsChecked import org.mozilla.fenix.helpers.click @@ -32,42 +34,57 @@ import org.mozilla.fenix.helpers.click class SettingsSubMenuDeleteBrowsingDataRobot { fun verifyNavigationToolBarHeader() = assertNavigationToolBarHeader() - fun verifyDeleteBrowsingDataButton() = assertDeleteBrowsingDataButton() - - fun verifyClickDeleteBrowsingDataButton() = assertClickDeleteBrowsingDataButton() - fun verifyMessageInDialogBox() = assertMessageInDialogBox() - fun verifyDeleteButtonInDialogBox() = assertDeleteButtonInDialogBox() - fun verifyCancelButtonInDialogBox() = assertCancelButtonInDialogBox() - - fun verifyAllTheCheckBoxesText() = assertAllTheCheckBoxesText() - - fun verifyAllTheCheckBoxesChecked() = assertAllTheCheckBoxesChecked() - - fun verifyContentsInDialogBox() { + fun verifyAllOptionsAndCheckBoxes() = assertAllOptionsAndCheckBoxes() + fun verifyAllCheckBoxesAreChecked() = assertAllCheckBoxesAreChecked() + fun verifyOpenTabsCheckBox(status: Boolean) = assertOpenTabsCheckBox(status) + fun verifyBrowsingHistoryDetails(status: Boolean) = assertBrowsingHistoryCheckBox(status) + fun verifyCookiesCheckBox(status: Boolean) = assertCookiesCheckBox(status) + fun verifyCachedFilesCheckBox(status: Boolean) = assertCachedFilesCheckBox(status) + fun verifySitePermissionsCheckBox(status: Boolean) = assertSitePermissionsCheckBox(status) + fun verifyDownloadsCheckBox(status: Boolean) = assertDownloadsCheckBox(status) + fun verifyOpenTabsDetails(tabNumber: String) = assertOpenTabsDescription(tabNumber) + fun verifyBrowsingHistoryDetails(addresses: String) = assertBrowsingHistoryDescription(addresses) + + fun verifyDialogElements() { verifyMessageInDialogBox() verifyDeleteButtonInDialogBox() verifyCancelButtonInDialogBox() } + fun switchOpenTabsCheckBox() = clickOpenTabsCheckBox() + fun switchBrowsingHistoryCheckBox() = clickBrowsingHistoryCheckBox() + fun switchCookiesCheckBox() = clickCookiesCheckBox() + fun switchCachedFilesCheckBox() = clickCachedFilesCheckBox() + fun switchSitePermissionsCheckBox() = clickSitePermissionsCheckBox() + fun switchDownloadsCheckBox() = clickDownloadsCheckBox() + fun clickDeleteBrowsingDataButton() = deleteBrowsingDataButton().click() + fun clickDialogCancelButton() = dialogCancelButton().click() + fun selectOnlyOpenTabsCheckBox() = checkOnlyOpenTabsCheckBox() + fun selectOnlyBrowsingHistoryCheckBox() = checkOnlyBrowsingHistoryCheckBox() + fun clickCancelButtonInDialogBoxAndVerifyContentsInDialogBox() { mDevice.wait( Until.findObject(By.text("Delete browsing data")), TestAssetHelper.waitingTime ) - verifyClickDeleteBrowsingDataButton() - verifyContentsInDialogBox() + clickDeleteBrowsingDataButton() + verifyDialogElements() cancelButton().click() } + fun confirmDeletionAndAssertSnackbar() { + dialogDeleteButton().click() + assertDeleteBrowsingDataSnackbar() + } fun verifyDeleteBrowsingDataSubMenuItems() { verifyDeleteBrowsingDataButton() clickCancelButtonInDialogBoxAndVerifyContentsInDialogBox() - verifyAllTheCheckBoxesText() - verifyAllTheCheckBoxesChecked() + verifyAllOptionsAndCheckBoxes() + verifyAllCheckBoxesAreChecked() } class Transition { @@ -85,82 +102,174 @@ class SettingsSubMenuDeleteBrowsingDataRobot { private fun goBackButton() = onView(allOf(withContentDescription("Navigate up"))) -private fun assertNavigationToolBarHeader() { +private fun navigationToolBarHeader() = onView( allOf( withId(R.id.navigationToolbar), withChild(withText(R.string.preferences_delete_browsing_data)) ) ) - .check((matches(withEffectiveVisibility(Visibility.VISIBLE)))) -} -private fun assertDeleteBrowsingDataButton() { - onView(withId(R.id.delete_data)) - .check((matches(withEffectiveVisibility(Visibility.VISIBLE)))) -} +private fun deleteBrowsingDataButton() = onView(withId(R.id.delete_data)) -private fun assertClickDeleteBrowsingDataButton() { - onView(withId(R.id.delete_data)) - .check((matches(withEffectiveVisibility(Visibility.VISIBLE)))).click() -} +private fun assertNavigationToolBarHeader() = + navigationToolBarHeader().check((matches(withEffectiveVisibility(Visibility.VISIBLE)))) + +private fun assertDeleteBrowsingDataButton() = + deleteBrowsingDataButton().check((matches(withEffectiveVisibility(Visibility.VISIBLE)))) private fun cancelButton() = mDevice.findObject(UiSelector().textStartsWith("CANCEL")) -private fun assertMessageInDialogBox() = - onView(withText("$appName will delete the selected browsing data.")) - .inRoot(isDialog()) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun dialogDeleteButton() = onView(withText("Delete")).inRoot(isDialog()) -private fun assertDeleteButtonInDialogBox() = - onView(withText("Delete")) - .inRoot(isDialog()) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun dialogCancelButton() = onView(withText("Cancel")).inRoot(isDialog()) -private fun assertCancelButtonInDialogBox() = - onView(withText("Cancel")) - .inRoot(isDialog()) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun openTabsSubsection() = onView(withText(R.string.preferences_delete_browsing_data_tabs_title_2)) -private fun assertAllTheCheckBoxesText() { +private fun openTabsDescription(tabNumber: String) = onView(withText("$tabNumber tabs")) - onView(withText(R.string.preferences_delete_browsing_data_tabs_title_2)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - - onView(withText("0 tabs")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun openTabsCheckBox() = onView(allOf(withId(R.id.checkbox), hasSibling(withText("Open tabs")))) +private fun browsingHistorySubsection() = onView(withText(R.string.preferences_delete_browsing_data_browsing_data_title)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - onView(withText("0 addresses")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun browsingHistoryDescription(addresses: String) = onView(withText("$addresses addresses")) + +private fun browsingHistoryCheckBox() = + onView(allOf(withId(R.id.checkbox), hasSibling(withText("Browsing history and site data")))) +private fun cookiesSubsection() = onView(withText(R.string.preferences_delete_browsing_data_cookies)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - onView(withText(R.string.preferences_delete_browsing_data_cookies_subtitle)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun cookiesDescription() = onView(withText(R.string.preferences_delete_browsing_data_cookies_subtitle)) + +private fun cookiesCheckBox() = + onView(allOf(withId(R.id.checkbox), hasSibling(withText("Cookies")))) +private fun cachedFilesSubsection() = onView(withText(R.string.preferences_delete_browsing_data_cached_files)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun cachedFilesDescription() = onView(withText(R.string.preferences_delete_browsing_data_cached_files_subtitle)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun cachedFilesCheckBox() = + onView(allOf(withId(R.id.checkbox), hasSibling(withText("Cached images and files")))) + +private fun sitePermissionsSubsection() = onView(withText(R.string.preferences_delete_browsing_data_site_permissions)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + +private fun sitePermissionsCheckBox() = + onView(allOf(withId(R.id.checkbox), hasSibling(withText("Site permissions")))) + +private fun downloadsSubsection() = + onView(withText(R.string.preferences_delete_browsing_data_downloads)) + +private fun downloadsCheckBox() = + onView(allOf(withId(R.id.checkbox), hasSibling(withText("Downloads")))) + +private fun dialogMessage() = + onView(withText("$appName will delete the selected browsing data.")) + .inRoot(isDialog()) + +private fun assertMessageInDialogBox() = + dialogMessage().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + +private fun assertDeleteButtonInDialogBox() = + dialogDeleteButton().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + +private fun assertCancelButtonInDialogBox() = + dialogCancelButton().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + +private fun assertAllOptionsAndCheckBoxes() { + openTabsSubsection().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + openTabsDescription("0").check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + openTabsCheckBox().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + browsingHistorySubsection().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + browsingHistoryDescription("0").check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + browsingHistoryCheckBox().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + cookiesSubsection().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + cookiesDescription().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + cookiesCheckBox().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + cachedFilesSubsection().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + cachedFilesDescription().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + cachedFilesCheckBox().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + sitePermissionsSubsection().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + sitePermissionsCheckBox().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + downloadsSubsection().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + downloadsCheckBox().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +} + +private fun assertAllCheckBoxesAreChecked() { + openTabsCheckBox().assertIsChecked(true) + browsingHistoryCheckBox().assertIsChecked(true) + cookiesCheckBox().assertIsChecked(true) + cachedFilesCheckBox().assertIsChecked(true) + sitePermissionsCheckBox().assertIsChecked(true) + downloadsCheckBox().assertIsChecked(true) +} + +private fun assertOpenTabsDescription(tabNumber: String) = + openTabsDescription(tabNumber).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + +private fun assertBrowsingHistoryDescription(addresses: String) = + browsingHistoryDescription(addresses).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + +private fun assertDeleteBrowsingDataSnackbar() { + assertTrue( + mDevice.findObject( + UiSelector().text("Browsing data deleted") + ).waitUntilGone(waitingTime) + ) } -private fun assertAllTheCheckBoxesChecked() { - onView(allOf(withId(R.id.checkbox), hasSibling(withText("Open tabs")))).assertIsChecked(true) +private fun clickOpenTabsCheckBox() = openTabsCheckBox().click() +private fun assertOpenTabsCheckBox(status: Boolean) = openTabsCheckBox().assertIsChecked(status) +private fun clickBrowsingHistoryCheckBox() = browsingHistoryCheckBox().click() +private fun assertBrowsingHistoryCheckBox(status: Boolean) = browsingHistoryCheckBox().assertIsChecked(status) +private fun clickCookiesCheckBox() = cookiesCheckBox().click() +private fun assertCookiesCheckBox(status: Boolean) = cookiesCheckBox().assertIsChecked(status) +private fun clickCachedFilesCheckBox() = cachedFilesCheckBox().click() +private fun assertCachedFilesCheckBox(status: Boolean) = cachedFilesCheckBox().assertIsChecked(status) +private fun clickSitePermissionsCheckBox() = sitePermissionsCheckBox().click() +private fun assertSitePermissionsCheckBox(status: Boolean) = sitePermissionsCheckBox().assertIsChecked(status) +private fun clickDownloadsCheckBox() = downloadsCheckBox().click() +private fun assertDownloadsCheckBox(status: Boolean) = downloadsCheckBox().assertIsChecked(status) + +fun checkOnlyOpenTabsCheckBox() { + clickBrowsingHistoryCheckBox() + assertBrowsingHistoryCheckBox(false) + + clickCookiesCheckBox() + assertCookiesCheckBox(false) + + clickCachedFilesCheckBox() + assertCachedFilesCheckBox(false) + + clickSitePermissionsCheckBox() + assertSitePermissionsCheckBox(false) + + clickDownloadsCheckBox() + assertDownloadsCheckBox(false) + + assertOpenTabsCheckBox(true) +} + +fun checkOnlyBrowsingHistoryCheckBox() { + clickOpenTabsCheckBox() + assertOpenTabsCheckBox(false) + + clickCookiesCheckBox() + assertCookiesCheckBox(false) - onView(allOf(withId(R.id.checkbox), hasSibling(withText("Browsing history and site data")))).assertIsChecked(true) + clickCachedFilesCheckBox() + assertCachedFilesCheckBox(false) - onView(allOf(withId(R.id.checkbox), hasSibling(withText("Cookies")))).assertIsChecked(true) + clickSitePermissionsCheckBox() + assertSitePermissionsCheckBox(false) - onView(allOf(withId(R.id.checkbox), hasSibling(withText("Cached images and files")))).assertIsChecked(true) + clickDownloadsCheckBox() + assertDownloadsCheckBox(false) - onView(allOf(withId(R.id.checkbox), hasSibling(withText("Site permissions")))).assertIsChecked(true) + assertBrowsingHistoryCheckBox(true) } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuEnhancedTrackingProtectionRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuEnhancedTrackingProtectionRobot.kt index c60f8e965f..3915461b31 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuEnhancedTrackingProtectionRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuEnhancedTrackingProtectionRobot.kt @@ -50,8 +50,6 @@ class SettingsSubMenuEnhancedTrackingProtectionRobot { fun verifyEnhancedTrackingProtectionOptions() = assertEnhancedTrackingProtectionOptions() - fun verifyEnhancedTrackingProtectionOptionsGrayedOut() = assertEnhancedTrackingProtectionOptionsGrayedOut() - fun verifyTrackingProtectionSwitchEnabled() = assertTrackingProtectionSwitchEnabled() fun switchEnhancedTrackingProtectionToggle() = onView(withResourceName("switch_widget")).click() diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuPrivateBrowsingRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuPrivateBrowsingRobot.kt index 4a5a23438b..943b568b19 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuPrivateBrowsingRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuPrivateBrowsingRobot.kt @@ -51,6 +51,18 @@ class SettingsSubMenuPrivateBrowsingRobot { fun clickOpenLinksInPrivateTabSwitch() = openLinksInPrivateTabSwitch().click() + fun cancelPrivateShortcutAddition() { + mDevice.wait( + Until.findObject(text("Add private browsing shortcut")), + waitingTime + ) + addPrivateBrowsingShortcutButton().click() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + mDevice.wait(Until.findObject(By.textContains("CANCEL")), waitingTime) + cancelShortcutAdditionButton().click() + } + } + fun addPrivateShortcutToHomescreen() { mDevice.wait( Until.findObject(text("Add private browsing shortcut")), @@ -105,6 +117,9 @@ private fun goBackButton() = onView(withContentDescription("Navigate up")) private fun addAutomaticallyButton() = mDevice.findObject(UiSelector().textStartsWith("add automatically")) +private fun cancelShortcutAdditionButton() = + mDevice.findObject(UiSelector().textContains("CANCEL")) + private fun privateBrowsingShortcutIcon() = mDevice.findObject(text("Private $appName")) private fun assertAddPrivateBrowsingShortcutButton() { diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuSearchRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuSearchRobot.kt index 435ce4a8f5..731aa75df5 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuSearchRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuSearchRobot.kt @@ -8,23 +8,28 @@ package org.mozilla.fenix.ui.robots import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.clearText import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.Visibility +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withChild import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withParent import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import org.hamcrest.CoreMatchers +import org.hamcrest.Matchers.allOf +import org.junit.Assert.assertTrue import org.mozilla.fenix.R import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime -import org.mozilla.fenix.helpers.TestHelper.packageName import org.mozilla.fenix.helpers.click /** @@ -53,9 +58,11 @@ class SettingsSubMenuSearchRobot { fun saveNewSearchEngine() { addSearchEngineSaveButton().click() - mDevice.findObject( - UiSelector().resourceId("$packageName:id/recycler_view") - ).waitForExists(waitingTime) + assertTrue( + mDevice.findObject( + UiSelector().textContains("Default search engine") + ).waitForExists(waitingTime) + ) } fun addNewSearchEngine(searchEngineName: String) { @@ -63,6 +70,35 @@ class SettingsSubMenuSearchRobot { saveNewSearchEngine() } + fun selectAddCustomSearchEngine() = onView(withText("Other")).click() + + fun typeCustomEngineDetails(engineName: String, engineURL: String) { + onView(withId(R.id.edit_engine_name)) + .perform(clearText()) + .perform(typeText(engineName)) + onView(withId(R.id.edit_search_string)) + .perform(clearText()) + .perform(typeText(engineURL)) + } + + fun openEngineOverflowMenu(searchEngineName: String) { + mDevice.findObject( + UiSelector().resourceId("org.mozilla.fenix.debug:id/overflow_menu") + ).waitForExists(waitingTime) + threeDotMenu(searchEngineName).click() + } + + fun clickEdit() = onView(withText("Edit")).click() + + fun saveEditSearchEngine() { + onView(withId(R.id.save_button)).click() + assertTrue( + mDevice.findObject( + UiSelector().textContains("Saved") + ).waitForExists(waitingTime) + ) + } + class Transition { val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @@ -78,21 +114,21 @@ class SettingsSubMenuSearchRobot { private fun assertDefaultSearchEngineHeader() = onView(withText("Default search engine")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) private fun assertSearchEngineList() { onView(withText("Google")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) onView(withText("Amazon.com")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) onView(withText("Bing")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) onView(withText("DuckDuckGo")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) onView(withText("Wikipedia")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) onView(withText("Add search engine")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } private fun assertShowSearchSuggestions() { @@ -102,7 +138,7 @@ private fun assertShowSearchSuggestions() { ) ) onView(withText("Show search suggestions")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } private fun assertShowSearchShortcuts() { @@ -112,7 +148,7 @@ private fun assertShowSearchShortcuts() { ) ) onView(withText("Show search engines")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } private fun assertShowClipboardSuggestions() { @@ -122,7 +158,7 @@ private fun assertShowClipboardSuggestions() { ) ) onView(withText("Show clipboard suggestions")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } private fun assertSearchBrowsingHistory() { @@ -132,7 +168,7 @@ private fun assertSearchBrowsingHistory() { ) ) onView(withText("Search browsing history")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } private fun assertSearchBookmarks() { @@ -142,12 +178,12 @@ private fun assertSearchBookmarks() { ) ) onView(withText("Search bookmarks")) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } private fun selectSearchEngine(searchEngine: String) { onView(withText(searchEngine)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) .perform(click()) } @@ -189,3 +225,11 @@ private fun addSearchEngineSaveButton() = onView(withId(R.id.add_search_engine)) private fun assertEngineListContains(searchEngineName: String) { onView(withId(R.id.search_engine_group)).check(matches(hasDescendant(withText(searchEngineName)))) } + +private fun threeDotMenu(searchEngineName: String) = + onView( + allOf( + withId(R.id.overflow_menu), + withParent(withChild(withText(searchEngineName))) + ) + ) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuTabsRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuTabsRobot.kt index 3cf81bf25b..fc1c336568 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuTabsRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuTabsRobot.kt @@ -16,6 +16,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import org.hamcrest.CoreMatchers.allOf +import org.mozilla.fenix.helpers.TestHelper.scrollToElementByText import org.mozilla.fenix.helpers.click /** @@ -23,11 +24,16 @@ import org.mozilla.fenix.helpers.click */ class SettingsSubMenuTabsRobot { - fun verifyOptions() = assertOptions() + fun verifyTabViewOptions() = assertTabViewOptions() + + fun verifyCloseTabsOptions() = assertCloseTabsOptions() fun verifyStartOnHomeOptions() = assertStartOnHomeOptions() - fun clickAlwaysStartOnHomeToggle() = alwaysStartOnHomeToggle().click() + fun clickAlwaysStartOnHomeToggle() { + scrollToElementByText("Move old tabs to inactive") + alwaysStartOnHomeToggle().click() + } class Transition { val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @@ -42,11 +48,22 @@ class SettingsSubMenuTabsRobot { } } -private fun assertOptions() { +private fun assertTabViewOptions() { + tabViewHeading() + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + listToggle() + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + gridToggle() + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + searchTermTabGroupsToggle() + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) +} + +private fun assertCloseTabsOptions() { + closeTabsHeading() + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) afterOneDayToggle() .check(ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) - manualToggle() - .check(ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) afterOneWeekToggle() .check(ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) afterOneMonthToggle() @@ -54,17 +71,27 @@ private fun assertOptions() { } private fun assertStartOnHomeOptions() { + // Scroll to ensure all the items are visible. + scrollToElementByText("Never") startOnHomeHeading() .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) afterFourHoursToggle() .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) alwaysStartOnHomeToggle() .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) - neverStartOnHomeToggle() - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) } -private fun manualToggle() = onView(withText("Manually")) +private fun tabViewHeading() = onView(withText("Tab view")) + +private fun listToggle() = onView(withText("List")) + +private fun gridToggle() = onView(withText("Grid")) + +private fun searchTermTabGroupsToggle() = onView(withText("Search groups")) + +private fun closeTabsHeading() = onView(withText("Close tabs")) + +private fun manuallyToggle() = onView(withText("Manually")) private fun afterOneDayToggle() = onView(withText("After one day")) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SyncSignInRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SyncSignInRobot.kt index ac7132ea77..e00503bcba 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SyncSignInRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SyncSignInRobot.kt @@ -6,14 +6,18 @@ package org.mozilla.fenix.ui.robots import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector import org.hamcrest.CoreMatchers.allOf +import org.junit.Assert.assertTrue import org.mozilla.fenix.R +import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime +import org.mozilla.fenix.helpers.TestHelper.packageName import org.mozilla.fenix.helpers.click /** @@ -22,7 +26,7 @@ import org.mozilla.fenix.helpers.click class SyncSignInRobot { fun verifyAccountSettingsMenuHeader() = assertAccountSettingsMenuHeader() - fun verifySyncSignInMenuHeader() = assertSyncSignInMenuHeader() + fun verifyTurnOnSyncMenu() = assertTurnOnSyncMenu() class Transition { val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())!! @@ -46,7 +50,13 @@ private fun assertAccountSettingsMenuHeader() { .check((matches(withEffectiveVisibility(Visibility.VISIBLE)))) } -private fun assertSyncSignInMenuHeader() { - onView(withText(R.string.sign_in_with_camera)) - .check((matches(withEffectiveVisibility(Visibility.VISIBLE)))) +private fun assertTurnOnSyncMenu() { + mDevice.findObject(UiSelector().resourceId("$packageName:id/container")).waitForExists(waitingTime) + assertTrue( + mDevice.findObject( + UiSelector() + .resourceId("$packageName:id/signInScanButton") + .resourceId("$packageName:id/signInEmailButton") + ).waitForExists(waitingTime) + ) } diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/TabDrawerRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/TabDrawerRobot.kt index d74a23236a..8dd4431afa 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/TabDrawerRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/TabDrawerRobot.kt @@ -148,16 +148,18 @@ class TabDrawerRobot { } fun verifyTabMediaControlButtonState(action: String) { - mDevice.waitNotNull( - findObject( - By - .res("$packageName:id/play_pause_button") - .desc(action) - ), - waitingTime - ) + mDevice.waitForIdle() - tabMediaControlButton().check(matches(withContentDescription(action))) + mDevice.findObject( + UiSelector() + .resourceId("$packageName:id/play_pause_button") + ).waitForExists(waitingTime) + + assertTrue( + mDevice.findObject( + UiSelector().descriptionContains(action) + ).waitForExists(waitingTime) + ) } fun clickTabMediaControlButton() = tabMediaControlButton().click() @@ -252,7 +254,7 @@ class TabDrawerRobot { fun openNewTab(interact: SearchRobot.() -> Unit): SearchRobot.Transition { mDevice.waitForIdle() - newTabButton().perform(click()) + newTabButton().click() SearchRobot().interact() return SearchRobot.Transition() } @@ -392,7 +394,7 @@ private fun normalBrowsingButton() = onView( ) private fun privateBrowsingButton() = onView(withContentDescription("Private tabs")) -private fun newTabButton() = onView(withId(R.id.new_tab_button)) +private fun newTabButton() = mDevice.findObject(UiSelector().resourceId("$packageName:id/new_tab_button")) private fun threeDotMenu() = onView(withId(R.id.tab_tray_overflow)) private fun assertExistingOpenTabs(title: String) { diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/ThreeDotMenuMainRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/ThreeDotMenuMainRobot.kt index 05991877d9..ec2e70dfb5 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/ThreeDotMenuMainRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/ThreeDotMenuMainRobot.kt @@ -16,6 +16,7 @@ import androidx.test.espresso.matcher.RootMatchers import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility @@ -28,6 +29,7 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.not import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.mozilla.fenix.R @@ -66,11 +68,6 @@ class ThreeDotMenuMainRobot { } fun clickShareButton() { - var maxSwipes = 3 - while (!shareButton().exists() && maxSwipes != 0) { - threeDotMenuRecyclerView().perform(swipeUp()) - maxSwipes-- - } shareButton().click() mDevice.waitNotNull(Until.findObject(By.text("ALL ACTIONS")), waitingTime) } @@ -93,6 +90,13 @@ class ThreeDotMenuMainRobot { fun verifyNewTabButton() = assertNewTabButton() fun verifyReportSiteIssueButton() = assertReportSiteIssueButton() + fun verifyDesktopSiteModeEnabled(state: Boolean) { + expandMenu() + if (state) { + desktopSiteButton().check(matches(isChecked())) + } else desktopSiteButton().check(matches(not(isChecked()))) + } + fun verifyPageThreeDotMainMenuItems() { verifyNewTabButton() verifyBookmarksButton() @@ -139,7 +143,7 @@ class ThreeDotMenuMainRobot { } fun openDownloadsManager(interact: DownloadRobot.() -> Unit): DownloadRobot.Transition { - onView(withId(R.id.mozac_browser_menu_recyclerView)).perform(swipeDown()) + threeDotMenuRecyclerView().perform(swipeDown()) downloadsButton().click() DownloadRobot().interact() @@ -147,7 +151,7 @@ class ThreeDotMenuMainRobot { } fun openSyncSignIn(interact: SyncSignInRobot.() -> Unit): SyncSignInRobot.Transition { - onView(withId(R.id.mozac_browser_menu_recyclerView)).perform(swipeDown()) + threeDotMenuRecyclerView().perform(swipeDown()) mDevice.waitNotNull(Until.findObject(By.text("Sign in to sync")), waitingTime) signInToSyncButton().click() @@ -156,7 +160,7 @@ class ThreeDotMenuMainRobot { } fun openBookmarks(interact: BookmarksRobot.() -> Unit): BookmarksRobot.Transition { - onView(withId(R.id.mozac_browser_menu_recyclerView)).perform(swipeDown()) + threeDotMenuRecyclerView().perform(swipeDown()) mDevice.waitNotNull(Until.findObject(By.text("Bookmarks")), waitingTime) bookmarksButton().click() @@ -167,7 +171,7 @@ class ThreeDotMenuMainRobot { } fun openHistory(interact: HistoryRobot.() -> Unit): HistoryRobot.Transition { - onView(withId(R.id.mozac_browser_menu_recyclerView)).perform(swipeDown()) + threeDotMenuRecyclerView().perform(swipeDown()) mDevice.waitNotNull(Until.findObject(By.text("History")), waitingTime) historyButton().click() @@ -185,6 +189,7 @@ class ThreeDotMenuMainRobot { fun sharePage(interact: LibrarySubMenusMultipleSelectionToolbarRobot.() -> Unit): LibrarySubMenusMultipleSelectionToolbarRobot.Transition { shareButton().click() + LibrarySubMenusMultipleSelectionToolbarRobot().interact() return LibrarySubMenusMultipleSelectionToolbarRobot.Transition() } @@ -205,10 +210,7 @@ class ThreeDotMenuMainRobot { } fun goBack(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { - // Close three dot - mDevice.pressBack() - // Nav back to previous page - mDevice.pressBack() + backButton().click() BrowserRobot().interact() return BrowserRobot.Transition() @@ -238,14 +240,6 @@ class ThreeDotMenuMainRobot { return BrowserRobot.Transition() } - fun stopPageLoad(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { - mDevice.waitNotNull(Until.findObject(By.desc("Stop")), waitingTime) - stopLoadingButton().click() - - BrowserRobot().interact() - return BrowserRobot.Transition() - } - fun closeAllTabs(interact: HomeScreenRobot.() -> Unit): HomeScreenRobot.Transition { closeAllTabsButton().click() @@ -254,6 +248,8 @@ class ThreeDotMenuMainRobot { } fun openReportSiteIssue(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { + threeDotMenuRecyclerView().perform(swipeUp()) + threeDotMenuRecyclerView().perform(swipeUp()) reportSiteIssueButton().click() BrowserRobot().interact() @@ -261,7 +257,8 @@ class ThreeDotMenuMainRobot { } fun openFindInPage(interact: FindInPageRobot.() -> Unit): FindInPageRobot.Transition { - onView(withId(R.id.mozac_browser_menu_recyclerView)).perform(swipeDown()) + threeDotMenuRecyclerView().perform(swipeUp()) + threeDotMenuRecyclerView().perform(swipeUp()) mDevice.waitNotNull(Until.findObject(By.text("Find in page")), waitingTime) findInPageButton().click() @@ -278,11 +275,8 @@ class ThreeDotMenuMainRobot { } fun openReaderViewAppearance(interact: ReaderViewRobot.() -> Unit): ReaderViewRobot.Transition { - var maxSwipes = 3 - while (!readerViewAppearanceToggle().exists() && maxSwipes != 0) { - threeDotMenuRecyclerView().perform(swipeUp()) - maxSwipes-- - } + threeDotMenuRecyclerView().perform(swipeUp()) + threeDotMenuRecyclerView().perform(swipeUp()) readerViewAppearanceToggle().click() ReaderViewRobot().interact() @@ -305,11 +299,8 @@ class ThreeDotMenuMainRobot { } fun clickInstall(interact: AddToHomeScreenRobot.() -> Unit): AddToHomeScreenRobot.Transition { - var maxSwipes = 3 - while (!installPWAButton().exists() && maxSwipes != 0) { - threeDotMenuRecyclerView().perform(swipeUp()) - maxSwipes-- - } + threeDotMenuRecyclerView().perform(swipeUp()) + threeDotMenuRecyclerView().perform(swipeUp()) installPWAButton().click() AddToHomeScreenRobot().interact() @@ -318,9 +309,8 @@ class ThreeDotMenuMainRobot { fun openSaveToCollection(interact: CollectionRobot.() -> Unit): CollectionRobot.Transition { // Ensure the menu is expanded and fully scrolled to the bottom. - for (i in 0..3) { - threeDotMenuRecyclerView().perform(swipeUp()) - } + threeDotMenuRecyclerView().perform(swipeUp()) + threeDotMenuRecyclerView().perform(swipeUp()) mDevice.waitNotNull(Until.findObject(By.text("Save to collection")), waitingTime) saveCollectionButton().click() @@ -337,6 +327,24 @@ class ThreeDotMenuMainRobot { SettingsSubMenuAddonsManagerRobot().interact() return SettingsSubMenuAddonsManagerRobot.Transition() } + + fun clickOpenInApp(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { + threeDotMenuRecyclerView().perform(swipeUp()) + threeDotMenuRecyclerView().perform(swipeUp()) + openInAppButton().click() + + BrowserRobot().interact() + return BrowserRobot.Transition() + } + + fun switchDesktopSiteMode(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { + threeDotMenuRecyclerView().perform(swipeUp()) + threeDotMenuRecyclerView().perform(swipeUp()) + desktopSiteButton().click() + + BrowserRobot().interact() + return BrowserRobot.Transition() + } } } private fun threeDotMenuRecyclerView() = @@ -373,6 +381,8 @@ private fun assertHelpButton() = helpButton() private fun forwardButton() = mDevice.findObject(UiSelector().description("Forward")) private fun assertForwardButton() = assertTrue(forwardButton().waitForExists(waitingTime)) +private fun backButton() = mDevice.findObject(UiSelector().description("Back")) + private fun addBookmarkButton() = onView(allOf(withId(R.id.checkbox), withText("Add"))) private fun assertAddBookmarkButton() { onView(withId(R.id.mozac_browser_menu_menuView)).perform(swipeUp()) @@ -488,13 +498,20 @@ private fun assertAddToMobileHome() { private fun installPWAButton() = mDevice.findObject(UiSelector().text("Install")) -private fun desktopSiteButton() = - onView(allOf(withText(R.string.browser_menu_desktop_site))) +private fun desktopSiteButton() = onView(withId(R.id.switch_widget)) private fun assertDesktopSite() { - onView(withId(R.id.mozac_browser_menu_menuView)).perform(swipeUp()) + threeDotMenuRecyclerView().perform(swipeUp()) desktopSiteButton().check(matches(isDisplayed())) } +private fun openInAppButton() = + onView( + allOf( + withText("Open in app"), + withEffectiveVisibility(Visibility.VISIBLE) + ) + ) + private fun downloadsButton() = onView(withText(R.string.library_downloads)) private fun assertDownloadsButton() { onView(withId(R.id.mozac_browser_menu_menuView)).perform(swipeDown()) diff --git a/app/src/beta/AndroidManifest.xml b/app/src/beta/AndroidManifest.xml deleted file mode 100644 index f98b6fa2f3..0000000000 --- a/app/src/beta/AndroidManifest.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index e462889342..2669e9ce92 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -19,28 +19,6 @@ - - - - - - - - - - - - - diff --git a/app/src/debug/res/raw/initial_experiments.json b/app/src/debug/res/raw/initial_experiments.json index a255084cee..6bced9269e 100644 --- a/app/src/debug/res/raw/initial_experiments.json +++ b/app/src/debug/res/raw/initial_experiments.json @@ -4,163 +4,56 @@ "appId": "org.mozilla.fenix", "appName": "fenix", "channel": "nightly", - "branches": [{ - "slug": "control", - "ratio": 100, - "feature": { - "value": {}, - "enabled": true, - "featureId": "nimbus-validation" - } - }, - { - "slug": "fancy-settings", - "ratio": 0, - "feature": { - "value": { - "settings-title": "Fancy Settings" - }, - "enabled": true, - "featureId": "nimbus-validation" - } - }, + "branches": [ { - "slug": "smiley", + "slug": "no-mr2", "ratio": 0, "feature": { "value": { - "settings-title-punctuation": "\uD83D\uDE03" + "sections-enabled": { + "topSites": true, + "recentExplorations": true, + "recentlySaved": false, + "jumpBackIn": false, + "pocket": false + } }, "enabled": true, - "featureId": "nimbus-validation" + "featureId": "homescreen" } }, { - "slug": "bundled-text", - "ratio": 0, - "feature": { - "value": { - "settings-title": "preferences_category_general" - }, - "enabled": true, - "featureId": "nimbus-validation" - } - } - ], - "outcomes": [], - "arguments": {}, - "probeSets": [], - "startDate": null, - "targeting": "true", - "featureIds": [ - "nimbus-validation" - ], - "application": "org.mozilla.firefox_beta", - "bucketConfig": { - "count": 0, - "start": 0, - "total": 10000, - "namespace": "nimbus-validation-2", - "randomizationUnit": "nimbus_id" - }, - "schemaVersion": "1.5.0", - "userFacingName": "Nimbus Text Variables Validation", - "referenceBranch": "control", - "proposedDuration": 14, - "isEnrollmentPaused": false, - "proposedEnrollment": 7, - "userFacingDescription": "Demonstration experiment to make trivial visible changes to text in Settings", - "last_modified": 1621443780172 - }, - { - "slug": "feature-icon-variables-validation-android", - "appId": "org.mozilla.fenix", - "appName": "fenix", - "channel": "nightly", - "branches": [{ - "slug": "control", + "slug": "full-mr2", "ratio": 100, - "feature": { - "value": {}, - "enabled": true, - "featureId": "nimbus-validation" - } - }, - { - "slug": "edit-menu-icon", - "ratio": 0, "feature": { "value": { - "settings-title": "preferences_category_general", - "settings-icon": "ic_edit" + "sections-enabled": { + "topSites": true, + "recentExplorations": true, + "recentlySaved": true, + "jumpBackIn": true, + "pocket": true + } }, "enabled": true, - "featureId": "nimbus-validation" - } - } - ], - "outcomes": [], - "arguments": {}, - "probeSets": [], - "startDate": null, - "targeting": "true", - "featureIds": [ - "nimbus-validation" - ], - "application": "org.mozilla.firefox_beta", - "bucketConfig": { - "count": 0, - "start": 0, - "total": 10000, - "namespace": "nimbus-validation-2", - "randomizationUnit": "nimbus_id" - }, - "schemaVersion": "1.5.0", - "userFacingName": "Nimbus Icon Variables Validation", - "referenceBranch": "control", - "proposedDuration": 14, - "isEnrollmentPaused": false, - "proposedEnrollment": 7, - "userFacingDescription": "Demonstration experiment to make trivial visible changes to icons in Settings", - "last_modified": 1621443780172 - }, - - { - "slug": "feature-text-variables-validation-ios", - "appId": "org.mozilla.ios.Fennec", - "appName": "firefox_ios", - "channel": "nightly", - "branches": [{ - "slug": "control", - "ratio": 100, - "feature": { - "value": {}, - "enabled": true, - "featureId": "nimbus-validation" + "featureId": "homescreen" } }, { - "slug": "a1", + "slug": "distraction-free", "ratio": 0, "feature": { "value": { - "settings-title": "Menu/Menu.OpenSettingsAction.Title", - "settings-title-punctuation": "…" + "sections-enabled": { + "topSites": true, + "recentExplorations": false, + "recentlySaved": false, + "jumpBackIn": false, + "pocket": false + } }, "enabled": true, - "featureId": "nimbus-validation" - } - }, - { - "slug": "a2", - "ratio": 0, - "feature": { - "value": { - "settings-title": "Settings.General.SectionName", - "settings-title-punctuation": "!" - }, - "enabled": true, - "featureId": "nimbus-validation" + "featureId": "homescreen" } } ], @@ -170,61 +63,9 @@ "startDate": null, "targeting": "true", "featureIds": [ - "nimbus-validation" + "homescreen" ], - "application": "org.mozilla.ios.Fennec", - "bucketConfig": { - "count": 0, - "start": 0, - "total": 10000, - "namespace": "nimbus-validation-2", - "randomizationUnit": "nimbus_id" - }, - "schemaVersion": "1.5.0", - "userFacingName": "Nimbus Text Variables Validation", - "referenceBranch": "control", - "proposedDuration": 14, - "isEnrollmentPaused": false, - "proposedEnrollment": 7, - "userFacingDescription": "Demonstration experiment to make trivial visible changes to text in Settings", - "last_modified": 1621443780172 - }, - { - "slug": "feature-icon-variables-validation-ios", - "appId": "org.mozilla.ios.Fennec", - "appName": "firefox_ios", - "channel": "nightly", - "branches": [{ - "slug": "control", - "ratio": 100, - "feature": { - "value": {}, - "enabled": true, - "featureId": "nimbus-validation" - } - }, - { - "slug": "treatment", - "ratio": 0, - "feature": { - "value": { - "settings-title": "Fancy Settings", - "settings-icon": "menu-ViewMobile" - }, - "enabled": true, - "featureId": "nimbus-validation" - } - } - ], - "outcomes": [], - "arguments": {}, - "probeSets": [], - "startDate": null, - "targeting": "true", - "featureIds": [ - "nimbus-validation" - ], - "application": "org.mozilla.ios.Fennec", + "application": "org.mozilla.firefox_beta", "bucketConfig": { "count": 0, "start": 0, @@ -233,12 +74,12 @@ "randomizationUnit": "nimbus_id" }, "schemaVersion": "1.5.0", - "userFacingName": "Nimbus Icon Variables Validation", + "userFacingName": "Home screen sections test", "referenceBranch": "control", "proposedDuration": 14, "isEnrollmentPaused": false, "proposedEnrollment": 7, - "userFacingDescription": "Demonstration experiment to make trivial visible changes to icons in Settings", + "userFacingDescription": "Experiment to test the home screen configurations", "last_modified": 1621443780172 } ] diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a91e307b29..d8e51eadac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -246,6 +246,27 @@ + + + + + + + + + + + + diff --git a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt index 113001daaa..bad10a707b 100644 --- a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt +++ b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt @@ -20,14 +20,17 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) { FromSettings(R.id.settingsFragment), FromBookmarks(R.id.bookmarkFragment), FromHistory(R.id.historyFragment), + FromHistoryMetadataGroup(R.id.historyMetadataGroupFragment), FromTrackingProtectionExceptions(R.id.trackingProtectionExceptionsFragment), FromAbout(R.id.aboutFragment), FromTrackingProtection(R.id.trackingProtectionFragment), + FromTrackingProtectionDialog(R.id.trackingProtectionPanelDialogFragment), FromSavedLoginsFragment(R.id.savedLoginsFragment), FromAddNewDeviceFragment(R.id.addNewDeviceFragment), FromAddSearchEngineFragment(R.id.addSearchEngineFragment), FromEditCustomSearchEngineFragment(R.id.editCustomSearchEngineFragment), FromAddonDetailsFragment(R.id.addonDetailsFragment), + FromStudiesFragment(R.id.studiesFragment), FromAddonPermissionsDetailsFragment(R.id.addonPermissionsDetailFragment), FromLoginDetailFragment(R.id.loginDetailFragment), FromTabsTray(R.id.tabsTrayFragment), diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index fa09056458..9b3bfea8e8 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -4,6 +4,10 @@ package org.mozilla.fenix +import android.content.Context +import mozilla.components.support.locale.LocaleManager +import mozilla.components.support.locale.LocaleManager.getSystemDefault + /** * A single source for setting feature flags that are mostly based on build type. */ @@ -19,11 +23,6 @@ object FeatureFlags { */ const val addressesFeature = true - /** - * Enables the Credit Cards autofill feature. - */ - const val creditCardsFeature = true - /** * Enables WebAuthn support. */ @@ -32,43 +31,64 @@ object FeatureFlags { /** * Enables the Home button in the browser toolbar to navigate back to the home screen. */ - val showHomeButtonFeature = Config.channel.isNightlyOrDebug + const val showHomeButtonFeature = true /** * Enables the Start On Home feature in the settings page. */ - val showStartOnHomeSettings = Config.channel.isNightlyOrDebug + const val showStartOnHomeSettings = true /** * Enables the "recent" tabs feature in the home screen. */ - val showRecentTabsFeature = Config.channel.isNightlyOrDebug + const val showRecentTabsFeature = true /** - * Enables recording of history metadata. + * Enables UI features based on history metadata. */ - val historyMetadataFeature = Config.channel.isDebug + const val historyMetadataUIFeature = true /** * Enables the recently saved bookmarks feature in the home screen. */ - val recentBookmarksFeature = Config.channel.isNightlyOrDebug + const val recentBookmarksFeature = true /** * Identifies and separates the tabs list with a secondary section containing least used tabs. */ - val inactiveTabs = Config.channel.isNightlyOrDebug + const val inactiveTabs = true /** - * Enables support for Android Autofill. - * - * In addition to toggling this flag, matching entries in the Android Manifest of the build - * type need to present. + * Enables showing the home screen behind the search dialog */ - val androidAutofill = Config.channel.isNightlyOrDebug || Config.channel.isBeta + const val showHomeBehindSearch = true /** - * Enables showing the home screen behind the search dialog + * Enables customizing the home screen + */ + const val customizeHome = true + + /** + * Identifies and separates the tabs list with a group containing search term tabs. + */ + val tabGroupFeature = Config.channel.isNightlyOrDebug + + /** + * Enables showing search groupings in the History. + */ + const val showHistorySearchGroups = true + + /** + * Show Pocket recommended stories on home. + */ + fun isPocketRecommendationsFeatureEnabled(context: Context): Boolean { + val langTag = LocaleManager.getCurrentLocale(context) + ?.toLanguageTag() ?: getSystemDefault().toLanguageTag() + return listOf("en-US", "en-CA").contains(langTag) + } + + /** + * Enables showing the homescreen onboarding card. */ - val showHomeBehindSearch = Config.channel.isNightlyOrDebug + const val showHomeOnboarding = false } diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt index d8133a270f..4441058d0a 100644 --- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt +++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -51,7 +51,6 @@ import org.mozilla.fenix.GleanMetrics.PerfStartup import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.metrics.MetricServiceType import org.mozilla.fenix.components.metrics.SecurePrefsTelemetry -import org.mozilla.fenix.ext.measureNoInline import org.mozilla.fenix.ext.settings import org.mozilla.fenix.perf.ProfilerMarkerFactProcessor import org.mozilla.fenix.perf.StartupTimeline @@ -65,17 +64,24 @@ import org.mozilla.fenix.telemetry.TelemetryLifecycleObserver import org.mozilla.fenix.utils.BrowsersCache import java.util.concurrent.TimeUnit import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.storage.FrecencyThresholdOption import mozilla.components.feature.autofill.AutofillUseCases import mozilla.components.feature.search.ext.buildSearchUrl import mozilla.components.feature.search.ext.waitForSelectedOrDefaultSearchEngine import mozilla.components.service.fxa.manager.SyncEnginesStorage +import org.mozilla.experiments.nimbus.NimbusInterface +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment import org.mozilla.fenix.GleanMetrics.Addons import org.mozilla.fenix.GleanMetrics.AndroidAutofill +import org.mozilla.fenix.GleanMetrics.CustomizeHome import org.mozilla.fenix.GleanMetrics.Preferences import org.mozilla.fenix.GleanMetrics.SearchDefaultEngine +import org.mozilla.fenix.components.Core import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MozillaProductDetector import org.mozilla.fenix.components.toolbar.ToolbarPosition +import org.mozilla.fenix.perf.MarkersLifecycleCallbacks +import org.mozilla.fenix.tabstray.ext.inactiveTabs import org.mozilla.fenix.utils.Settings /** @@ -98,7 +104,6 @@ open class FenixApplication : LocaleAwareApplication(), Provider { override fun onCreate() { // We use start/stop instead of measure so we don't measure outside the main process. val completeMethodDurationTimerId = PerfStartup.applicationOnCreate.start() // DO NOT MOVE ANYTHING ABOVE HERE. - val subsectionThroughGleanTimerId = PerfStartup.appOnCreateToGleanInit.start() super.onCreate() @@ -120,8 +125,6 @@ open class FenixApplication : LocaleAwareApplication(), Provider { initializeGlean() } - PerfStartup.appOnCreateToGleanInit.stopAndAccumulate(subsectionThroughGleanTimerId) - setupInMainProcessOnly() // DO NOT MOVE ANYTHING BELOW THIS stop CALL. @@ -163,54 +166,51 @@ open class FenixApplication : LocaleAwareApplication(), Provider { @CallSuper open fun setupInMainProcessOnly() { - PerfStartup.appOnCreateToMegazordInit.measureNoInline { - ProfilerMarkerFactProcessor.create { components.core.engine.profiler }.register() + ProfilerMarkerFactProcessor.create { components.core.engine.profiler }.register() - run { - // Attention: Do not invoke any code from a-s in this scope. - val megazordSetup = setupMegazord() + run { + // Attention: Do not invoke any code from a-s in this scope. + val megazordSetup = setupMegazord() - setDayNightTheme() - components.strictMode.enableStrictMode(true) - warmBrowsersCache() + setDayNightTheme() + components.strictMode.enableStrictMode(true) + warmBrowsersCache() - // Make sure the engine is initialized and ready to use. - components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { - components.core.engine.warmUp() - } - initializeWebExtensionSupport() - restoreBrowserState() - restoreDownloads() - - // Just to make sure it is impossible for any application-services pieces - // to invoke parts of itself that require complete megazord initialization - // before that process completes, we wait here, if necessary. - if (!megazordSetup.isCompleted) { - runBlockingIncrement { megazordSetup.await() } - } + // Make sure the engine is initialized and ready to use. + components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { + components.core.engine.warmUp() + } + initializeWebExtensionSupport() + restoreBrowserState() + restoreDownloads() + + // Just to make sure it is impossible for any application-services pieces + // to invoke parts of itself that require complete megazord initialization + // before that process completes, we wait here, if necessary. + if (!megazordSetup.isCompleted) { + runBlockingIncrement { megazordSetup.await() } } } - PerfStartup.appOnCreateToSetupInMain.measureNoInline { - setupLeakCanary() - startMetricsIfEnabled() - setupPush() + setupLeakCanary() + startMetricsIfEnabled() + setupPush() - visibilityLifecycleCallback = VisibilityLifecycleCallback(getSystemService()) - registerActivityLifecycleCallbacks(visibilityLifecycleCallback) + visibilityLifecycleCallback = VisibilityLifecycleCallback(getSystemService()) + registerActivityLifecycleCallbacks(visibilityLifecycleCallback) + registerActivityLifecycleCallbacks(MarkersLifecycleCallbacks(components.core.engine)) - // Storage maintenance disabled, for now, as it was interfering with background migrations. - // See https://github.com/mozilla-mobile/fenix/issues/7227 for context. - // if ((System.currentTimeMillis() - settings().lastPlacesStorageMaintenance) > ONE_DAY_MILLIS) { - // runStorageMaintenance() - // } + // Storage maintenance disabled, for now, as it was interfering with background migrations. + // See https://github.com/mozilla-mobile/fenix/issues/7227 for context. + // if ((System.currentTimeMillis() - settings().lastPlacesStorageMaintenance) > ONE_DAY_MILLIS) { + // runStorageMaintenance() + // } - components.appStartReasonProvider.registerInAppOnCreate(this) - components.startupActivityLog.registerInAppOnCreate(this) - initVisualCompletenessQueueAndQueueTasks() + components.appStartReasonProvider.registerInAppOnCreate(this) + components.startupActivityLog.registerInAppOnCreate(this) + initVisualCompletenessQueueAndQueueTasks() - ProcessLifecycleOwner.get().lifecycle.addObserver(TelemetryLifecycleObserver(components.core.store)) - } + ProcessLifecycleOwner.get().lifecycle.addObserver(TelemetryLifecycleObserver(components.core.store)) } @OptIn(DelicateCoroutinesApi::class) // GlobalScope usage @@ -249,6 +249,27 @@ open class FenixApplication : LocaleAwareApplication(), Provider { components.core.bookmarksStorage.warmUp() components.core.passwordsStorage.warmUp() components.core.autofillStorage.warmUp() + + // Populate the top site cache to improve initial load experience + // of the home fragment when the app is launched to a tab. The actual + // database call is not expensive. However, the additional context + // switches delay rendering top sites when the cache is empty, which + // we can prevent with this. + components.core.topSitesStorage.getTopSites( + components.settings.topSitesMaxLimit, + if (components.settings.showTopFrecentSites) + FrecencyThresholdOption.SKIP_ONE_TIME_PAGES + else + null + ) + + // This service uses `historyStorage`, and so we can only touch it when we know + // it's safe to touch `historyStorage. By 'safe', we mainly mean that underlying + // places library will be able to load, which requires first running Megazord.init(). + // The visual completeness tasks are scheduled after the Megazord.init() call. + components.core.historyMetadataService.cleanup( + System.currentTimeMillis() - Core.HISTORY_METADATA_MAX_AGE_IN_MS + ) } SecurePrefsTelemetry(this@FenixApplication, components.analytics.experiments).startTests() @@ -621,6 +642,15 @@ open class FenixApplication : LocaleAwareApplication(), Provider { tabViewSetting.set(settings.getTabViewPingString()) closeTabSetting.set(settings.getTabTimeoutPingString()) + inactiveTabsCount.set(browserStore.state.inactiveTabs.size.toLong()) + + val installSourcePackage = if (SDK_INT >= Build.VERSION_CODES.R) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + packageManager.getInstallerPackageName(packageName) + } + installSource.set(installSourcePackage.orEmpty()) } with(AndroidAutofill) { @@ -647,6 +677,7 @@ open class FenixApplication : LocaleAwareApplication(), Provider { with(Preferences) { searchSuggestionsEnabled.set(settings.shouldShowSearchSuggestions) remoteDebuggingEnabled.set(settings.isRemoteDebuggingEnabled) + studiesEnabled.set(settings.isExperimentationEnabled) telemetryEnabled.set(settings.isTelemetryEnabled) browsingHistorySuggestion.set(settings.shouldShowHistorySuggestions) bookmarksSuggestion.set(settings.shouldShowBookmarkSuggestions) @@ -700,7 +731,23 @@ open class FenixApplication : LocaleAwareApplication(), Provider { else -> "" } ) + + inactiveTabsEnabled.set(settings.inactiveTabsAreEnabled) } + reportHomeScreenMetrics(settings) + } + + @VisibleForTesting + internal fun reportHomeScreenMetrics(settings: Settings) { + components.analytics.experiments.register(object : NimbusInterface.Observer { + override fun onUpdatesApplied(updated: List) { + CustomizeHome.jumpBackIn.set(settings.showRecentTabsFeature) + CustomizeHome.recentlySaved.set(settings.showRecentBookmarksFeature) + CustomizeHome.mostVisitedSites.set(settings.showTopFrecentSites) + CustomizeHome.recentlyVisited.set(settings.historyMetadataUIFeature) + CustomizeHome.pocket.set(settings.showPocketRecommendationsFeature) + } + }) } protected fun recordOnInit() { diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index af01860f0d..ff1fe2b329 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix import android.content.Context import android.content.Intent import android.content.Intent.ACTION_MAIN +import android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT import android.content.res.Configuration import android.os.Build import android.os.Bundle @@ -14,10 +15,11 @@ import android.os.StrictMode import android.os.SystemClock import android.text.format.DateUtils import android.util.AttributeSet +import android.view.ActionMode import android.view.KeyEvent import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View -import android.view.ActionMode import android.view.ViewConfiguration import android.view.WindowManager.LayoutParams.FLAG_SECURE import androidx.annotation.CallSuper @@ -32,14 +34,13 @@ import androidx.navigation.NavDirections import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.NavigationUI -import kotlinx.android.synthetic.main.activity_home.* import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers.IO import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.search.SearchEngine @@ -50,6 +51,7 @@ import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineView import mozilla.components.concept.storage.BookmarkNode import mozilla.components.concept.storage.BookmarkNodeType +import mozilla.components.concept.storage.HistoryMetadataKey import mozilla.components.feature.contextmenu.DefaultSelectionActionDelegate import mozilla.components.feature.privatemode.notification.PrivateNotificationFeature import mozilla.components.feature.search.BrowserStoreSearchAdapter @@ -67,7 +69,6 @@ import mozilla.components.support.utils.SafeIntent import mozilla.components.support.utils.toSafeIntent import mozilla.components.support.webextensions.WebExtensionPopupFeature import org.mozilla.fenix.GleanMetrics.Metrics -import org.mozilla.fenix.GleanMetrics.PerfStartup import org.mozilla.fenix.addons.AddonDetailsFragmentDirections import org.mozilla.fenix.addons.AddonPermissionsDetailsFragmentDirections import org.mozilla.fenix.browser.browsingmode.BrowsingMode @@ -75,11 +76,11 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.browser.browsingmode.DefaultBrowsingModeManager import org.mozilla.fenix.components.metrics.BreadcrumbsRecorder import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.ActivityHomeBinding import org.mozilla.fenix.exceptions.trackingprotection.TrackingProtectionExceptionsFragmentDirections import org.mozilla.fenix.ext.alreadyOnDestination import org.mozilla.fenix.ext.breadcrumb import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.measureNoInline import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.setNavigationIcon @@ -94,8 +95,10 @@ import org.mozilla.fenix.home.intent.StartSearchIntentProcessor import org.mozilla.fenix.library.bookmarks.BookmarkFragmentDirections import org.mozilla.fenix.library.bookmarks.DesktopFolders import org.mozilla.fenix.library.history.HistoryFragmentDirections +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections import org.mozilla.fenix.library.recentlyclosed.RecentlyClosedFragmentDirections import org.mozilla.fenix.onboarding.DefaultBrowserNotificationWorker +import org.mozilla.fenix.perf.MarkersLifecycleCallbacks import org.mozilla.fenix.perf.Performance import org.mozilla.fenix.perf.PerformanceInflater import org.mozilla.fenix.perf.ProfilerMarkers @@ -111,11 +114,13 @@ import org.mozilla.fenix.settings.logins.fragment.LoginDetailFragmentDirections import org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragmentDirections import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections +import org.mozilla.fenix.settings.studies.StudiesFragmentDirections import org.mozilla.fenix.share.AddNewDeviceFragmentDirections import org.mozilla.fenix.tabstray.TabsTrayFragment import org.mozilla.fenix.tabstray.TabsTrayFragmentDirections import org.mozilla.fenix.theme.DefaultThemeManager import org.mozilla.fenix.theme.ThemeManager +import org.mozilla.fenix.trackingprotection.TrackingProtectionPanelDialogFragmentDirections import org.mozilla.fenix.utils.BrowsersCache import org.mozilla.fenix.utils.Settings import java.lang.ref.WeakReference @@ -135,6 +140,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { // components requires context to access. protected val homeActivityInitTimeStampNanoSeconds = SystemClock.elapsedRealtimeNanos() + private lateinit var binding: ActivityHomeBinding lateinit var themeManager: ThemeManager lateinit var browsingModeManager: BrowsingModeManager @@ -176,9 +182,9 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { private val startupPathProvider = StartupPathProvider() private lateinit var startupTypeTelemetry: StartupTypeTelemetry - final override fun onCreate(savedInstanceState: Bundle?): Unit = PerfStartup.homeActivityOnCreate.measureNoInline { - // DO NOT MOVE ANYTHING ABOVE THIS addMarker CALL. - components.core.engine.profiler?.addMarker("Activity.onCreate", "HomeActivity") + final override fun onCreate(savedInstanceState: Bundle?) { + // DO NOT MOVE ANYTHING ABOVE THIS getProfilerTime CALL. + val startTimeProfiler = components.core.engine.profiler?.getProfilerTime() components.strictMode.attachListenerToDisablePenaltyDeath(supportFragmentManager) // There is disk read violations on some devices such as samsung and pixel for android 9/10 @@ -188,6 +194,9 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { super.onCreate(savedInstanceState) } + // Checks if Activity is currently in PiP mode if launched from external intents, then exits it + checkAndExitPiP() + // Diagnostic breadcrumb for "Display already aquired" crash: // https://github.com/mozilla-mobile/android-components/issues/7960 breadcrumb( @@ -200,12 +209,14 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { components.publicSuffixList.prefetch() - setContentView(R.layout.activity_home) + binding = ActivityHomeBinding.inflate(layoutInflater) + setContentView(binding.root) + ProfilerMarkers.addListenerForOnGlobalLayout(components.core.engine, this, binding.root) // Must be after we set the content view if (isVisuallyComplete) { components.performance.visualCompletenessQueue - .attachViewToRunVisualCompletenessQueueLater(WeakReference(rootContainer)) + .attachViewToRunVisualCompletenessQueueLater(WeakReference(binding.rootContainer)) } privateNotificationObserver = PrivateNotificationFeature( @@ -241,10 +252,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { } supportActionBar?.hide() - lifecycle.addObservers( - webExtensionPopupFeature, - StartupTimeline.homeActivityLifecycleObserver - ) + lifecycle.addObservers(webExtensionPopupFeature) if (shouldAddToRecentsScreen(intent)) { intent.removeExtra(START_IN_RECENTS_SCREEN) @@ -261,9 +269,24 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { components.core.requestInterceptor.setNavigationController(navHost.navController) + if (settings().showPocketRecommendationsFeature) { + components.core.pocketStoriesService.startPeriodicStoriesRefresh() + } + + components.core.engine.profiler?.addMarker( + MarkersLifecycleCallbacks.MARKER_NAME, startTimeProfiler, "HomeActivity.onCreate" + ) StartupTimeline.onActivityCreateEndHome(this) // DO NOT MOVE ANYTHING BELOW HERE. } + private fun checkAndExitPiP() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode && intent != null) { + // Exit PiP mode + moveTaskToBack(false) + startActivity(Intent(this, this::class.java).setFlags(FLAG_ACTIVITY_REORDER_TO_FRONT)) + } + } + private fun startupTelemetryOnCreateCalled(safeIntent: SafeIntent) { // We intentionally only record this in HomeActivity and not ExternalBrowserActivity (e.g. // PWAs) so we don't include more unpredictable code paths in the results. @@ -271,7 +294,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { components.performance.visualCompletenessQueue, components.startupStateProvider, safeIntent, - rootContainer + binding.rootContainer ) } @@ -279,14 +302,6 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { override fun onResume() { super.onResume() - // Even if screenshots are allowed, we hide private content in the recents screen in onPause - // only when we are in private mode, so in onResume we should go back to setting these flags - // with the user screenshot setting only when we are in private mode. - // See https://github.com/mozilla-mobile/fenix/issues/11153 - if (settings().lastKnownMode == BrowsingMode.Private) { - updateSecureWindowFlags(settings().lastKnownMode) - } - // Diagnostic breadcrumb for "Display already aquired" crash: // https://github.com/mozilla-mobile/android-components/issues/7960 breadcrumb( @@ -310,7 +325,10 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { isFenixTheDefaultBrowser() } - override fun onStart() = PerfStartup.homeActivityOnStart.measureNoInline { + override fun onStart() { + // DO NOT MOVE ANYTHING ABOVE THIS getProfilerTime CALL. + val startProfilerTime = components.core.engine.profiler?.getProfilerTime() + super.onStart() // Diagnostic breadcrumb for "Display already aquired" crash: @@ -319,7 +337,10 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { message = "onStart()" ) - ProfilerMarkers.homeActivityOnStart(rootContainer, components.core.engine.profiler) + ProfilerMarkers.homeActivityOnStart(binding.rootContainer, components.core.engine.profiler) + components.core.engine.profiler?.addMarker( + MarkersLifecycleCallbacks.MARKER_NAME, startProfilerTime, "HomeActivity.onStart" + ) // DO NOT MOVE ANYTHING BELOW THIS addMarker CALL. } override fun onStop() { @@ -340,13 +361,6 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { settings().shouldReturnToBrowser = components.core.store.state.getNormalOrPrivateTabs(private = false).isNotEmpty() - // Even if screenshots are allowed, we want to hide private content in the recents screen - // only when we are in private mode - // See https://github.com/mozilla-mobile/fenix/issues/11153 - if (settings().lastKnownMode.isPrivate) { - window.addFlags(FLAG_SECURE) - } - lifecycleScope.launch(IO) { components.core.bookmarksStorage.getTree(BookmarkRoot.Root.id, true)?.let { val desktopRootNode = DesktopFolders( @@ -411,6 +425,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { ) ) + components.core.pocketStoriesService.stopPeriodicStoriesRefresh() privateNotificationObserver?.stop() } @@ -569,6 +584,11 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { return false } + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + ProfilerMarkers.addForDispatchTouchEvent(components.core.engine.profiler, ev) + return super.dispatchTouchEvent(ev) + } + final override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { // Inspired by https://searchfox.org/mozilla-esr68/source/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java#584-613 // Android N and Huawei devices have broken onKeyLongPress events for the back button, so we @@ -667,7 +687,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { */ override fun getSupportActionBarAndInflateIfNecessary(): ActionBar { if (!isToolbarInflated) { - navigationToolbar = navigationToolbarStub.inflate() as Toolbar + navigationToolbar = binding.navigationToolbarStub.inflate() as Toolbar setSupportActionBar(navigationToolbar) // Add ids to this that we don't want to have a toolbar back button @@ -709,10 +729,11 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { engine: SearchEngine? = null, forceSearch: Boolean = false, flags: EngineSession.LoadUrlFlags = EngineSession.LoadUrlFlags.none(), - requestDesktopMode: Boolean = false + requestDesktopMode: Boolean = false, + historyMetadata: HistoryMetadataKey? = null ) { openToBrowser(from, customTabSessionId) - load(searchTermOrURL, newTab, engine, forceSearch, flags, requestDesktopMode) + load(searchTermOrURL, newTab, engine, forceSearch, flags, requestDesktopMode, historyMetadata) } fun openToBrowser(from: BrowserDirection, customTabSessionId: String? = null) { @@ -740,12 +761,16 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { BookmarkFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromHistory -> HistoryFragmentDirections.actionGlobalBrowser(customTabSessionId) + BrowserDirection.FromHistoryMetadataGroup -> + HistoryMetadataGroupFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromTrackingProtectionExceptions -> TrackingProtectionExceptionsFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromAbout -> AboutFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromTrackingProtection -> TrackingProtectionFragmentDirections.actionGlobalBrowser(customTabSessionId) + BrowserDirection.FromTrackingProtectionDialog -> + TrackingProtectionPanelDialogFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromSavedLoginsFragment -> SavedLoginsAuthFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromAddNewDeviceFragment -> @@ -764,12 +789,17 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { TabsTrayFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromRecentlyClosed -> RecentlyClosedFragmentDirections.actionGlobalBrowser(customTabSessionId) + BrowserDirection.FromStudiesFragment -> StudiesFragmentDirections.actionGlobalBrowser( + customTabSessionId + ) } /** * Loads a URL or performs a search (depending on the value of [searchTermOrURL]). * * @param flags Flags that will be used when loading the URL (not applied to searches). + * @param historyMetadata The [HistoryMetadataKey] of the new tab in case this tab + * was opened from history. */ private fun load( searchTermOrURL: String, @@ -777,7 +807,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { engine: SearchEngine?, forceSearch: Boolean, flags: EngineSession.LoadUrlFlags = EngineSession.LoadUrlFlags.none(), - requestDesktopMode: Boolean = false + requestDesktopMode: Boolean = false, + historyMetadata: HistoryMetadataKey? = null ) { val startTime = components.core.engine.profiler?.getProfilerTime() val mode = browsingModeManager.mode @@ -795,7 +826,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { components.useCases.tabsUseCases.addTab( url = searchTermOrURL.toNormalizedUrl(), flags = flags, - private = private + private = private, + historyMetadata = historyMetadata ) } else { components.useCases.sessionUseCases.loadUrl( diff --git a/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt b/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt index 4556bc9861..72ff68264c 100644 --- a/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/IntentReceiverActivity.kt @@ -20,6 +20,7 @@ import org.mozilla.fenix.components.getType import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.perf.MarkersLifecycleCallbacks import org.mozilla.fenix.perf.StartupTimeline import org.mozilla.fenix.shortcut.NewTabShortcutIntentProcessor @@ -30,8 +31,8 @@ class IntentReceiverActivity : Activity() { @VisibleForTesting override fun onCreate(savedInstanceState: Bundle?) { - // DO NOT MOVE ANYTHING ABOVE THIS addMarker CALL. - components.core.engine.profiler?.addMarker("Activity.onCreate", "IntentReceiverActivity") + // DO NOT MOVE ANYTHING ABOVE THIS getProfilerTime CALL. + val startTimeProfiler = components.core.engine.profiler?.getProfilerTime() // StrictMode violation on certain devices such as Samsung components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { @@ -45,6 +46,9 @@ class IntentReceiverActivity : Activity() { intent.stripUnwantedFlags() processIntent(intent) + components.core.engine.profiler?.addMarker( + MarkersLifecycleCallbacks.MARKER_NAME, startTimeProfiler, "IntentReceiverActivity.onCreate" + ) StartupTimeline.onActivityCreateEndIntentReceiver() // DO NOT MOVE ANYTHING BELOW HERE. } diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsView.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsBindingDelegate.kt similarity index 76% rename from app/src/main/java/org/mozilla/fenix/addons/AddonDetailsView.kt rename to app/src/main/java/org/mozilla/fenix/addons/AddonDetailsBindingDelegate.kt index 5561941e03..99bfb4be5a 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsView.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsBindingDelegate.kt @@ -13,12 +13,11 @@ import android.view.View import androidx.core.net.toUri import androidx.core.text.HtmlCompat import androidx.core.text.getSpans -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.fragment_add_on_details.* import mozilla.components.feature.addons.Addon import mozilla.components.feature.addons.ui.translateDescription import mozilla.components.feature.addons.ui.updatedAtDate import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentAddOnDetailsBinding import java.text.DateFormat import java.text.NumberFormat import java.util.Locale @@ -39,10 +38,10 @@ interface AddonDetailsInteractor { /** * Shows the details of an add-on. */ -class AddonDetailsView( - override val containerView: View, +class AddonDetailsBindingDelegate( + private val binding: FragmentAddOnDetailsBinding, private val interactor: AddonDetailsInteractor -) : LayoutContainer { +) { private val dateFormatter = DateFormat.getDateInstance() private val numberFormatter = NumberFormat.getNumberInstance(Locale.getDefault()) @@ -58,24 +57,24 @@ class AddonDetailsView( private fun bindRating(addon: Addon) { addon.rating?.let { rating -> - val resources = containerView.resources + val resources = binding.root.resources val ratingContentDescription = resources.getString(R.string.mozac_feature_addons_rating_content_description) - rating_view.contentDescription = String.format(ratingContentDescription, rating.average) - rating_view.rating = rating.average + binding.ratingView.contentDescription = String.format(ratingContentDescription, rating.average) + binding.ratingView.rating = rating.average - users_count.text = numberFormatter.format(rating.reviews) + binding.usersCount.text = numberFormatter.format(rating.reviews) } } private fun bindWebsite(addon: Addon) { - home_page_label.setOnClickListener { + binding.homePageLabel.setOnClickListener { interactor.openWebsite(addon.siteUrl.toUri()) } } private fun bindLastUpdated(addon: Addon) { - last_updated_text.text = dateFormatter.format(addon.updatedAtDate) + binding.lastUpdatedText.text = dateFormatter.format(addon.updatedAtDate) } private fun bindVersion(addon: Addon) { @@ -83,24 +82,24 @@ class AddonDetailsView( if (version.isNullOrEmpty()) { version = addon.version } - version_text.text = version + binding.versionText.text = version if (addon.isInstalled()) { - version_text.setOnLongClickListener { + binding.versionText.setOnLongClickListener { interactor.showUpdaterDialog(addon) true } } else { - version_text.setOnLongClickListener(null) + binding.versionText.setOnLongClickListener(null) } } private fun bindAuthors(addon: Addon) { - author_text.text = addon.authors.joinToString { author -> author.name }.trim() + binding.authorText.text = addon.authors.joinToString { author -> author.name }.trim() } private fun bindDetails(addon: Addon) { - val detailsText = addon.translateDescription(containerView.context) + val detailsText = addon.translateDescription(binding.root.context) val parsedText = detailsText.replace("\n", "
") val text = HtmlCompat.fromHtml(parsedText, HtmlCompat.FROM_HTML_MODE_COMPACT) @@ -110,8 +109,8 @@ class AddonDetailsView( for (link in links) { addActionToLinks(spannableStringBuilder, link) } - details.text = spannableStringBuilder - details.movementMethod = LinkMovementMethod.getInstance() + binding.details.text = spannableStringBuilder + binding.details.movementMethod = LinkMovementMethod.getInstance() } private fun addActionToLinks( diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsFragment.kt index b391f5b696..8767e27ec4 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsFragment.kt @@ -21,6 +21,7 @@ import mozilla.components.feature.addons.update.DefaultAddonUpdater.UpdateAttemp import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentAddOnDetailsBinding import org.mozilla.fenix.ext.showToolbar /** @@ -38,7 +39,8 @@ class AddonDetailsFragment : Fragment(R.layout.fragment_add_on_details), AddonDe showToolbar(title = args.addon.translateName(it)) } - AddonDetailsView(view, interactor = this).bind(args.addon) + val binding = FragmentAddOnDetailsBinding.bind(view) + AddonDetailsBindingDelegate(binding, interactor = this).bind(args.addon) } override fun openWebsite(addonSiteUrl: Uri) { diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonInternalSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonInternalSettingsFragment.kt index 94571480a4..ac93df6c23 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonInternalSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonInternalSettingsFragment.kt @@ -10,9 +10,9 @@ import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_add_on_internal_settings.* import mozilla.components.feature.addons.ui.translateName import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentAddOnInternalSettingsBinding import org.mozilla.fenix.ext.showToolbar /** @@ -40,10 +40,10 @@ class AddonInternalSettingsFragment : AddonPopupBaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - + val binding = FragmentAddOnInternalSettingsBinding.bind(view) args.addon.installedState?.optionsPageUrl?.let { engineSession?.let { engineSession -> - addonSettingsEngineView.render(engineSession) + binding.addonSettingsEngineView.render(engineSession) engineSession.loadUrl(it) } } ?: findNavController().navigateUp() diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsView.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionDetailsBindingDelegate.kt similarity index 83% rename from app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsView.kt rename to app/src/main/java/org/mozilla/fenix/addons/AddonPermissionDetailsBindingDelegate.kt index 78f89a6ccf..d1b817fbae 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsView.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionDetailsBindingDelegate.kt @@ -5,14 +5,12 @@ package org.mozilla.fenix.addons import android.net.Uri -import android.view.View import androidx.core.net.toUri import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.fragment_add_on_permissions.* import mozilla.components.feature.addons.Addon import mozilla.components.feature.addons.ui.AddonPermissionsAdapter import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentAddOnPermissionsBinding import org.mozilla.fenix.theme.ThemeManager interface AddonPermissionsDetailsInteractor { @@ -26,10 +24,10 @@ interface AddonPermissionsDetailsInteractor { /** * Shows the permission details of an add-on. */ -class AddonPermissionsDetailsView( - override val containerView: View, +class AddonPermissionDetailsBindingDelegate( + val binding: FragmentAddOnPermissionsBinding, private val interactor: AddonPermissionsDetailsInteractor -) : LayoutContainer { +) { fun bind(addon: Addon) { bindPermissions(addon) @@ -37,7 +35,7 @@ class AddonPermissionsDetailsView( } private fun bindPermissions(addon: Addon) { - add_ons_permissions.apply { + binding.addOnsPermissions.apply { layoutManager = LinearLayoutManager(context) val sortedPermissions = addon.translatePermissions(context).sorted() adapter = AddonPermissionsAdapter( @@ -50,7 +48,7 @@ class AddonPermissionsDetailsView( } private fun bindLearnMore() { - learn_more_label.setOnClickListener { + binding.learnMoreLabel.setOnClickListener { interactor.openWebsite(LEARN_MORE_URL.toUri()) } } diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsFragment.kt index c349747256..a5503518c1 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsFragment.kt @@ -13,6 +13,7 @@ import mozilla.components.feature.addons.ui.translateName import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentAddOnPermissionsBinding import org.mozilla.fenix.ext.showToolbar /** @@ -29,7 +30,8 @@ class AddonPermissionsDetailsFragment : context?.let { showToolbar(args.addon.translateName(it)) } - AddonPermissionsDetailsView(view, interactor = this).bind(args.addon) + val binding = FragmentAddOnPermissionsBinding.bind(view) + AddonPermissionDetailsBindingDelegate(binding, interactor = this).bind(args.addon) } override fun openWebsite(addonSiteUrl: Uri) { diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt index 6e42ffecce..a840ef1868 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt @@ -26,9 +26,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import kotlinx.android.synthetic.main.fragment_add_ons_management.addonProgressOverlay import kotlinx.android.synthetic.main.fragment_add_ons_management.view.add_ons_empty_message import kotlinx.android.synthetic.main.fragment_add_ons_management.view.add_ons_list -import kotlinx.android.synthetic.main.fragment_add_ons_management.view.add_ons_progress_bar -import kotlinx.android.synthetic.main.overlay_add_on_progress.view.add_ons_overlay_text -import kotlinx.android.synthetic.main.overlay_add_on_progress.view.cancel_button import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch @@ -41,6 +38,7 @@ import io.github.forkmaintainers.iceraven.components.PagedAddonsManagerAdapter import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.FragmentAddOnsManagementBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getRootView import org.mozilla.fenix.ext.requireComponents @@ -60,6 +58,8 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) private val args by navArgs() + private var binding: FragmentAddOnsManagementBinding? = null + /** * Whether or not an add-on installation is in progress. */ @@ -88,7 +88,8 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - bindRecyclerView(view) + binding = FragmentAddOnsManagementBinding.bind(view) + bindRecyclerView() } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -161,16 +162,17 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) super.onDestroyView() // letting go of the resources to avoid memory leak. adapter = null + binding = null } - private fun bindRecyclerView(view: View) { + private fun bindRecyclerView() { val managementView = AddonsManagementView( navController = findNavController(), showPermissionDialog = ::showPermissionDialog ) - val recyclerView = view.add_ons_list - recyclerView.layoutManager = LinearLayoutManager(requireContext()) + val recyclerView = binding?.addOnsList + recyclerView?.layoutManager = LinearLayoutManager(requireContext()) val shouldRefresh = adapter != null // If the fragment was launched to install an "external" add-on from AMO, we deactivate @@ -190,10 +192,10 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) ) } isInstallationInProgress = false - view.add_ons_progress_bar.isVisible = false - view.add_ons_empty_message.isVisible = false + binding?.addOnsProgressBar?.isVisible = false + binding?.addOnsEmptyMessage?.isVisible = false - recyclerView.adapter = adapter + recyclerView?.adapter = adapter if (shouldRefresh) { adapter?.updateAddons(addons!!) } @@ -208,13 +210,12 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) } catch (e: AddonManagerException) { lifecycleScope.launch(Dispatchers.Main) { runIfFragmentIsAttached { - showSnackBar( - view, - getString(R.string.mozac_feature_addons_failed_to_query_add_ons) - ) + binding?.let { + showSnackBar(it.root, getString(R.string.mozac_feature_addons_failed_to_query_add_ons)) + } isInstallationInProgress = false - view.add_ons_progress_bar.isVisible = false - view.add_ons_empty_message.isVisible = true + binding?.addOnsProgressBar?.isVisible = false + binding?.addOnsEmptyMessage?.isVisible = true } } } @@ -341,10 +342,10 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) } private val onPositiveButtonClicked: ((Addon) -> Unit) = { addon -> - addonProgressOverlay?.visibility = View.VISIBLE + binding?.addonProgressOverlay?.overlayCardView?.visibility = View.VISIBLE if (requireContext().settings().accessibilityServicesEnabled) { - announceForAccessibility(addonProgressOverlay.add_ons_overlay_text.text) + binding?.let { announceForAccessibility(it.addonProgressOverlay.addOnsOverlayText.text) } } isInstallationInProgress = true @@ -355,7 +356,7 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) runIfFragmentIsAttached { isInstallationInProgress = false adapter?.updateAddon(it) - addonProgressOverlay?.visibility = View.GONE + binding?.addonProgressOverlay?.overlayCardView?.visibility = View.GONE showInstallationDialog(it) } }, @@ -374,17 +375,18 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) ) } } - addonProgressOverlay?.visibility = View.GONE + binding?.addonProgressOverlay?.overlayCardView?.visibility = View.GONE isInstallationInProgress = false } } ) - addonProgressOverlay.cancel_button.setOnClickListener { + binding?.addonProgressOverlay?.cancelButton?.setOnClickListener { lifecycleScope.launch(Dispatchers.Main) { + val safeBinding = binding // Hide the installation progress overlay once cancellation is successful. if (installOperation.cancel().await()) { - addonProgressOverlay.visibility = View.GONE + safeBinding?.addonProgressOverlay?.overlayCardView?.visibility = View.GONE } } } @@ -394,10 +396,14 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) val event = AccessibilityEvent.obtain( AccessibilityEvent.TYPE_ANNOUNCEMENT ) - addonProgressOverlay.onInitializeAccessibilityEvent(event) + + binding?.addonProgressOverlay?.overlayCardView?.onInitializeAccessibilityEvent(event) event.text.add(announcementText) event.contentDescription = null - addonProgressOverlay.parent.requestSendAccessibilityEvent(addonProgressOverlay, event) + binding?.addonProgressOverlay?.overlayCardView?.parent?.requestSendAccessibilityEvent( + binding?.addonProgressOverlay?.overlayCardView, + event + ) } companion object { diff --git a/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt index bece385139..b7264ad863 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt @@ -15,7 +15,6 @@ import androidx.navigation.Navigation import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import com.google.android.material.switchmaterial.SwitchMaterial -import kotlinx.android.synthetic.main.fragment_installed_add_on_details.view.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.feature.addons.Addon @@ -24,6 +23,7 @@ import mozilla.components.feature.addons.ui.translateName import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.FragmentInstalledAddOnDetailsBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.runIfFragmentIsAttached @@ -34,6 +34,8 @@ import org.mozilla.fenix.ext.runIfFragmentIsAttached @Suppress("LargeClass", "TooManyFunctions") class InstalledAddonDetailsFragment : Fragment() { private lateinit var addon: Addon + private var _binding: FragmentInstalledAddOnDetailsBinding? = null + private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, @@ -44,17 +46,28 @@ class InstalledAddonDetailsFragment : Fragment() { addon = AddonDetailsFragmentArgs.fromBundle(requireNotNull(arguments)).addon } - return inflater.inflate(R.layout.fragment_installed_add_on_details, container, false).also { - bindUI(it) - } + _binding = FragmentInstalledAddOnDetailsBinding.inflate( + inflater, + container, + false + ) + + bindUI() + + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - bindAddon(view) + bindAddon() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null } - private fun bindAddon(view: View) { + private fun bindAddon() { lifecycleScope.launch(Dispatchers.IO) { try { val addons = requireContext().components.addonManager.getAddons() @@ -65,10 +78,10 @@ class InstalledAddonDetailsFragment : Fragment() { throw AddonManagerException(Exception("Addon ${addon.id} not found")) } else { addon = it - bindUI(view) + bindUI() } - view.add_on_progress_bar.isVisible = false - view.addon_container.isVisible = true + binding.addOnProgressBar.isVisible = false + binding.addonContainer.isVisible = true } } } @@ -76,7 +89,7 @@ class InstalledAddonDetailsFragment : Fragment() { lifecycleScope.launch(Dispatchers.Main) { runIfFragmentIsAttached { showSnackBar( - view, + binding.root, getString(R.string.mozac_feature_addons_failed_to_query_add_ons) ) findNavController().popBackStack() @@ -86,27 +99,27 @@ class InstalledAddonDetailsFragment : Fragment() { } } - private fun bindUI(view: View) { - val title = addon.translateName(view.context) + private fun bindUI() { + val title = addon.translateName(binding.root.context) showToolbar(title) - bindEnableSwitch(view) - bindSettings(view) - bindDetails(view) - bindPermissions(view) - bindAllowInPrivateBrowsingSwitch(view) - bindRemoveButton(view) + bindEnableSwitch() + bindSettings() + bindDetails() + bindPermissions() + bindAllowInPrivateBrowsingSwitch() + bindRemoveButton() } @SuppressWarnings("LongMethod") - private fun bindEnableSwitch(view: View) { - val switch = view.enable_switch - val privateBrowsingSwitch = view.allow_in_private_browsing_switch + private fun bindEnableSwitch() { + val switch = binding.enableSwitch + val privateBrowsingSwitch = binding.allowInPrivateBrowsingSwitch switch.setState(addon.isEnabled()) switch.setOnCheckedChangeListener { v, isChecked -> val addonManager = v.context.components.addonManager switch.isClickable = false - view.remove_add_on.isEnabled = false + binding.removeAddOn.isEnabled = false if (isChecked) { addonManager.enableAddon( addon, @@ -117,11 +130,11 @@ class InstalledAddonDetailsFragment : Fragment() { privateBrowsingSwitch.isVisible = it.isEnabled() privateBrowsingSwitch.isChecked = it.isAllowedInPrivateBrowsing() switch.setText(R.string.mozac_feature_addons_enabled) - view.settings.isVisible = shouldSettingsBeVisible() - view.remove_add_on.isEnabled = true + binding.settings.isVisible = shouldSettingsBeVisible() + binding.removeAddOn.isEnabled = true context?.let { showSnackBar( - view, + binding.root, getString( R.string.mozac_feature_addons_successfully_enabled, addon.translateName(it) @@ -133,11 +146,11 @@ class InstalledAddonDetailsFragment : Fragment() { onError = { runIfFragmentIsAttached { switch.isClickable = true - view.remove_add_on.isEnabled = true + binding.removeAddOn.isEnabled = true switch.setState(addon.isEnabled()) context?.let { showSnackBar( - view, + binding.root, getString( R.string.mozac_feature_addons_failed_to_enable, addon.translateName(it) @@ -148,7 +161,7 @@ class InstalledAddonDetailsFragment : Fragment() { } ) } else { - view.settings.isVisible = false + binding.settings.isVisible = false addonManager.disableAddon( addon, onSuccess = { @@ -157,10 +170,10 @@ class InstalledAddonDetailsFragment : Fragment() { switch.isClickable = true privateBrowsingSwitch.isVisible = it.isEnabled() switch.setText(R.string.mozac_feature_addons_disabled) - view.remove_add_on.isEnabled = true + binding.removeAddOn.isEnabled = true context?.let { showSnackBar( - view, + binding.root, getString( R.string.mozac_feature_addons_successfully_disabled, addon.translateName(it) @@ -173,11 +186,11 @@ class InstalledAddonDetailsFragment : Fragment() { runIfFragmentIsAttached { switch.isClickable = true privateBrowsingSwitch.isClickable = true - view.remove_add_on.isEnabled = true + binding.removeAddOn.isEnabled = true switch.setState(addon.isEnabled()) context?.let { showSnackBar( - view, + binding.root, getString( R.string.mozac_feature_addons_failed_to_disable, addon.translateName(it) @@ -191,8 +204,8 @@ class InstalledAddonDetailsFragment : Fragment() { } } - private fun bindSettings(view: View) { - view.settings.apply { + private fun bindSettings() { + binding.settings.apply { isVisible = shouldSettingsBeVisible() setOnClickListener { requireContext().components.analytics.metrics.track( @@ -216,34 +229,34 @@ class InstalledAddonDetailsFragment : Fragment() { } } - private fun bindDetails(view: View) { - view.details.setOnClickListener { + private fun bindDetails() { + binding.details.setOnClickListener { val directions = InstalledAddonDetailsFragmentDirections.actionInstalledAddonFragmentToAddonDetailsFragment( addon ) - Navigation.findNavController(view).navigate(directions) + Navigation.findNavController(binding.root).navigate(directions) } } - private fun bindPermissions(view: View) { - view.permissions.setOnClickListener { + private fun bindPermissions() { + binding.permissions.setOnClickListener { val directions = InstalledAddonDetailsFragmentDirections.actionInstalledAddonFragmentToAddonPermissionsDetailsFragment( addon ) - Navigation.findNavController(view).navigate(directions) + Navigation.findNavController(binding.root).navigate(directions) } } - private fun bindAllowInPrivateBrowsingSwitch(view: View) { - val switch = view.allow_in_private_browsing_switch + private fun bindAllowInPrivateBrowsingSwitch() { + val switch = binding.allowInPrivateBrowsingSwitch switch.isChecked = addon.isAllowedInPrivateBrowsing() switch.isVisible = addon.isEnabled() switch.setOnCheckedChangeListener { v, isChecked -> val addonManager = v.context.components.addonManager switch.isClickable = false - view.remove_add_on.isEnabled = false + binding.removeAddOn.isEnabled = false addonManager.setAddonAllowedInPrivateBrowsing( addon, isChecked, @@ -251,45 +264,45 @@ class InstalledAddonDetailsFragment : Fragment() { runIfFragmentIsAttached { this.addon = it switch.isClickable = true - view.remove_add_on.isEnabled = true + binding.removeAddOn.isEnabled = true } }, onError = { runIfFragmentIsAttached { switch.isChecked = addon.isAllowedInPrivateBrowsing() switch.isClickable = true - view.remove_add_on.isEnabled = true + binding.removeAddOn.isEnabled = true } } ) } } - private fun bindRemoveButton(view: View) { - view.remove_add_on.setOnClickListener { - setAllInteractiveViewsClickable(view, false) + private fun bindRemoveButton() { + binding.removeAddOn.setOnClickListener { + setAllInteractiveViewsClickable(binding, false) requireContext().components.addonManager.uninstallAddon( addon, onSuccess = { runIfFragmentIsAttached { - setAllInteractiveViewsClickable(view, true) + setAllInteractiveViewsClickable(binding, true) context?.let { showSnackBar( - view, + binding.root, getString( R.string.mozac_feature_addons_successfully_uninstalled, addon.translateName(it) ) ) } - view.findNavController().popBackStack() + binding.root.findNavController().popBackStack() } }, onError = { _, _ -> runIfFragmentIsAttached { - setAllInteractiveViewsClickable(view, true) + setAllInteractiveViewsClickable(binding, true) context?.let { showSnackBar( - view, + binding.root, getString( R.string.mozac_feature_addons_failed_to_uninstall, addon.translateName(it) @@ -302,12 +315,15 @@ class InstalledAddonDetailsFragment : Fragment() { } } - private fun setAllInteractiveViewsClickable(view: View, clickable: Boolean) { - view.enable_switch.isClickable = clickable - view.settings.isClickable = clickable - view.details.isClickable = clickable - view.permissions.isClickable = clickable - view.remove_add_on.isClickable = clickable + private fun setAllInteractiveViewsClickable( + binding: FragmentInstalledAddOnDetailsBinding, + clickable: Boolean + ) { + binding.enableSwitch.isClickable = clickable + binding.settings.isClickable = clickable + binding.details.isClickable = clickable + binding.permissions.isClickable = clickable + binding.removeAddOn.isClickable = clickable } private fun SwitchMaterial.setState(checked: Boolean) { diff --git a/app/src/main/java/org/mozilla/fenix/addons/NotYetSupportedAddonFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/NotYetSupportedAddonFragment.kt index 25147dba34..258d472430 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/NotYetSupportedAddonFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/NotYetSupportedAddonFragment.kt @@ -12,10 +12,10 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.android.synthetic.main.fragment_not_yet_supported_addons.view.* import mozilla.components.feature.addons.ui.UnsupportedAddonsAdapter import mozilla.components.feature.addons.ui.UnsupportedAddonsAdapterDelegate import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentNotYetSupportedAddonsBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.showToolbar @@ -40,12 +40,14 @@ class NotYetSupportedAddonFragment : addons = args.addons.toList() ) - view.unsupported_add_ons_list.apply { + val binding = FragmentNotYetSupportedAddonsBinding.bind(view) + + binding.unsupportedAddOnsList.apply { layoutManager = LinearLayoutManager(requireContext()) adapter = unsupportedAddonsAdapter } - view.learn_more_label.setOnClickListener { + binding.learnMoreLabel.setOnClickListener { val intent = Intent(Intent.ACTION_VIEW).setData(Uri.parse(LEARN_MORE_URL)) startActivity(intent) } diff --git a/app/src/main/java/org/mozilla/fenix/addons/WebExtensionActionPopupFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/WebExtensionActionPopupFragment.kt index 2a4d572a10..a556237121 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/WebExtensionActionPopupFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/WebExtensionActionPopupFragment.kt @@ -10,13 +10,13 @@ import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_add_on_internal_settings.* import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.state.action.WebExtensionAction import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineView import mozilla.components.lib.state.ext.consumeFrom import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentAddOnInternalSettingsBinding import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar @@ -57,10 +57,12 @@ class WebExtensionActionPopupFragment : AddonPopupBaseFragment(), EngineSession. override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val binding = FragmentAddOnInternalSettingsBinding.bind(view) + val session = engineSession // If we have the session, render it otherwise consume it from the store. if (session != null) { - addonSettingsEngineView.render(session) + binding.addonSettingsEngineView.render(session) consumePopupSession() } else { consumeFrom(coreComponents.store) { state -> @@ -68,7 +70,7 @@ class WebExtensionActionPopupFragment : AddonPopupBaseFragment(), EngineSession. val popupSession = extState.popupSession if (popupSession != null) { initializeSession(popupSession) - addonSettingsEngineView.render(popupSession) + binding.addonSettingsEngineView.render(popupSession) popupSession.register(this) consumePopupSession() engineSession = popupSession diff --git a/app/src/main/java/org/mozilla/fenix/android/FenixDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/android/FenixDialogFragment.kt new file mode 100644 index 0000000000..09577577a7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/android/FenixDialogFragment.kt @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.android + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatDialogFragment +import androidx.appcompat.view.ContextThemeWrapper +import com.google.android.material.R +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import org.mozilla.fenix.HomeActivity + +/** + * Base [AppCompatDialogFragment] that adds behaviour to create a top or bottom dialog. + */ +abstract class FenixDialogFragment : AppCompatDialogFragment() { + /** + * Indicates the position of the dialog top or bottom. + */ + abstract val gravity: Int + /** + * The layout id that will be render on the dialog. + */ + abstract val layoutId: Int + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return if (gravity == Gravity.BOTTOM) { + BottomSheetDialog(requireContext(), this.theme).apply { + setOnShowListener { + val bottomSheet = + findViewById(R.id.design_bottom_sheet) as FrameLayout + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + } else { + Dialog(requireContext()).applyCustomizationsForTopDialog(inflateRootView()) + } + } + + private fun Dialog.applyCustomizationsForTopDialog(rootView: View): Dialog { + addContentView( + rootView, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + ) + + window?.apply { + setGravity(gravity) + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + // This must be called after addContentView, or it won't fully fill to the edge. + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + return this + } + + fun inflateRootView(container: ViewGroup? = null): View { + val contextThemeWrapper = ContextThemeWrapper( + activity, + (activity as HomeActivity).themeManager.currentThemeResource + ) + return LayoutInflater.from(contextThemeWrapper).inflate( + layoutId, + container, + false + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index 18b03dd3fc..19e45b0799 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -30,8 +30,6 @@ import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.fragment_browser.* -import kotlinx.android.synthetic.main.fragment_browser.view.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -115,7 +113,6 @@ import org.mozilla.fenix.ext.breadcrumb import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.ext.hideToolbar -import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings @@ -133,10 +130,9 @@ import mozilla.components.feature.webauthn.WebAuthnFeature import mozilla.components.support.base.feature.ActivityResultHandler import mozilla.components.support.ktx.android.view.enterToImmersiveMode import mozilla.components.support.ktx.kotlin.getOrigin -import org.mozilla.fenix.GleanMetrics.PerfStartup import org.mozilla.fenix.components.toolbar.interactor.BrowserToolbarInteractor import org.mozilla.fenix.components.toolbar.interactor.DefaultBrowserToolbarInteractor -import org.mozilla.fenix.ext.measureNoInline +import org.mozilla.fenix.databinding.FragmentBrowserBinding import org.mozilla.fenix.ext.secure import org.mozilla.fenix.settings.biometric.BiometricPromptFeature import mozilla.components.feature.session.behavior.ToolbarPosition as MozacToolbarPosition @@ -155,6 +151,9 @@ abstract class BaseBrowserFragment : OnBackLongPressedListener, AccessibilityManager.AccessibilityStateChangeListener { + private var _binding: FragmentBrowserBinding? = null + protected val binding get() = _binding!! + private lateinit var browserFragmentStore: BrowserFragmentStore private lateinit var browserAnimator: BrowserAnimator @@ -212,7 +211,7 @@ abstract class BaseBrowserFragment : inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View = PerfStartup.baseBfragmentOnCreateView.measureNoInline { + ): View { customTabSessionId = requireArguments().getString(EXTRA_SESSION_ID) // Diagnostic breadcrumb for "Display already aquired" crash: @@ -224,7 +223,7 @@ abstract class BaseBrowserFragment : ) ) - val view = inflater.inflate(R.layout.fragment_browser, container, false) + _binding = FragmentBrowserBinding.inflate(inflater, container, false) val activity = activity as HomeActivity activity.themeManager.applyStatusBarTheme(activity) @@ -235,30 +234,28 @@ abstract class BaseBrowserFragment : ) } - view + return binding.root } - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) = - PerfStartup.baseBfragmentOnViewCreated.measureNoInline { // weird indentation to avoid breaking blame. - initializeUI(view) - - if (customTabSessionId == null) { - // We currently only need this observer to navigate to home - // in case all tabs have been removed on startup. No need to - // this if we have a known session to display. - observeRestoreComplete(requireComponents.core.store, findNavController()) - } + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initializeUI(view) - observeTabSelection(requireComponents.core.store) + if (customTabSessionId == null) { + // We currently only need this observer to navigate to home + // in case all tabs have been removed on startup. No need to + // this if we have a known session to display. + observeRestoreComplete(requireComponents.core.store, findNavController()) + } - if (!onboarding.userHasBeenOnboarded()) { - observeTabSource(requireComponents.core.store) - } + observeTabSelection(requireComponents.core.store) - requireContext().accessibilityManager.addAccessibilityStateChangeListener(this) - Unit + if (!onboarding.userHasBeenOnboarded()) { + observeTabSource(requireComponents.core.store) } + requireContext().accessibilityManager.addAccessibilityStateChangeListener(this) + } + private fun initializeUI(view: View) { val tab = getCurrentTab() browserInitialized = if (tab != null) { @@ -281,8 +278,8 @@ abstract class BaseBrowserFragment : browserAnimator = BrowserAnimator( fragment = WeakReference(this), - engineView = WeakReference(engineView), - swipeRefresh = WeakReference(swipeRefresh), + engineView = WeakReference(binding.engineView), + swipeRefresh = WeakReference(binding.swipeRefresh), viewLifecycleScope = WeakReference(viewLifecycleOwner.lifecycleScope) ).apply { beginAnimateInIfNecessary() @@ -295,7 +292,7 @@ abstract class BaseBrowserFragment : val readerMenuController = DefaultReaderModeController( readerViewFeature, - view.readerViewControlsBar, + binding.readerViewControlsBar, isPrivate = activity.browsingModeManager.mode.isPrivate, onReaderModeChanged = { activity.finishActionMode() } ) @@ -306,7 +303,7 @@ abstract class BaseBrowserFragment : navController = findNavController(), metrics = requireComponents.analytics.metrics, readerModeController = readerMenuController, - engineView = engineView, + engineView = binding.engineView, homeViewModel = homeViewModel, customTabSessionId = customTabSessionId, onTabCounterClicked = { @@ -326,7 +323,7 @@ abstract class BaseBrowserFragment : } viewLifecycleOwner.lifecycleScope.allowUndo( - requireView().browserLayout, + binding.browserLayout, snackbarMessage, requireContext().getString(R.string.snackbar_deleted_undo), { @@ -346,7 +343,7 @@ abstract class BaseBrowserFragment : readerModeController = readerMenuController, sessionFeature = sessionFeature, findInPageLauncher = { findInPageIntegration.withFeature { it.launch() } }, - swipeRefresh = swipeRefresh, + swipeRefresh = binding.swipeRefresh, browserAnimator = browserAnimator, customTabSessionId = customTabSessionId, openInFenixIntent = openInFenixIntent, @@ -367,7 +364,7 @@ abstract class BaseBrowserFragment : ) _browserToolbarView = BrowserToolbarView( - container = view.browserLayout, + container = binding.browserLayout, toolbarPosition = context.settings().toolbarPosition, interactor = browserToolbarInteractor, customTabSession = customTabSessionId?.let { store.state.findCustomTab(it) }, @@ -384,8 +381,8 @@ abstract class BaseBrowserFragment : feature = FindInPageIntegration( store = store, sessionId = customTabSessionId, - stub = view.stubFindInPage, - engineView = engineView, + stub = binding.stubFindInPage, + engineView = binding.engineView, toolbarInfo = FindInPageIntegration.ToolbarInfo( browserToolbarView.view, !context.settings().shouldUseFixedTopToolbar && context.settings().isDynamicToolbarEnabled, @@ -400,17 +397,12 @@ abstract class BaseBrowserFragment : showQuickSettingsDialog() } - browserToolbarView.view.display.setOnTrackingProtectionClickedListener { - context.metrics.track(Event.TrackingProtectionIconPressed) - showTrackingProtectionPanel() - } - contextMenuFeature.set( feature = ContextMenuFeature( fragmentManager = parentFragmentManager, store = store, - candidates = getContextMenuCandidates(context, view.browserLayout), - engineView = view.engineView, + candidates = getContextMenuCandidates(context, binding.browserLayout), + engineView = binding.engineView, useCases = context.components.useCases.contextMenuUseCases, tabId = customTabSessionId ), @@ -492,14 +484,14 @@ abstract class BaseBrowserFragment : ) val dynamicDownloadDialog = DynamicDownloadDialog( - container = view.browserLayout, + context = context, downloadState = downloadState, didFail = downloadJobStatus == DownloadState.Status.FAILED, tryAgain = downloadFeature::tryAgain, onCannotOpenFile = { - showCannotOpenFileError(view.browserLayout, context, it) + showCannotOpenFileError(binding.browserLayout, context, it) }, - view = view.viewDynamicDownloadDialog, + binding = binding.viewDynamicDownloadDialog, toolbarHeight = toolbarHeight ) { sharedViewModel.downloadDialogState.remove(downloadState.sessionId) } @@ -510,7 +502,7 @@ abstract class BaseBrowserFragment : resumeDownloadDialogState( getCurrentTab()?.id, - store, view, context, toolbarHeight + store, context, toolbarHeight ) shareDownloadsFeature.set( @@ -594,7 +586,7 @@ abstract class BaseBrowserFragment : onNeedToRequestPermissions = { permissions -> requestPermissions(permissions, REQUEST_CODE_PROMPT_PERMISSIONS) }, - loginPickerView = loginSelectBar, + loginPickerView = binding.loginSelectBar, onManageLogins = { browserAnimator.captureEngineViewAndDrawStatically { val directions = @@ -602,7 +594,7 @@ abstract class BaseBrowserFragment : findNavController().navigate(directions) } }, - creditCardPickerView = creditCardSelectBar, + creditCardPickerView = binding.creditCardSelectBar, onManageCreditCards = { val directions = NavGraphDirections.actionGlobalCreditCardsSettingFragment() @@ -620,7 +612,7 @@ abstract class BaseBrowserFragment : feature = SessionFeature( requireComponents.core.store, requireComponents.useCases.sessionUseCases.goBack, - view.engineView, + binding.engineView, customTabSessionId ), owner = this, @@ -726,17 +718,17 @@ abstract class BaseBrowserFragment : .collect { tab -> pipModeChanged(tab) } } - view.swipeRefresh.isEnabled = shouldPullToRefreshBeEnabled(false) + binding.swipeRefresh.isEnabled = shouldPullToRefreshBeEnabled(false) - if (view.swipeRefresh.isEnabled) { + if (binding.swipeRefresh.isEnabled) { val primaryTextColor = ThemeManager.resolveAttribute(R.attr.primaryText, context) - view.swipeRefresh.setColorSchemeColors(primaryTextColor) + binding.swipeRefresh.setColorSchemeColors(primaryTextColor) swipeRefreshFeature.set( feature = SwipeRefreshFeature( requireComponents.core.store, context.components.useCases.sessionUseCases.reload, - view.swipeRefresh, + binding.swipeRefresh, customTabSessionId ), owner = this, @@ -866,7 +858,6 @@ abstract class BaseBrowserFragment : internal fun resumeDownloadDialogState( sessionId: String?, store: BrowserStore, - view: View, context: Context, toolbarHeight: Int ) { @@ -874,7 +865,7 @@ abstract class BaseBrowserFragment : sharedViewModel.downloadDialogState[sessionId] if (savedDownloadState == null || sessionId == null) { - view.viewDynamicDownloadDialog.visibility = View.GONE + binding.viewDynamicDownloadDialog.root.visibility = View.GONE return } @@ -892,14 +883,14 @@ abstract class BaseBrowserFragment : { sharedViewModel.downloadDialogState.remove(sessionId) } DynamicDownloadDialog( - container = view.browserLayout, + context = context, downloadState = savedDownloadState.first, didFail = savedDownloadState.second, tryAgain = onTryAgain, onCannotOpenFile = { - showCannotOpenFileError(view.browserLayout, context, it) + showCannotOpenFileError(binding.browserLayout, context, it) }, - view = view.viewDynamicDownloadDialog, + binding = binding.viewDynamicDownloadDialog, toolbarHeight = toolbarHeight, onDismiss = onDismiss ).show() @@ -1018,13 +1009,13 @@ abstract class BaseBrowserFragment : } if (browserInitialized) { - view?.let { view -> + view?.let { fullScreenChanged(false) browserToolbarView.expand() val toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height) val context = requireContext() - resumeDownloadDialogState(selectedTab.id, context.components.core.store, view, context, toolbarHeight) + resumeDownloadDialogState(selectedTab.id, context.components.core.store, context, toolbarHeight) } } else { view?.let { view -> initializeUI(view) } @@ -1163,8 +1154,6 @@ abstract class BaseBrowserFragment : sitePermissions: SitePermissions? ) - protected abstract fun navToTrackingProtectionPanel(tab: SessionState) - /** * Returns the layout [android.view.Gravity] for the quick settings and ETP dialog. */ @@ -1200,13 +1189,6 @@ abstract class BaseBrowserFragment : } } - private fun showTrackingProtectionPanel() { - val tab = getCurrentTab() ?: return - view?.let { - navToTrackingProtectionPanel(tab) - } - } - /** * Set the activity normal/private theme to match the current session. */ @@ -1245,9 +1227,9 @@ abstract class BaseBrowserFragment : withContext(Main) { requireComponents.analytics.metrics.track(Event.AddBookmark) - view?.let { view -> + view?.let { FenixSnackbar.make( - view = view.browserLayout, + view = binding.browserLayout, duration = FenixSnackbar.LENGTH_LONG, isDisplayedWithBrowserToolbar = true ) @@ -1298,7 +1280,7 @@ abstract class BaseBrowserFragment : // Close find in page bar if opened findInPageIntegration.onBackPressed() FenixSnackbar.make( - view = requireView().browserLayout, + view = binding.browserLayout, duration = Snackbar.LENGTH_SHORT, isDisplayedWithBrowserToolbar = false ) @@ -1307,14 +1289,14 @@ abstract class BaseBrowserFragment : activity?.enterToImmersiveMode() browserToolbarView.collapse() browserToolbarView.view.isVisible = false - val browserEngine = swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams + val browserEngine = binding.swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams browserEngine.bottomMargin = 0 browserEngine.topMargin = 0 - swipeRefresh.translationY = 0f + binding.swipeRefresh.translationY = 0f - engineView.setDynamicToolbarMaxHeight(0) + binding.engineView.setDynamicToolbarMaxHeight(0) // Without this, fullscreen has a margin at the top. - engineView.setVerticalClipping(0) + binding.engineView.setVerticalClipping(0) requireComponents.analytics.metrics.track(Event.MediaFullscreenState) } else { @@ -1330,7 +1312,7 @@ abstract class BaseBrowserFragment : } } - activity?.swipeRefresh?.isEnabled = shouldPullToRefreshBeEnabled(inFullScreen) + binding.swipeRefresh.isEnabled = shouldPullToRefreshBeEnabled(inFullScreen) } /* @@ -1348,6 +1330,7 @@ abstract class BaseBrowserFragment : requireContext().accessibilityManager.removeAccessibilityStateChangeListener(this) _browserToolbarView = null _browserToolbarInteractor = null + _binding = null } override fun onAttach(context: Context) { @@ -1424,13 +1407,13 @@ abstract class BaseBrowserFragment : * Convenience method for replacing EngineView (id/engineView) in unit tests. */ @VisibleForTesting - internal fun getEngineView() = engineView + internal fun getEngineView() = binding.engineView /** * Convenience method for replacing SwipeRefreshLayout (id/swipeRefresh) in unit tests. */ @VisibleForTesting - internal fun getSwipeRefreshLayout() = swipeRefresh + internal fun getSwipeRefreshLayout() = binding.swipeRefresh @VisibleForTesting internal fun shouldShowCompletedDownloadDialog( diff --git a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt index d0e985cfc4..f7cc414321 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt @@ -13,8 +13,6 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.lifecycle.Observer import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.fragment_browser.* -import kotlinx.android.synthetic.main.fragment_browser.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.state.SessionState @@ -33,16 +31,15 @@ import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.TabCollectionStorage +import org.mozilla.fenix.components.toolbar.ToolbarMenu import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.nav -import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.ext.requireComponents -import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.runIfFragmentIsAttached +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.shortcut.PwaOnboardingObserver import org.mozilla.fenix.theme.ThemeManager -import org.mozilla.fenix.trackingprotection.TrackingProtectionOverlay /** * Fragment used for browsing the web within the main app. @@ -53,8 +50,6 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { private val windowFeature = ViewBoundFeatureWrapper() private val openInAppOnboardingObserver = ViewBoundFeatureWrapper() - private val trackingProtectionOverlayObserver = - ViewBoundFeatureWrapper() private var readerModeAvailable = false private var pwaOnboardingObserver: PwaOnboardingObserver? = null @@ -67,11 +62,11 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { val components = context.components if (context.settings().isSwipeToolbarToSwitchTabsEnabled) { - gestureLayout.addGestureListener( + binding.gestureLayout.addGestureListener( ToolbarGestureHandler( activity = requireActivity(), - contentLayout = browserLayout, - tabPreview = tabPreview, + contentLayout = binding.browserLayout, + tabPreview = binding.tabPreview, toolbarLayout = browserToolbarView.view, store = components.core.store, selectTabUseCase = components.useCases.tabsUseCases.selectTab @@ -82,10 +77,10 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { if (FeatureFlags.showHomeButtonFeature) { val homeAction = BrowserToolbar.Button( imageDrawable = AppCompatResources.getDrawable( - requireContext(), + context, R.drawable.mozac_ic_home )!!, - contentDescription = requireContext().getString(R.string.browser_toolbar_home), + contentDescription = context.getString(R.string.browser_toolbar_home), iconTintColorResource = ThemeManager.resolveAttribute(R.attr.primaryText, context), listener = browserToolbarInteractor::onHomeButtonClicked ) @@ -93,19 +88,100 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { browserToolbarView.view.addNavigationAction(homeAction) } + if (resources.getBoolean(R.bool.tablet)) { + val enableTint = ThemeManager.resolveAttribute(R.attr.primaryText, context) + val disableTint = ThemeManager.resolveAttribute(R.attr.disabled, context) + val backAction = BrowserToolbar.TwoStateButton( + primaryImage = AppCompatResources.getDrawable( + context, + R.drawable.mozac_ic_back + )!!, + primaryContentDescription = context.getString(R.string.browser_menu_back), + primaryImageTintResource = enableTint, + isInPrimaryState = { getCurrentTab()?.content?.canGoBack ?: false }, + secondaryImageTintResource = disableTint, + disableInSecondaryState = true, + longClickListener = { + browserToolbarInteractor.onBrowserToolbarMenuItemTapped( + ToolbarMenu.Item.Back(viewHistory = true) + ) + }, + listener = { + browserToolbarInteractor.onBrowserToolbarMenuItemTapped( + ToolbarMenu.Item.Back(viewHistory = false) + ) + } + ) + browserToolbarView.view.addNavigationAction(backAction) + val forwardAction = BrowserToolbar.TwoStateButton( + primaryImage = AppCompatResources.getDrawable( + context, + R.drawable.mozac_ic_forward + )!!, + primaryContentDescription = context.getString(R.string.browser_menu_forward), + primaryImageTintResource = enableTint, + isInPrimaryState = { getCurrentTab()?.content?.canGoForward ?: false }, + secondaryImageTintResource = disableTint, + disableInSecondaryState = true, + longClickListener = { + browserToolbarInteractor.onBrowserToolbarMenuItemTapped( + ToolbarMenu.Item.Forward(viewHistory = true) + ) + }, + listener = { + browserToolbarInteractor.onBrowserToolbarMenuItemTapped( + ToolbarMenu.Item.Forward(viewHistory = false) + ) + } + ) + browserToolbarView.view.addNavigationAction(forwardAction) + val refreshAction = BrowserToolbar.TwoStateButton( + primaryImage = AppCompatResources.getDrawable( + context, + R.drawable.mozac_ic_refresh + )!!, + primaryContentDescription = context.getString(R.string.browser_menu_refresh), + primaryImageTintResource = enableTint, + isInPrimaryState = { + getCurrentTab()?.content?.loading == false + }, + secondaryImage = AppCompatResources.getDrawable( + context, + R.drawable.mozac_ic_stop + )!!, + secondaryContentDescription = context.getString(R.string.browser_menu_stop), + disableInSecondaryState = false, + longClickListener = { + browserToolbarInteractor.onBrowserToolbarMenuItemTapped( + ToolbarMenu.Item.Reload(bypassCache = true) + ) + }, + listener = { + if (getCurrentTab()?.content?.loading == true) { + browserToolbarInteractor.onBrowserToolbarMenuItemTapped(ToolbarMenu.Item.Stop) + } else { + browserToolbarInteractor.onBrowserToolbarMenuItemTapped( + ToolbarMenu.Item.Reload(bypassCache = false) + ) + } + } + ) + browserToolbarView.view.addNavigationAction(refreshAction) + } + val readerModeAction = BrowserToolbar.ToggleButton( image = AppCompatResources.getDrawable( - requireContext(), + context, R.drawable.ic_readermode )!!, imageSelected = AppCompatResources.getDrawable( - requireContext(), + context, R.drawable.ic_readermode_selected )!!, - contentDescription = requireContext().getString(R.string.browser_menu_read), - contentDescriptionSelected = requireContext().getString(R.string.browser_menu_read_close), + contentDescription = context.getString(R.string.browser_menu_read), + contentDescriptionSelected = context.getString(R.string.browser_menu_read_close), visible = { readerModeAvailable }, @@ -118,7 +194,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { browserToolbarView.view.addPageAction(readerModeAction) thumbnailsFeature.set( - feature = BrowserThumbnails(context, view.engineView, components.core.store), + feature = BrowserThumbnails(context, binding.engineView, components.core.store), owner = this, view = view ) @@ -129,7 +205,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { context, components.core.engine, components.core.store, - view.readerViewControlsBar + binding.readerViewControlsBar ) { available, active -> if (available) { components.analytics.metrics.track(Event.ReaderModeAvailable) @@ -162,27 +238,13 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { navController = findNavController(), settings = context.settings(), appLinksUseCases = context.components.useCases.appLinksUseCases, - container = browserLayout as ViewGroup, + container = binding.browserLayout as ViewGroup, shouldScrollWithTopToolbar = !context.settings().shouldUseBottomToolbar ), owner = this, view = view ) } - if (context.settings().shouldShowTrackingProtectionCfr) { - trackingProtectionOverlayObserver.set( - feature = TrackingProtectionOverlay( - context = context, - store = context.components.core.store, - lifecycleOwner = viewLifecycleOwner, - settings = context.settings(), - metrics = context.components.analytics.metrics, - getToolbar = { browserToolbarView.view } - ), - owner = this, - view = view - ) - } } override fun onStart() { @@ -208,9 +270,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { override fun onStop() { super.onStop() updateLastBrowseActivity() - if (requireContext().settings().historyMetadataFeature) { - updateHistoryMetadata() - } + updateHistoryMetadata() pwaOnboardingObserver?.stop() } @@ -241,33 +301,22 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { } override fun navToQuickSettingsSheet(tab: SessionState, sitePermissions: SitePermissions?) { - val directions = - BrowserFragmentDirections.actionBrowserFragmentToQuickSettingsSheetDialogFragment( - sessionId = tab.id, - url = tab.content.url, - title = tab.content.title, - isSecured = tab.content.securityInfo.secure, - sitePermissions = sitePermissions, - gravity = getAppropriateLayoutGravity(), - certificateName = tab.content.securityInfo.issuer, - permissionHighlights = tab.content.permissionHighlights - ) - nav(R.id.browserFragment, directions) - } - - override fun navToTrackingProtectionPanel(tab: SessionState) { - val navController = findNavController() requireComponents.useCases.trackingProtectionUseCases.containsException(tab.id) { contains -> runIfFragmentIsAttached { - val isEnabled = tab.trackingProtection.enabled && !contains + val isTrackingProtectionEnabled = tab.trackingProtection.enabled && !contains val directions = - BrowserFragmentDirections.actionBrowserFragmentToTrackingProtectionPanelDialogFragment( + BrowserFragmentDirections.actionBrowserFragmentToQuickSettingsSheetDialogFragment( sessionId = tab.id, url = tab.content.url, - trackingProtectionEnabled = isEnabled, - gravity = getAppropriateLayoutGravity() + title = tab.content.title, + isSecured = tab.content.securityInfo.secure, + sitePermissions = sitePermissions, + gravity = getAppropriateLayoutGravity(), + certificateName = tab.content.securityInfo.issuer, + permissionHighlights = tab.content.permissionHighlights, + isTrackingProtectionEnabled = isTrackingProtectionEnabled ) - navController.navigateSafe(R.id.browserFragment, directions) + nav(R.id.browserFragment, directions) } } } @@ -302,7 +351,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { } } FenixSnackbar.make( - view = view.browserLayout, + view = binding.browserLayout, duration = Snackbar.LENGTH_SHORT, isDisplayedWithBrowserToolbar = true ) diff --git a/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt b/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt index 346cce7e5f..061cb4df8a 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt @@ -13,11 +13,10 @@ import android.widget.FrameLayout import androidx.appcompat.content.res.AppCompatResources import androidx.core.view.doOnNextLayout import androidx.core.view.updateLayoutParams -import kotlinx.android.synthetic.main.tab_preview.view.* -import kotlinx.android.synthetic.main.tabs_tray_tab_counter.view.* import mozilla.components.browser.thumbnails.loader.ThumbnailLoader import mozilla.components.concept.base.images.ImageLoadRequest import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabPreviewBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings import org.mozilla.fenix.theme.ThemeManager @@ -29,32 +28,30 @@ class TabPreview @JvmOverloads constructor( defStyle: Int = 0 ) : FrameLayout(context, attrs, defStyle) { + private val binding = TabPreviewBinding.inflate(LayoutInflater.from(context), this) private val thumbnailLoader = ThumbnailLoader(context.components.core.thumbnailStorage) init { - val inflater = LayoutInflater.from(context) - inflater.inflate(R.layout.tab_preview, this, true) - if (!context.settings().shouldUseBottomToolbar) { - fakeToolbar.updateLayoutParams { + binding.fakeToolbar.updateLayoutParams { gravity = Gravity.TOP } - fakeToolbar.background = AppCompatResources.getDrawable( + binding.fakeToolbar.background = AppCompatResources.getDrawable( context, ThemeManager.resolveAttribute(R.attr.bottomBarBackgroundTop, context) ) } // Change view properties to avoid confusing the UI tests - tab_button.counter_box.id = View.NO_ID - tab_button.counter_text.id = View.NO_ID + binding.tabButton.findViewById(R.id.counter_box).id = View.NO_ID + binding.tabButton.findViewById(R.id.counter_text).id = View.NO_ID } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) - previewThumbnail.translationY = if (!context.settings().shouldUseBottomToolbar) { - fakeToolbar.height.toFloat() + binding.previewThumbnail.translationY = if (!context.settings().shouldUseBottomToolbar) { + binding.fakeToolbar.height.toFloat() } else { 0f } @@ -62,6 +59,7 @@ class TabPreview @JvmOverloads constructor( fun loadPreviewThumbnail(thumbnailId: String) { doOnNextLayout { + val previewThumbnail = binding.previewThumbnail val thumbnailSize = max(previewThumbnail.height, previewThumbnail.width) thumbnailLoader.loadIntoView( previewThumbnail, diff --git a/app/src/main/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBanner.kt b/app/src/main/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBanner.kt index 83e11465cd..e341634b21 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBanner.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBanner.kt @@ -34,7 +34,7 @@ class DynamicInfoBanner( super.showBanner() if (shouldScrollWithTopToolbar) { - (bannerLayout.layoutParams as CoordinatorLayout.LayoutParams).behavior = DynamicInfoBannerBehavior( + (binding.root.layoutParams as CoordinatorLayout.LayoutParams).behavior = DynamicInfoBannerBehavior( context, null ) } diff --git a/app/src/main/java/org/mozilla/fenix/browser/infobanner/InfoBanner.kt b/app/src/main/java/org/mozilla/fenix/browser/infobanner/InfoBanner.kt index 700803141e..db2bd06465 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/infobanner/InfoBanner.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/infobanner/InfoBanner.kt @@ -10,8 +10,7 @@ import android.view.LayoutInflater import android.view.View.GONE import android.view.ViewGroup import androidx.annotation.VisibleForTesting -import kotlinx.android.synthetic.main.info_banner.view.* -import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.InfoBannerBinding import org.mozilla.fenix.ext.settings /** @@ -40,27 +39,26 @@ open class InfoBanner( ) { @SuppressLint("InflateParams") @VisibleForTesting - internal val bannerLayout = LayoutInflater.from(context) - .inflate(R.layout.info_banner, null) + internal val binding = InfoBannerBinding.inflate(LayoutInflater.from(context), container, false) internal open fun showBanner() { - bannerLayout.banner_info_message.text = message - bannerLayout.dismiss.text = dismissText + binding.bannerInfoMessage.text = message + binding.dismiss.text = dismissText if (actionText.isNullOrEmpty()) { - bannerLayout.action.visibility = GONE + binding.action.visibility = GONE } else { - bannerLayout.action.text = actionText + binding.action.text = actionText } - container.addView(bannerLayout) + container.addView(binding.root) - bannerLayout.dismiss.setOnClickListener { + binding.dismiss.setOnClickListener { dismissAction?.invoke() - if (dismissByHiding) { bannerLayout.visibility = GONE } else { dismiss() } + if (dismissByHiding) { binding.root.visibility = GONE } else { dismiss() } } - bannerLayout.action.setOnClickListener { + binding.action.setOnClickListener { actionToPerform?.invoke() } @@ -68,6 +66,6 @@ open class InfoBanner( } internal fun dismiss() { - container.removeView(bannerLayout) + container.removeView(binding.root) } } diff --git a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationFragment.kt b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationFragment.kt index 012a73c886..ce8caddec8 100644 --- a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationFragment.kt @@ -12,11 +12,11 @@ import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_create_collection.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.lib.state.ext.consumeFrom import org.mozilla.fenix.R import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.databinding.FragmentCreateCollectionBinding import org.mozilla.fenix.ext.requireComponents @ExperimentalCoroutinesApi @@ -25,6 +25,9 @@ class CollectionCreationFragment : DialogFragment() { private lateinit var collectionCreationStore: CollectionCreationStore private lateinit var collectionCreationInteractor: CollectionCreationInteractor + private var _binding: FragmentCreateCollectionBinding? = null + private val binding get() = _binding!! + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) isCancelable = false @@ -35,8 +38,8 @@ class CollectionCreationFragment : DialogFragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_create_collection, container, false) + ): View { + _binding = FragmentCreateCollectionBinding.inflate(inflater, container, false) val args: CollectionCreationFragmentArgs by navArgs() collectionCreationStore = StoreProvider.get(this) { @@ -63,11 +66,11 @@ class CollectionCreationFragment : DialogFragment() { ) ) collectionCreationView = CollectionCreationView( - view.createCollectionWrapper, + binding.createCollectionWrapper, collectionCreationInteractor ) - return view + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -78,6 +81,11 @@ class CollectionCreationFragment : DialogFragment() { } } + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + override fun onResume() { super.onResume() collectionCreationView.onResumed() diff --git a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapter.kt b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapter.kt index e74cc340e8..f5842b3464 100644 --- a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapter.kt @@ -5,14 +5,13 @@ package org.mozilla.fenix.collections import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.collection_tab_list_row.* import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.CollectionTabListRowBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.home.Tab @@ -26,11 +25,16 @@ class CollectionCreationTabListAdapter( private var selectedTabs: MutableSet = mutableSetOf() private var hideCheckboxes = false + private lateinit var binding: CollectionTabListRowBinding + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(TabViewHolder.LAYOUT_ID, parent, false) + binding = CollectionTabListRowBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) - return TabViewHolder(view) + return TabViewHolder(binding) } override fun onBindViewHolder(holder: TabViewHolder, position: Int, payloads: MutableList) { @@ -41,11 +45,11 @@ class CollectionCreationTabListAdapter( is CheckChanged -> { val checkChanged = payloads[0] as CheckChanged if (checkChanged.shouldBeChecked) { - holder.tab_selected_checkbox.isChecked = true + binding.tabSelectedCheckbox.isChecked = true } else if (checkChanged.shouldBeUnchecked) { - holder.tab_selected_checkbox.isChecked = false + binding.tabSelectedCheckbox.isChecked = false } - holder.tab_selected_checkbox.isGone = checkChanged.shouldHideCheckBox + binding.tabSelectedCheckbox.isGone = checkChanged.shouldHideCheckBox } } } @@ -54,7 +58,7 @@ class CollectionCreationTabListAdapter( override fun onBindViewHolder(holder: TabViewHolder, position: Int) { val tab = tabs[position] val isSelected = selectedTabs.contains(tab) - holder.tab_selected_checkbox.setOnCheckedChangeListener { _, isChecked -> + binding.tabSelectedCheckbox.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { selectedTabs.add(tab) interactor.addTabToSelection(tab) @@ -88,24 +92,24 @@ class CollectionCreationTabListAdapter( } } -class TabViewHolder(view: View) : ViewHolder(view) { +class TabViewHolder(private val binding: CollectionTabListRowBinding) : ViewHolder(binding.root) { init { - collection_item_tab.setOnClickListener { - tab_selected_checkbox.isChecked = !tab_selected_checkbox.isChecked + binding.collectionItemTab.setOnClickListener { + binding.tabSelectedCheckbox.isChecked = !binding.tabSelectedCheckbox.isChecked } } fun bind(tab: Tab, isSelected: Boolean, shouldHideCheckBox: Boolean) { - hostname.text = tab.hostname - tab_title.text = tab.title - tab_selected_checkbox.isInvisible = shouldHideCheckBox + binding.hostname.text = tab.hostname + binding.tabTitle.text = tab.title + binding.tabSelectedCheckbox.isInvisible = shouldHideCheckBox itemView.isClickable = !shouldHideCheckBox - if (tab_selected_checkbox.isChecked != isSelected) { - tab_selected_checkbox.isChecked = isSelected + if (binding.tabSelectedCheckbox.isChecked != isSelected) { + binding.tabSelectedCheckbox.isChecked = isSelected } - itemView.context.components.core.icons.loadIntoView(favicon_image, tab.url) + itemView.context.components.core.icons.loadIntoView(binding.faviconImage, tab.url) } companion object { diff --git a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationView.kt b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationView.kt index 34db0f881f..6645082c5d 100644 --- a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationView.kt +++ b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationView.kt @@ -9,7 +9,6 @@ import android.os.Looper import android.text.InputFilter import android.view.KeyEvent import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import androidx.constraintlayout.widget.ConstraintSet @@ -18,31 +17,33 @@ import androidx.recyclerview.widget.RecyclerView import androidx.transition.AutoTransition import androidx.transition.Transition import androidx.transition.TransitionManager -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.component_collection_creation.* import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.components.support.ktx.android.view.showKeyboard import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.ComponentCollectionCreationBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.home.Tab class CollectionCreationView( - container: ViewGroup, + private val container: ViewGroup, private val interactor: CollectionCreationInteractor -) : LayoutContainer { +) { - override val containerView: View = LayoutInflater.from(container.context) - .inflate(R.layout.component_collection_creation, container, true) + private val binding = ComponentCollectionCreationBinding.inflate( + LayoutInflater.from(container.context), + container, + true + ) private val bottomBarView = CollectionCreationBottomBarView( interactor = interactor, - layout = bottom_button_bar_layout, - iconButton = bottom_bar_icon_button, - textView = bottom_bar_text, - saveButton = save_button + layout = binding.bottomButtonBarLayout, + iconButton = binding.bottomBarIconButton, + textView = binding.bottomBarText, + saveButton = binding.saveButton ) private val collectionCreationTabListAdapter = CollectionCreationTabListAdapter(interactor) private val collectionSaveListAdapter = SaveCollectionListAdapter(interactor) @@ -58,10 +59,10 @@ class CollectionCreationView( init { transition.duration = TRANSITION_DURATION - transition.excludeTarget(back_button, true) + transition.excludeTarget(binding.backButton, true) - name_collection_edittext.filters += InputFilter.LengthFilter(COLLECTION_NAME_MAX_LENGTH) - name_collection_edittext.setOnEditorActionListener { view, actionId, _ -> + binding.nameCollectionEdittext.filters += InputFilter.LengthFilter(COLLECTION_NAME_MAX_LENGTH) + binding.nameCollectionEdittext.setOnEditorActionListener { view, actionId, _ -> val text = view.text.toString() if (actionId == EditorInfo.IME_ACTION_DONE && text.isNotBlank()) { when (step) { @@ -75,15 +76,15 @@ class CollectionCreationView( false } - tab_list.run { + binding.tabList.run { adapter = collectionCreationTabListAdapter itemAnimator = null - layoutManager = LinearLayoutManager(containerView.context, RecyclerView.VERTICAL, true) + layoutManager = LinearLayoutManager(container.context, RecyclerView.VERTICAL, true) } - collections_list.run { + binding.collectionsList.run { adapter = collectionSaveListAdapter - layoutManager = LinearLayoutManager(containerView.context, RecyclerView.VERTICAL, true) + layoutManager = LinearLayoutManager(container.context, RecyclerView.VERTICAL, true) } } @@ -109,18 +110,18 @@ class CollectionCreationView( } private fun updateForSelectTabs(state: CollectionCreationState) { - containerView.context.components.analytics.metrics.track(Event.CollectionTabSelectOpened) + container.context.components.analytics.metrics.track(Event.CollectionTabSelectOpened) - tab_list.isClickable = true + binding.tabList.isClickable = true - back_button.apply { + binding.backButton.apply { text = context.getString(R.string.create_collection_select_tabs) setOnClickListener { interactor.onBackPressed(SaveCollectionStep.SelectTabs) } } - select_all_button.apply { + binding.selectAllButton.apply { val allSelected = state.selectedTabs.size == state.tabs.size text = if (allSelected) context.getString(R.string.create_collection_deselect_all) @@ -132,43 +133,43 @@ class CollectionCreationView( } selectTabsConstraints.clone( - containerView.context, + container.context, R.layout.component_collection_creation ) collectionCreationTabListAdapter.updateData(state.tabs, state.selectedTabs) - selectTabsConstraints.applyTo(collection_constraint_layout) + selectTabsConstraints.applyTo(binding.collectionConstraintLayout) } private fun updateForSelectCollection() { - tab_list.isClickable = false + binding.tabList.isClickable = false selectCollectionConstraints.clone( - containerView.context, + container.context, R.layout.component_collection_creation_select_collection ) - selectCollectionConstraints.applyTo(collection_constraint_layout) + selectCollectionConstraints.applyTo(binding.collectionConstraintLayout) - back_button.apply { + binding.backButton.apply { text = context.getString(R.string.create_collection_select_collection) setOnClickListener { interactor.onBackPressed(SaveCollectionStep.SelectCollection) } } - TransitionManager.beginDelayedTransition(collection_constraint_layout, transition) + TransitionManager.beginDelayedTransition(binding.collectionConstraintLayout, transition) } private fun updateForNameCollection(state: CollectionCreationState) { - tab_list.isClickable = false + binding.tabList.isClickable = false nameCollectionConstraints.clone( - containerView.context, + container.context, R.layout.component_collection_creation_name_collection ) - nameCollectionConstraints.applyTo(collection_constraint_layout) + nameCollectionConstraints.applyTo(binding.collectionConstraintLayout) collectionCreationTabListAdapter.updateData(state.selectedTabs.toList(), state.selectedTabs, true) - back_button.apply { + binding.backButton.apply { text = context.getString(R.string.create_collection_name_collection) setOnClickListener { - name_collection_edittext.hideKeyboard() + binding.nameCollectionEdittext.hideKeyboard() val handler = Handler(Looper.getMainLooper()) handler.postDelayed( { @@ -179,22 +180,22 @@ class CollectionCreationView( } } - name_collection_edittext.showKeyboard() + binding.nameCollectionEdittext.showKeyboard() - name_collection_edittext.setText( - containerView.context.getString( + binding.nameCollectionEdittext.setText( + container.context.getString( R.string.create_collection_default_name, state.defaultCollectionNumber ) ) - name_collection_edittext.setSelection(0, name_collection_edittext.text.length) + binding.nameCollectionEdittext.setSelection(0, binding.nameCollectionEdittext.text.length) } private fun updateForRenameCollection(state: CollectionCreationState) { - tab_list.isClickable = false + binding.tabList.isClickable = false state.selectedTabCollection?.let { tabCollection -> - val publicSuffixList = containerView.context.components.publicSuffixList + val publicSuffixList = container.context.components.publicSuffixList tabCollection.tabs.map { tab -> Tab( sessionId = tab.id.toString(), @@ -207,17 +208,17 @@ class CollectionCreationView( } } nameCollectionConstraints.clone( - containerView.context, + container.context, R.layout.component_collection_creation_name_collection ) - nameCollectionConstraints.applyTo(collection_constraint_layout) - name_collection_edittext.setText(state.selectedTabCollection?.title) - name_collection_edittext.setSelection(0, name_collection_edittext.text.length) + nameCollectionConstraints.applyTo(binding.collectionConstraintLayout) + binding.nameCollectionEdittext.setText(state.selectedTabCollection?.title) + binding.nameCollectionEdittext.setSelection(0, binding.nameCollectionEdittext.text.length) - back_button.apply { + binding.backButton.apply { text = context.getString(R.string.collection_rename) setOnClickListener { - name_collection_edittext.hideKeyboard() + binding.nameCollectionEdittext.hideKeyboard() val handler = Handler(Looper.getMainLooper()) handler.postDelayed( { @@ -231,7 +232,7 @@ class CollectionCreationView( override fun onTransitionStart(transition: Transition) { /* noop */ } override fun onTransitionEnd(transition: Transition) { - name_collection_edittext.showKeyboard() + binding.nameCollectionEdittext.showKeyboard() transition.removeListener(this) } @@ -239,12 +240,12 @@ class CollectionCreationView( override fun onTransitionPause(transition: Transition) { /* noop */ } override fun onTransitionResume(transition: Transition) { /* noop */ } }) - TransitionManager.beginDelayedTransition(collection_constraint_layout, transition) + TransitionManager.beginDelayedTransition(binding.collectionConstraintLayout, transition) } fun onResumed() { if (step == SaveCollectionStep.NameCollection || step == SaveCollectionStep.RenameCollection) { - name_collection_edittext.showKeyboard() + binding.nameCollectionEdittext.showKeyboard() } } diff --git a/app/src/main/java/org/mozilla/fenix/collections/SaveCollectionListAdapter.kt b/app/src/main/java/org/mozilla/fenix/collections/SaveCollectionListAdapter.kt index 0ee663b53d..c7dcc0c9a8 100644 --- a/app/src/main/java/org/mozilla/fenix/collections/SaveCollectionListAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/collections/SaveCollectionListAdapter.kt @@ -5,15 +5,14 @@ package org.mozilla.fenix.collections import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.core.graphics.BlendModeColorFilterCompat.createBlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat.SRC_IN import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.collections_list_item.* import mozilla.components.feature.tab.collections.TabCollection import org.mozilla.fenix.R import org.mozilla.fenix.components.description +import org.mozilla.fenix.databinding.CollectionsListItemBinding import org.mozilla.fenix.ext.getIconColor import org.mozilla.fenix.home.Tab import org.mozilla.fenix.utils.view.ViewHolder @@ -26,10 +25,13 @@ class SaveCollectionListAdapter( private var selectedTabs: Set = setOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CollectionViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(CollectionViewHolder.LAYOUT_ID, parent, false) + val binding = CollectionsListItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) - return CollectionViewHolder(view) + return CollectionViewHolder(binding) } override fun onBindViewHolder(holder: CollectionViewHolder, position: Int) { @@ -49,12 +51,12 @@ class SaveCollectionListAdapter( } } -class CollectionViewHolder(view: View) : ViewHolder(view) { +class CollectionViewHolder(private val binding: CollectionsListItemBinding) : ViewHolder(binding.root) { fun bind(collection: TabCollection) { - collection_item.text = collection.title - collection_description.text = collection.description(itemView.context) - collection_icon.colorFilter = + binding.collectionItem.text = collection.title + binding.collectionDescription.text = collection.description(itemView.context) + binding.collectionIcon.colorFilter = createBlendModeColorFilterCompat(collection.getIconColor(itemView.context), SRC_IN) } diff --git a/app/src/main/java/org/mozilla/fenix/components/Analytics.kt b/app/src/main/java/org/mozilla/fenix/components/Analytics.kt index af365fd00c..c617856698 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Analytics.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Analytics.kt @@ -22,6 +22,7 @@ import org.mozilla.fenix.ReleaseChannel import org.mozilla.fenix.components.metrics.AdjustMetricsService import org.mozilla.fenix.components.metrics.GleanMetricsService import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.experiments.NimbusFeatures import org.mozilla.fenix.experiments.createNimbus import org.mozilla.fenix.ext.settings import org.mozilla.fenix.perf.lazyMonitored @@ -103,6 +104,10 @@ class Analytics( val experiments: NimbusApi by lazyMonitored { createNimbus(context, BuildConfig.NIMBUS_ENDPOINT) } + + val features: NimbusFeatures by lazyMonitored { + NimbusFeatures(context) + } } fun isSentryEnabled() = !BuildConfig.SENTRY_TOKEN.isNullOrEmpty() diff --git a/app/src/main/java/org/mozilla/fenix/components/Core.kt b/app/src/main/java/org/mozilla/fenix/components/Core.kt index e39c04bad0..1606b4c54c 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Core.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Core.kt @@ -59,6 +59,9 @@ import mozilla.components.service.digitalassetlinks.local.StatementApi import mozilla.components.service.digitalassetlinks.local.StatementRelationChecker import mozilla.components.service.location.LocationService import mozilla.components.service.location.MozillaLocationService +import mozilla.components.service.pocket.Frequency +import mozilla.components.service.pocket.PocketStoriesConfig +import mozilla.components.service.pocket.PocketStoriesService import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage import mozilla.components.service.sync.logins.SyncableLoginsStorage import mozilla.components.support.locale.LocaleManager @@ -84,6 +87,8 @@ import org.mozilla.fenix.telemetry.TelemetryMiddleware import org.mozilla.fenix.utils.Mockable import org.mozilla.fenix.utils.getUndoDelay import org.mozilla.geckoview.GeckoRuntime +import java.lang.IllegalStateException +import java.util.concurrent.TimeUnit /** * Component group for all core browser functionality. @@ -208,13 +213,10 @@ class Core( RecordingDevicesMiddleware(context), PromptMiddleware(), AdsTelemetryMiddleware(adsTelemetry), - LastMediaAccessMiddleware() + LastMediaAccessMiddleware(), + HistoryMetadataMiddleware(historyMetadataService) ) - if (context.settings().historyMetadataFeature) { - middlewareList += HistoryMetadataMiddleware(historyMetadataService) - } - BrowserStore( middleware = middlewareList + EngineMiddleware.create(engine) ).apply { @@ -252,9 +254,7 @@ class Core( * The [HistoryMetadataService] is used to record history metadata. */ val historyMetadataService: HistoryMetadataService by lazyMonitored { - DefaultHistoryMetadataService(storage = historyStorage).apply { - cleanup(System.currentTimeMillis() - HISTORY_METADATA_MAX_AGE_IN_MS) - } + DefaultHistoryMetadataService(storage = historyStorage) } /** @@ -322,6 +322,12 @@ class Core( val pinnedSiteStorage by lazyMonitored { PinnedSiteStorage(context) } + @Suppress("MagicNumber") + val pocketStoriesConfig by lazyMonitored { + PocketStoriesConfig(client, Frequency(4, TimeUnit.HOURS)) + } + val pocketStoriesService by lazyMonitored { PocketStoriesService(context, pocketStoriesConfig) } + val topSitesStorage by lazyMonitored { val defaultTopSites = mutableListOf>() @@ -348,6 +354,13 @@ class Core( SupportUtils.PDD_URL ) ) + + defaultTopSites.add( + Pair( + context.getString(R.string.default_top_site_tc), + SupportUtils.TC_URL + ) + ) } else { defaultTopSites.add( Pair( @@ -444,6 +457,6 @@ class Core( private const val KEY_STORAGE_NAME = "core_prefs" private const val PASSWORDS_KEY = "passwords" private const val RECENTLY_CLOSED_MAX = 10 - private const val HISTORY_METADATA_MAX_AGE_IN_MS = 14 * 24 * 60 * 60 * 1000 // 14 days + const val HISTORY_METADATA_MAX_AGE_IN_MS = 14 * 24 * 60 * 60 * 1000 // 14 days } } diff --git a/app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt b/app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt index 2d4948f044..68244c7723 100644 --- a/app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt +++ b/app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt @@ -18,8 +18,8 @@ import androidx.core.widget.TextViewCompat import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.ContentViewCallback import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.fenix_snackbar.view.* import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FenixSnackbarBinding import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.settings import org.mozilla.fenix.utils.Mockable @@ -27,20 +27,20 @@ import org.mozilla.fenix.utils.Mockable @Mockable class FenixSnackbar private constructor( parent: ViewGroup, - content: View, + private val binding: FenixSnackbarBinding, contentViewCallback: FenixSnackbarCallback, isError: Boolean -) : BaseTransientBottomBar(parent, content, contentViewCallback) { +) : BaseTransientBottomBar(parent, binding.root, contentViewCallback) { init { view.setBackgroundColor(Color.TRANSPARENT) setAppropriateBackground(isError) - content.snackbar_btn.increaseTapArea(actionButtonIncreaseDps) + binding.snackbarBtn.increaseTapArea(actionButtonIncreaseDps) TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( - content.snackbar_text, + binding.snackbarText, minTextSize, maxTextSize, stepGranularity, @@ -49,7 +49,7 @@ class FenixSnackbar private constructor( } fun setAppropriateBackground(isError: Boolean) { - view.snackbar_layout.background = if (isError) { + binding.snackbarLayout.background = if (isError) { AppCompatResources.getDrawable(context, R.drawable.fenix_snackbar_error_background) } else { AppCompatResources.getDrawable(context, R.drawable.fenix_snackbar_background) @@ -57,7 +57,7 @@ class FenixSnackbar private constructor( } fun setText(text: String) = this.apply { - view.snackbar_text.text = text + binding.snackbarText.text = text } fun setLength(duration: Int) = this.apply { @@ -65,7 +65,7 @@ class FenixSnackbar private constructor( } fun setAction(text: String, action: () -> Unit) = this.apply { - view.snackbar_btn.apply { + binding.snackbarBtn.apply { setText(text) visibility = View.VISIBLE setOnClickListener { @@ -110,7 +110,7 @@ class FenixSnackbar private constructor( } val inflater = LayoutInflater.from(parent.context) - val content = inflater.inflate(R.layout.fenix_snackbar, parent, false) + val binding = FenixSnackbarBinding.inflate(inflater, parent, false) val durationOrAccessibleDuration = if (parent.context.settings().accessibilityServicesEnabled) { @@ -119,12 +119,12 @@ class FenixSnackbar private constructor( duration } - val callback = FenixSnackbarCallback(content) + val callback = FenixSnackbarCallback(binding.root) val shouldUseBottomToolbar = view.context.settings().shouldUseBottomToolbar val toolbarHeight = view.resources.getDimensionPixelSize(R.dimen.browser_toolbar_height) val dynamicToolbarEnabled = view.context.settings().isDynamicToolbarEnabled - return FenixSnackbar(parent, content, callback, isError).also { + return FenixSnackbar(parent, binding, callback, isError).also { it.duration = durationOrAccessibleDuration it.view.updatePadding( diff --git a/app/src/main/java/org/mozilla/fenix/components/TabCollectionStorage.kt b/app/src/main/java/org/mozilla/fenix/components/TabCollectionStorage.kt index da337195b1..4950feb9ef 100644 --- a/app/src/main/java/org/mozilla/fenix/components/TabCollectionStorage.kt +++ b/app/src/main/java/org/mozilla/fenix/components/TabCollectionStorage.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.state.createTab import mozilla.components.feature.tab.collections.Tab import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollectionStorage @@ -68,6 +69,15 @@ class TabCollectionStorage( } } + suspend fun createCollection(tabCollection: TabCollection): Long? { + return withContext(ioScope.coroutineContext) { + val sessions = tabCollection.tabs.map { createTab(url = it.url, title = it.title) } + val id = collectionStorage.createCollection(tabCollection.title, sessions) + notifyObservers { onCollectionCreated(tabCollection.title, sessions, id) } + id + } + } + suspend fun addTabsToCollection(tabCollection: TabCollection, sessions: List): Long? { return withContext(ioScope.coroutineContext) { val id = collectionStorage.addTabsToCollection(tabCollection, sessions) diff --git a/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt b/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt index 32f943936c..2f6760e2e7 100644 --- a/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt +++ b/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt @@ -4,51 +4,195 @@ package org.mozilla.fenix.components.history -import mozilla.components.concept.storage.HistoryStorage +import mozilla.components.browser.storage.sync.PlacesHistoryStorage import mozilla.components.concept.storage.VisitInfo import mozilla.components.concept.storage.VisitType +import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl +import org.mozilla.fenix.FeatureFlags +import org.mozilla.fenix.library.history.History +import org.mozilla.fenix.library.history.toHistoryMetadata import org.mozilla.fenix.perf.runBlockingIncrement +import kotlin.math.abs + +private const val BUFFER_TIME = 15000 /* 15 seconds in ms */ /** - * An Interface for providing a paginated list of [VisitInfo] + * An Interface for providing a paginated list of [History]. */ interface PagedHistoryProvider { /** - * Gets a list of [VisitInfo] + * Gets a list of [History]. + * * @param offset How much to offset the list by * @param numberOfItems How many items to fetch - * @param onComplete A callback that returns the list of [VisitInfo] + * @param onComplete A callback that returns the list of [History] */ - fun getHistory(offset: Long, numberOfItems: Long, onComplete: (List) -> Unit) + fun getHistory(offset: Long, numberOfItems: Long, onComplete: (List) -> Unit) } -// A PagedList DataSource runs on a background thread automatically. -// If we run this in our own coroutineScope it breaks the PagedList -fun HistoryStorage.createSynchronousPagedHistoryProvider(): PagedHistoryProvider { - return object : PagedHistoryProvider { - - override fun getHistory( - offset: Long, - numberOfItems: Long, - onComplete: (List) -> Unit - ) { - runBlockingIncrement { - val history = getVisitsPaginated( - offset, - numberOfItems, - excludeTypes = listOf( - VisitType.NOT_A_VISIT, - VisitType.DOWNLOAD, - VisitType.REDIRECT_TEMPORARY, - VisitType.RELOAD, - VisitType.EMBED, - VisitType.FRAMED_LINK, - VisitType.REDIRECT_PERMANENT +/** + * @param historyStorage + */ +class DefaultPagedHistoryProvider( + private val historyStorage: PlacesHistoryStorage, + private val showHistorySearchGroups: Boolean = FeatureFlags.showHistorySearchGroups, +) : PagedHistoryProvider { + + @Volatile private var historyGroups: List? = null + + @Suppress("LongMethod") + override fun getHistory( + offset: Long, + numberOfItems: Long, + onComplete: (List) -> Unit, + ) { + // A PagedList DataSource runs on a background thread automatically. + // If we run this in our own coroutineScope it breaks the PagedList + runBlockingIncrement { + val history: List + + if (showHistorySearchGroups) { + // We need to refetch all the history metadata if the offset resets back at 0 + // in the case of a pull to refresh. + if (historyGroups == null || offset == 0L) { + historyGroups = historyStorage.getHistoryMetadataSince(Long.MIN_VALUE) + .sortedByDescending { it.createdAt } + .filter { it.key.searchTerm != null } + .groupBy { it.key.searchTerm!! } + .map { (searchTerm, items) -> + History.Group( + id = items.first().createdAt.toInt(), + title = searchTerm, + visitedAt = items.first().createdAt, + items = items.map { it.toHistoryMetadata() } + ) + } + } + + history = getHistoryAndSearchGroups(offset, numberOfItems) + } else { + history = historyStorage + .getVisitsPaginated( + offset, + numberOfItems, + excludeTypes = listOf( + VisitType.NOT_A_VISIT, + VisitType.DOWNLOAD, + VisitType.REDIRECT_TEMPORARY, + VisitType.RELOAD, + VisitType.EMBED, + VisitType.FRAMED_LINK, + VisitType.REDIRECT_PERMANENT + ) ) + .mapIndexed(transformVisitInfoToHistoryItem(offset.toInt())) + } + + onComplete(history) + } + } + + /** + * Returns the [History.Regular] corresponding to the given [History.Metadata] item. + * + * @param historyMetadata The [History.Metadata] to match. + * @return the [History.Regular] corresponding to the given [History.Metadata] item or null. + */ + suspend fun getMatchingHistory(historyMetadata: History.Metadata): VisitInfo? { + val history = historyStorage.getDetailedVisits( + start = historyMetadata.visitedAt - BUFFER_TIME, + end = historyMetadata.visitedAt + BUFFER_TIME, + excludeTypes = listOf( + VisitType.NOT_A_VISIT, + VisitType.DOWNLOAD, + VisitType.REDIRECT_TEMPORARY, + VisitType.RELOAD, + VisitType.EMBED, + VisitType.FRAMED_LINK, + VisitType.REDIRECT_PERMANENT + ) + ) + return history + .filter { it.url == historyMetadata.url } + .minByOrNull { abs(historyMetadata.visitedAt - it.visitTime) } + } + + /** + * Clears the history groups to refetch the most history metadata after any changes. + */ + fun clearHistoryGroups() { + historyGroups = null + } + + @Suppress("MagicNumber") + private suspend fun getHistoryAndSearchGroups( + offset: Long, + numberOfItems: Long, + ): List { + val result = mutableListOf() + val history: List = historyStorage + .getVisitsPaginated( + offset, + numberOfItems, + excludeTypes = listOf( + VisitType.NOT_A_VISIT, + VisitType.DOWNLOAD, + VisitType.REDIRECT_TEMPORARY, + VisitType.RELOAD, + VisitType.EMBED, + VisitType.FRAMED_LINK, + VisitType.REDIRECT_PERMANENT ) + ) + .mapIndexed(transformVisitInfoToHistoryItem(offset.toInt())) + + // History metadata items are recorded after their associated visited info, we add an + // additional buffer time to the most recent visit to account for a history group + // appearing as the most recent item. + val visitedAtBuffer = if (offset == 0L) BUFFER_TIME else 0 - onComplete(history) + // Get the history groups that fit within the range of visited times in the current history + // items. + val historyGroupsInOffset = if (history.isNotEmpty()) { + historyGroups?.filter { + history.last().visitedAt <= it.visitedAt - visitedAtBuffer && + it.visitedAt - visitedAtBuffer <= (history.first().visitedAt + visitedAtBuffer) + } ?: emptyList() + } else { + emptyList() + } + val historyMetadata = historyGroupsInOffset.flatMap { it.items } + + // Add all history items that are not in a group filtering out any matches with a history + // metadata item. + result.addAll(history.filter { item -> historyMetadata.find { it.url == item.url } == null }) + + // Filter history metadata items with no view time and dedupe by url. + // Note that distinctBy is sufficient here as it keeps the order of the source + // collection, and we're only sorting by visitedAt (=updatedAt) currently. + // If we needed the view time we'd have to aggregate it for entries with the same + // url, but we don't have a use case for this currently in the history view. + result.addAll( + historyGroupsInOffset.map { group -> + group.copy(items = group.items.distinctBy { it.url }) } + ) + + return result.sortedByDescending { it.visitedAt } + } + + private fun transformVisitInfoToHistoryItem(offset: Int): (id: Int, visit: VisitInfo) -> History.Regular { + return { id, visit -> + val title = visit.title + ?.takeIf(String::isNotEmpty) + ?: visit.url.tryGetHostFromUrl() + + History.Regular( + id = offset + id, + title = title, + url = visit.url, + visitedAt = visit.visitTime + ) } } } diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt index ed9bb43dc2..a45b7e6e22 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt @@ -17,10 +17,14 @@ import org.mozilla.fenix.GleanMetrics.ContextMenu import org.mozilla.fenix.GleanMetrics.CrashReporter import org.mozilla.fenix.GleanMetrics.ErrorPage import org.mozilla.fenix.GleanMetrics.Events +import org.mozilla.fenix.GleanMetrics.History import org.mozilla.fenix.GleanMetrics.Logins import org.mozilla.fenix.GleanMetrics.Onboarding +import org.mozilla.fenix.GleanMetrics.Pocket +import org.mozilla.fenix.GleanMetrics.Preferences import org.mozilla.fenix.GleanMetrics.ProgressiveWebApp import org.mozilla.fenix.GleanMetrics.SearchShortcuts +import org.mozilla.fenix.GleanMetrics.TabsTray import org.mozilla.fenix.GleanMetrics.ToolbarSettings import org.mozilla.fenix.GleanMetrics.TopSites import org.mozilla.fenix.GleanMetrics.TrackingProtection @@ -78,6 +82,9 @@ sealed class Event { object HistoryOpenedInPrivateTabs : Event() object HistoryItemRemoved : Event() object HistoryAllItemsRemoved : Event() + data class HistoryRecentSearchesTapped(val source: String) : Event() { + override val extras = mapOf(History.recentSearchesTappedKeys.pageNumber to source) + } object ReaderModeAvailable : Event() object ReaderModeOpened : Event() object ReaderModeClosed : Event() @@ -105,11 +112,14 @@ sealed class Event { object NotificationMediaPause : Event() object TopSiteOpenDefault : Event() object TopSiteOpenGoogle : Event() + object TopSiteOpenBaidu : Event() object TopSiteOpenFrecent : Event() object TopSiteOpenPinned : Event() object TopSiteOpenInNewTab : Event() object TopSiteOpenInPrivateTab : Event() object TopSiteRemoved : Event() + object GoogleTopSiteRemoved : Event() + object BaiduTopSiteRemoved : Event() object TrackingProtectionTrackerList : Event() object TrackingProtectionIconPressed : Event() object TrackingProtectionSettingsPanel : Event() @@ -125,14 +135,43 @@ sealed class Event { object WhatsNewTapped : Event() object PocketTopSiteClicked : Event() object PocketTopSiteRemoved : Event() + object PocketHomeRecsShown : Event() + object PocketHomeRecsDiscoverMoreClicked : Event() + object PocketHomeRecsLearnMoreClicked : Event() + data class PocketHomeRecsStoryClicked( + val timesShown: Long, + val storyPosition: Pair, + ) : Event() { + override val extras: Map + get() = mapOf( + Pocket.homeRecsStoryClickedKeys.timesShown to timesShown.toString(), + Pocket.homeRecsStoryClickedKeys.position to "${storyPosition.first}x${storyPosition.second}" + ) + } + + data class PocketHomeRecsCategoryClicked( + val categoryname: String, + val previousSelectedCategoriesTotal: Int, + val isSelectedNextState: Boolean + ) : Event() { + override val extras: Map + get() = mapOf( + Pocket.homeRecsCategoryClickedKeys.categoryName to categoryname, + Pocket.homeRecsCategoryClickedKeys.selectedTotal to previousSelectedCategoriesTotal.toString(), + Pocket.homeRecsCategoryClickedKeys.newState to when (isSelectedNextState) { + true -> "selected" + false -> "deselected" + } + ) + } object FennecToFenixMigrated : Event() object AddonsOpenInSettings : Event() + object StudiesSettings : Event() object VoiceSearchTapped : Event() object SearchWidgetInstalled : Event() object OnboardingAutoSignIn : Event() object OnboardingManualSignIn : Event() object OnboardingPrivacyNotice : Event() - object OnboardingPrivateBrowsing : Event() object OnboardingFinish : Event() object ChangedToDefaultBrowser : Event() object DefaultBrowserNotifTapped : Event() @@ -142,16 +181,15 @@ sealed class Event { object LoginDialogPromptSave : Event() object LoginDialogPromptNeverSave : Event() - object ContextualHintETPDisplayed : Event() - object ContextualHintETPDismissed : Event() - object ContextualHintETPOutsideTap : Event() - object ContextualHintETPInsideTap : Event() - // Tab tray object TabsTrayOpened : Event() object TabsTrayClosed : Event() - object OpenedExistingTab : Event() - object ClosedExistingTab : Event() + data class OpenedExistingTab(val source: String) : Event() { + override val extras = mapOf(TabsTray.openedExistingTabKeys.source to source) + } + data class ClosedExistingTab(val source: String) : Event() { + override val extras = mapOf(TabsTray.closedExistingTabKeys.source to source) + } object TabsTrayPrivateModeTapped : Event() object TabsTrayNormalModeTapped : Event() object TabsTraySyncedModeTapped : Event() @@ -161,6 +199,24 @@ sealed class Event { object TabsTraySaveToCollectionPressed : Event() object TabsTrayShareAllTabsPressed : Event() object TabsTrayCloseAllTabsPressed : Event() + object TabsTrayRecentlyClosedPressed : Event() + object TabsTrayInactiveTabsExpanded : Event() + object TabsTrayInactiveTabsCollapsed : Event() + object TabsTrayAutoCloseDialogSeen : Event() + object TabsTrayAutoCloseDialogTurnOnClicked : Event() + object TabsTrayAutoCloseDialogDismissed : Event() + data class TabsTrayHasInactiveTabs(val count: Int) : Event() { + override val extras = mapOf(TabsTray.hasInactiveTabsKeys.inactiveTabsCount to count.toString()) + } + object TabsTrayCloseAllInactiveTabs : Event() + data class TabsTrayCloseInactiveTab(val amountClosed: Int = 1) : Event() + object TabsTrayOpenInactiveTab : Event() + + object InactiveTabsSurveyOpened : Event() + data class InactiveTabsOffSurvey(val feedback: String) : Event() { + override val extras: Map + get() = mapOf(Preferences.turnOffInactiveTabsSurveyKeys.feedback to feedback.lowercase(Locale.ROOT)) + } object ProgressiveWebAppOpenFromHomescreenTap : Event() object ProgressiveWebAppInstallAsShortcut : Event() @@ -198,6 +254,7 @@ sealed class Event { // Home menu interaction object HomeMenuSettingsItemClicked : Event() object HomeScreenDisplayed : Event() + object HomeScreenCustomizedHomeClicked : Event() // Browser Toolbar object BrowserToolbarHomeButtonClicked : Event() @@ -210,6 +267,17 @@ sealed class Event { object ShowAllRecentTabs : Event() object OpenRecentTab : Event() object OpenInProgressMediaTab : Event() + object RecentTabsSectionIsVisible : Event() + object RecentTabsSectionIsNotVisible : Event() + + // Recent bookmarks + object BookmarkClicked : Event() + object ShowAllBookmarks : Event() + object RecentBookmarksShown : Event() + data class RecentBookmarkCount(val count: Int) : Event() + + // Recently visited/Recent searches + object RecentSearchesGroupDeleted : Event() // Android Autofill object AndroidAutofillUnlockSuccessful : Event() @@ -221,6 +289,18 @@ sealed class Event { object AndroidAutofillRequestWithLogins : Event() object AndroidAutofillRequestWithoutLogins : Event() + // Credit cards + object CreditCardSaved : Event() + object CreditCardDeleted : Event() + object CreditCardModified : Event() + object CreditCardFormDetected : Event() + object CreditCardAutofilled : Event() + object CreditCardAutofillPromptShown : Event() + object CreditCardAutofillPromptExpanded : Event() + object CreditCardAutofillPromptDismissed : Event() + object CreditCardManagementAddTapped : Event() + object CreditCardManagementCardTapped : Event() + // Interaction events with extras data class TopSiteSwipeCarousel(val page: Int) : Event() { @@ -317,6 +397,31 @@ sealed class Event { } } + data class CustomizeHomePreferenceToggled( + val preferenceKey: String, + val enabled: Boolean, + val context: Context + ) : Event() { + private val telemetryAllowMap = mapOf( + context.getString(R.string.pref_key_enable_top_frecent_sites) to "most_visited_sites", + context.getString(R.string.pref_key_recent_tabs) to "jump_back_in", + context.getString(R.string.pref_key_recent_bookmarks) to "recently_saved", + context.getString(R.string.pref_key_history_metadata_feature) to "recently_visited", + context.getString(R.string.pref_key_pocket_homescreen_recommendations) to "pocket", + ) + + override val extras: Map + get() = mapOf( + Events.preferenceToggledKeys.preferenceKey to (telemetryAllowMap[preferenceKey] ?: ""), + Events.preferenceToggledKeys.enabled to enabled.toString() + ) + + init { + // If the event is not in the allow list, we don't want to track it + require(telemetryAllowMap.contains(preferenceKey)) + } + } + data class AddonsOpenInToolbarMenu(val addonId: String) : Event() { override val extras: Map? get() = hashMapOf(Addons.openAddonInToolbarMenuKeys.addonId to addonId) diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt index 57800892a6..2063361168 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt @@ -18,10 +18,11 @@ import org.mozilla.fenix.GleanMetrics.BookmarksManagement import org.mozilla.fenix.GleanMetrics.BrowserSearch import org.mozilla.fenix.GleanMetrics.Collections import org.mozilla.fenix.GleanMetrics.ContextMenu -import org.mozilla.fenix.GleanMetrics.ContextualHintTrackingProtection import org.mozilla.fenix.GleanMetrics.ContextualMenu import org.mozilla.fenix.GleanMetrics.CrashReporter +import org.mozilla.fenix.GleanMetrics.CreditCards import org.mozilla.fenix.GleanMetrics.CustomTab +import org.mozilla.fenix.GleanMetrics.CustomizeHome import org.mozilla.fenix.GleanMetrics.ErrorPage import org.mozilla.fenix.GleanMetrics.Events import org.mozilla.fenix.GleanMetrics.ExperimentsDefaultBrowser @@ -36,8 +37,11 @@ import org.mozilla.fenix.GleanMetrics.Metrics import org.mozilla.fenix.GleanMetrics.Onboarding import org.mozilla.fenix.GleanMetrics.Pings import org.mozilla.fenix.GleanMetrics.Pocket +import org.mozilla.fenix.GleanMetrics.Preferences import org.mozilla.fenix.GleanMetrics.ProgressiveWebApp import org.mozilla.fenix.GleanMetrics.ReaderMode +import org.mozilla.fenix.GleanMetrics.RecentBookmarks +import org.mozilla.fenix.GleanMetrics.RecentSearches import org.mozilla.fenix.GleanMetrics.RecentTabs import org.mozilla.fenix.GleanMetrics.SearchShortcuts import org.mozilla.fenix.GleanMetrics.SearchWidget @@ -280,6 +284,10 @@ private val Event.wrapper: EventWrapper<*>? { Events.preferenceToggled.record(it) }, { Events.preferenceToggledKeys.valueOf(it) } ) + is Event.CustomizeHomePreferenceToggled -> EventWrapper( + { CustomizeHome.preferenceToggled.record(it) }, + { CustomizeHome.preferenceToggledKeys.valueOf(it) } + ) is Event.HistoryOpened -> EventWrapper( { History.opened.record(it) } ) @@ -307,6 +315,10 @@ private val Event.wrapper: EventWrapper<*>? is Event.HistoryAllItemsRemoved -> EventWrapper( { History.removedAll.record(it) } ) + is Event.HistoryRecentSearchesTapped -> EventWrapper( + { History.recentSearchesTapped.record(it) }, + { History.recentSearchesTappedKeys.valueOf(it) } + ) is Event.CollectionRenamed -> EventWrapper( { Collections.renamed.record(it) } ) @@ -455,6 +467,9 @@ private val Event.wrapper: EventWrapper<*>? is Event.TopSiteOpenGoogle -> EventWrapper( { TopSites.openGoogleSearchAttribution.record(it) } ) + is Event.TopSiteOpenBaidu -> EventWrapper( + { TopSites.openBaiduSearchAttribution.record(it) } + ) is Event.TopSiteOpenFrecent -> EventWrapper( { TopSites.openFrecency.record(it) } ) @@ -470,6 +485,12 @@ private val Event.wrapper: EventWrapper<*>? is Event.TopSiteRemoved -> EventWrapper( { TopSites.remove.record(it) } ) + is Event.GoogleTopSiteRemoved -> EventWrapper( + { TopSites.googleTopSiteRemoved.record(it) } + ) + is Event.BaiduTopSiteRemoved -> EventWrapper( + { TopSites.baiduTopSiteRemoved.record(it) } + ) is Event.TopSiteLongPress -> EventWrapper( { TopSites.longPress.record(it) }, { TopSites.longPressKeys.valueOf(it) } @@ -484,6 +505,23 @@ private val Event.wrapper: EventWrapper<*>? is Event.PocketTopSiteRemoved -> EventWrapper( { Pocket.pocketTopSiteRemoved.record(it) } ) + is Event.PocketHomeRecsShown -> EventWrapper( + { Pocket.homeRecsShown.record(it) } + ) + is Event.PocketHomeRecsLearnMoreClicked -> EventWrapper( + { Pocket.homeRecsLearnMoreClicked.record(it) } + ) + is Event.PocketHomeRecsDiscoverMoreClicked -> EventWrapper( + { Pocket.homeRecsDiscoverClicked.record(it) } + ) + is Event.PocketHomeRecsStoryClicked -> EventWrapper( + { Pocket.homeRecsStoryClicked.record(it) }, + { Pocket.homeRecsStoryClickedKeys.valueOf(it) } + ) + is Event.PocketHomeRecsCategoryClicked -> EventWrapper( + { Pocket.homeRecsCategoryClicked.record(it) }, + { Pocket.homeRecsCategoryClickedKeys.valueOf(it) } + ) is Event.DarkThemeSelected -> EventWrapper( { AppTheme.darkThemeSelected.record(it) }, { AppTheme.darkThemeSelectedKeys.valueOf(it) } @@ -491,6 +529,9 @@ private val Event.wrapper: EventWrapper<*>? is Event.AddonsOpenInSettings -> EventWrapper( { Addons.openAddonsInSettings.record(it) } ) + is Event.StudiesSettings -> EventWrapper( + { Preferences.studiesPreferenceEnabled.record(it) } + ) is Event.AddonsOpenInToolbarMenu -> EventWrapper( { Addons.openAddonInToolbarMenu.record(it) }, { Addons.openAddonInToolbarMenuKeys.valueOf(it) } @@ -506,9 +547,6 @@ private val Event.wrapper: EventWrapper<*>? { Events.tabCounterMenuAction.record(it) }, { Events.tabCounterMenuActionKeys.valueOf(it) } ) - is Event.OnboardingPrivateBrowsing -> EventWrapper( - { Onboarding.prefToggledPrivateBrowsing.record(it) } - ) is Event.OnboardingPrivacyNotice -> EventWrapper( { Onboarding.privacyNotice.record(it) } ) @@ -534,33 +572,19 @@ private val Event.wrapper: EventWrapper<*>? { Onboarding.prefToggledToolbarPositionKeys.valueOf(it) } ) - is Event.ContextualHintETPDisplayed -> EventWrapper( - { ContextualHintTrackingProtection.display.record(it) } - ) - - is Event.ContextualHintETPDismissed -> EventWrapper( - { ContextualHintTrackingProtection.dismiss.record(it) } - ) - - is Event.ContextualHintETPInsideTap -> EventWrapper( - { ContextualHintTrackingProtection.insideTap.record(it) } - ) - - is Event.ContextualHintETPOutsideTap -> EventWrapper( - { ContextualHintTrackingProtection.outsideTap.record(it) } - ) - is Event.TabsTrayOpened -> EventWrapper( { TabsTray.opened.record(it) } ) is Event.TabsTrayClosed -> EventWrapper( { TabsTray.closed.record(it) } ) - is Event.OpenedExistingTab -> EventWrapper( - { TabsTray.openedExistingTab.record(it) } + is Event.OpenedExistingTab -> EventWrapper( + { TabsTray.openedExistingTab.record(it) }, + { TabsTray.openedExistingTabKeys.valueOf(it) } ) - is Event.ClosedExistingTab -> EventWrapper( - { TabsTray.closedExistingTab.record(it) } + is Event.ClosedExistingTab -> EventWrapper( + { TabsTray.closedExistingTab.record(it) }, + { TabsTray.closedExistingTabKeys.valueOf(it) } ) is Event.TabsTrayPrivateModeTapped -> EventWrapper( { TabsTray.privateModeTapped.record(it) } @@ -589,6 +613,44 @@ private val Event.wrapper: EventWrapper<*>? is Event.TabsTrayCloseAllTabsPressed -> EventWrapper( { TabsTray.closeAllTabs.record(it) } ) + is Event.TabsTrayRecentlyClosedPressed -> EventWrapper( + { TabsTray.inactiveTabsRecentlyClosed.record(it) } + ) + is Event.TabsTrayInactiveTabsExpanded -> EventWrapper( + { TabsTray.inactiveTabsExpanded.record(it) } + ) + is Event.TabsTrayInactiveTabsCollapsed -> EventWrapper( + { TabsTray.inactiveTabsCollapsed.record(it) } + ) + is Event.TabsTrayAutoCloseDialogDismissed -> EventWrapper( + { TabsTray.autoCloseDimissed.record(it) } + ) + is Event.TabsTrayAutoCloseDialogSeen -> EventWrapper( + { TabsTray.autoCloseSeen.record(it) } + ) + is Event.TabsTrayAutoCloseDialogTurnOnClicked -> EventWrapper( + { TabsTray.autoCloseTurnOnClicked.record(it) } + ) + is Event.TabsTrayHasInactiveTabs -> EventWrapper( + { TabsTray.hasInactiveTabs.record(it) }, + { TabsTray.hasInactiveTabsKeys.valueOf(it) } + ) + is Event.TabsTrayCloseAllInactiveTabs -> EventWrapper( + { TabsTray.closeAllInactiveTabs.record(it) } + ) + is Event.TabsTrayCloseInactiveTab -> EventWrapper( + { TabsTray.closeInactiveTab.add(amountClosed) } + ) + is Event.TabsTrayOpenInactiveTab -> EventWrapper( + { TabsTray.openInactiveTab.add() } + ) + is Event.InactiveTabsSurveyOpened -> EventWrapper( + { Preferences.inactiveTabsSurveyOpened.record(it) } + ) + is Event.InactiveTabsOffSurvey -> EventWrapper( + { Preferences.turnOffInactiveTabsSurvey.record(it) }, + { Preferences.turnOffInactiveTabsSurveyKeys.valueOf(it) } + ) is Event.AutoPlaySettingVisited -> EventWrapper( { Autoplay.visitedSetting.record(it) } ) @@ -704,6 +766,9 @@ private val Event.wrapper: EventWrapper<*>? is Event.HomeScreenDisplayed -> EventWrapper( { HomeScreen.homeScreenDisplayed.record(it) } ) + is Event.HomeScreenCustomizedHomeClicked -> EventWrapper( + { HomeScreen.customizeHomeClicked.record(it) } + ) is Event.TabViewSettingChanged -> EventWrapper( { Events.tabViewChanged.record(it) }, { Events.tabViewChangedKeys.valueOf(it) } @@ -733,6 +798,34 @@ private val Event.wrapper: EventWrapper<*>? { RecentTabs.showAllClicked.record(it) } ) + is Event.RecentTabsSectionIsVisible -> EventWrapper( + { RecentTabs.sectionVisible.set(true) } + ) + + is Event.RecentTabsSectionIsNotVisible -> EventWrapper( + { RecentTabs.sectionVisible.set(false) } + ) + + is Event.BookmarkClicked -> EventWrapper( + { RecentBookmarks.bookmarkClicked.add() } + ) + + is Event.ShowAllBookmarks -> EventWrapper( + { RecentBookmarks.showAllBookmarks.add() } + ) + + is Event.RecentSearchesGroupDeleted -> EventWrapper( + { RecentSearches.groupDeleted.record(it) } + ) + + is Event.RecentBookmarksShown -> EventWrapper( + { RecentBookmarks.shown.record(it) } + ) + + is Event.RecentBookmarkCount -> EventWrapper( + { RecentBookmarks.recentBookmarksCount.set(this.count.toLong()) }, + ) + is Event.AndroidAutofillRequestWithLogins -> EventWrapper( { AndroidAutofill.requestMatchingLogins.record(it) } ) @@ -757,6 +850,36 @@ private val Event.wrapper: EventWrapper<*>? is Event.AndroidAutofillConfirmationSuccessful -> EventWrapper( { AndroidAutofill.confirmSuccessful.record(it) } ) + is Event.CreditCardSaved -> EventWrapper( + { CreditCards.saved.add() } + ) + is Event.CreditCardDeleted -> EventWrapper( + { CreditCards.deleted.add() } + ) + is Event.CreditCardModified -> EventWrapper( + { CreditCards.modified.record(it) } + ) + is Event.CreditCardFormDetected -> EventWrapper( + { CreditCards.formDetected.record(it) } + ) + is Event.CreditCardAutofillPromptShown -> EventWrapper( + { CreditCards.autofillPromptShown.record(it) } + ) + is Event.CreditCardAutofillPromptExpanded -> EventWrapper( + { CreditCards.autofillPromptExpanded.record(it) } + ) + is Event.CreditCardAutofillPromptDismissed -> EventWrapper( + { CreditCards.autofillPromptDismissed.record(it) } + ) + is Event.CreditCardAutofilled -> EventWrapper( + { CreditCards.autofilled.record(it) } + ) + is Event.CreditCardManagementAddTapped -> EventWrapper( + { CreditCards.managementAddTapped.record(it) } + ) + is Event.CreditCardManagementCardTapped -> EventWrapper( + { CreditCards.managementCardTapped.record(it) } + ) // Don't record other events in Glean: is Event.AddBookmark -> null diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricController.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricController.kt index 3a4387b094..ca29647344 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricController.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricController.kt @@ -5,9 +5,9 @@ package org.mozilla.fenix.components.metrics import androidx.annotation.VisibleForTesting -import mozilla.components.browser.awesomebar.facts.BrowserAwesomeBarFacts import mozilla.components.browser.menu.facts.BrowserMenuFacts import mozilla.components.browser.toolbar.facts.ToolbarFacts +import mozilla.components.compose.browser.awesomebar.AwesomeBarFacts as ComposeAwesomeBarFacts import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.feature.autofill.facts.AutofillFacts import mozilla.components.feature.awesomebar.facts.AwesomeBarFacts @@ -19,7 +19,8 @@ import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider import mozilla.components.feature.contextmenu.facts.ContextMenuFacts import mozilla.components.feature.customtabs.CustomTabsFacts import mozilla.components.feature.media.facts.MediaFacts -import mozilla.components.feature.prompts.facts.LoginDialogFacts +import mozilla.components.feature.prompts.dialog.LoginDialogFacts +import mozilla.components.feature.prompts.facts.CreditCardAutofillDialogFacts import mozilla.components.feature.pwa.ProgressiveWebAppFacts import mozilla.components.feature.search.telemetry.ads.AdsTelemetry import mozilla.components.feature.search.telemetry.incontent.InContentTelemetry @@ -156,17 +157,27 @@ internal class ReleaseMetricController( MetricServiceType.Marketing -> isMarketingDataTelemetryEnabled() } - @Suppress("LongMethod") - private fun Fact.toEvent(): Event? = when (Pair(component, item)) { - Component.FEATURE_PROMPTS to LoginDialogFacts.Items.DISPLAY -> Event.LoginDialogPromptDisplayed - Component.FEATURE_PROMPTS to LoginDialogFacts.Items.CANCEL -> Event.LoginDialogPromptCancelled - Component.FEATURE_PROMPTS to LoginDialogFacts.Items.NEVER_SAVE -> Event.LoginDialogPromptNeverSave - Component.FEATURE_PROMPTS to LoginDialogFacts.Items.SAVE -> Event.LoginDialogPromptSave + @Suppress("LongMethod", "MaxLineLength") + private fun Fact.toEvent(): Event? = when { + Component.FEATURE_PROMPTS == component && LoginDialogFacts.Items.DISPLAY == item -> Event.LoginDialogPromptDisplayed + Component.FEATURE_PROMPTS == component && LoginDialogFacts.Items.CANCEL == item -> Event.LoginDialogPromptCancelled + Component.FEATURE_PROMPTS == component && LoginDialogFacts.Items.NEVER_SAVE == item -> Event.LoginDialogPromptNeverSave + Component.FEATURE_PROMPTS == component && LoginDialogFacts.Items.SAVE == item -> Event.LoginDialogPromptSave + Component.FEATURE_PROMPTS == component && CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_FORM_DETECTED == item -> + Event.CreditCardFormDetected + Component.FEATURE_PROMPTS == component && CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_SUCCESS == item -> + Event.CreditCardAutofilled + Component.FEATURE_PROMPTS == component && CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_PROMPT_SHOWN == item -> + Event.CreditCardAutofillPromptShown + Component.FEATURE_PROMPTS == component && CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_PROMPT_EXPANDED == item -> + Event.CreditCardAutofillPromptExpanded + Component.FEATURE_PROMPTS == component && CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_PROMPT_DISMISSED == item -> + Event.CreditCardAutofillPromptDismissed - Component.FEATURE_CONTEXTMENU to ContextMenuFacts.Items.ITEM -> { + Component.FEATURE_CONTEXTMENU == component && ContextMenuFacts.Items.ITEM == item -> { metadata?.get("item")?.let { Event.ContextMenuItemTapped.create(it.toString()) } } - Component.FEATURE_CONTEXTMENU to ContextMenuFacts.Items.TEXT_SELECTION_OPTION -> { + Component.FEATURE_CONTEXTMENU == component && ContextMenuFacts.Items.TEXT_SELECTION_OPTION == item -> { when (metadata?.get("textSelectionOption")?.toString()) { CONTEXT_MENU_COPY -> Event.ContextMenuCopyTapped CONTEXT_MENU_SEARCH, CONTEXT_MENU_SEARCH_PRIVATELY -> Event.ContextMenuSearchTapped @@ -176,23 +187,23 @@ internal class ReleaseMetricController( } } - Component.BROWSER_TOOLBAR to ToolbarFacts.Items.MENU -> { + Component.BROWSER_TOOLBAR == component && ToolbarFacts.Items.MENU == item -> { metadata?.get("customTab")?.let { Event.CustomTabsMenuOpened } ?: Event.ToolbarMenuShown } - Component.BROWSER_MENU to BrowserMenuFacts.Items.WEB_EXTENSION_MENU_ITEM -> { + Component.BROWSER_MENU == component && BrowserMenuFacts.Items.WEB_EXTENSION_MENU_ITEM == item -> { metadata?.get("id")?.let { Event.AddonsOpenInToolbarMenu(it.toString()) } } - Component.FEATURE_CUSTOMTABS to CustomTabsFacts.Items.CLOSE -> Event.CustomTabsClosed - Component.FEATURE_CUSTOMTABS to CustomTabsFacts.Items.ACTION_BUTTON -> Event.CustomTabsActionTapped + Component.FEATURE_CUSTOMTABS == component && CustomTabsFacts.Items.CLOSE == item -> Event.CustomTabsClosed + Component.FEATURE_CUSTOMTABS == component && CustomTabsFacts.Items.ACTION_BUTTON == item -> Event.CustomTabsActionTapped - Component.FEATURE_MEDIA to MediaFacts.Items.NOTIFICATION -> { + Component.FEATURE_MEDIA == component && MediaFacts.Items.NOTIFICATION == item -> { when (action) { Action.PLAY -> Event.NotificationMediaPlay Action.PAUSE -> Event.NotificationMediaPause else -> null } } - Component.FEATURE_MEDIA to MediaFacts.Items.STATE -> { + Component.FEATURE_MEDIA == component && MediaFacts.Items.STATE == item -> { when (action) { Action.PLAY -> Event.MediaPlayState Action.PAUSE -> Event.MediaPauseState @@ -200,7 +211,7 @@ internal class ReleaseMetricController( else -> null } } - Component.SUPPORT_WEBEXTENSIONS to WebExtensionFacts.Items.WEB_EXTENSIONS_INITIALIZED -> { + Component.SUPPORT_WEBEXTENSIONS == component && WebExtensionFacts.Items.WEB_EXTENSIONS_INITIALIZED == item -> { metadata?.get("installed")?.let { installedAddons -> if (installedAddons is List<*>) { settings.installedAddonsCount = installedAddons.size @@ -217,8 +228,8 @@ internal class ReleaseMetricController( null } - Component.BROWSER_AWESOMEBAR to BrowserAwesomeBarFacts.Items.PROVIDER_DURATION -> { - metadata?.get(BrowserAwesomeBarFacts.MetadataKeys.DURATION_PAIR)?.let { providerTiming -> + Component.BROWSER_AWESOMEBAR == component && ComposeAwesomeBarFacts.Items.PROVIDER_DURATION == item -> { + metadata?.get(ComposeAwesomeBarFacts.MetadataKeys.DURATION_PAIR)?.let { providerTiming -> require(providerTiming is Pair<*, *>) { "Expected providerTiming to be a Pair" } when (val provider = providerTiming.first as AwesomeBar.SuggestionProvider) { is HistoryStorageSuggestionProvider -> PerfAwesomebar.historySuggestions @@ -236,13 +247,13 @@ internal class ReleaseMetricController( } null } - Component.FEATURE_PWA to ProgressiveWebAppFacts.Items.HOMESCREEN_ICON_TAP -> { + Component.FEATURE_PWA == component && ProgressiveWebAppFacts.Items.HOMESCREEN_ICON_TAP == item -> { Event.ProgressiveWebAppOpenFromHomescreenTap } - Component.FEATURE_PWA to ProgressiveWebAppFacts.Items.INSTALL_SHORTCUT -> { + Component.FEATURE_PWA == component && ProgressiveWebAppFacts.Items.INSTALL_SHORTCUT == item -> { Event.ProgressiveWebAppInstallAsShortcut } - Component.FEATURE_TOP_SITES to TopSitesFacts.Items.COUNT -> { + Component.FEATURE_TOP_SITES == component && TopSitesFacts.Items.COUNT == item -> { value?.let { var count = 0 try { @@ -255,59 +266,59 @@ internal class ReleaseMetricController( } null } - Component.FEATURE_SYNCEDTABS to SyncedTabsFacts.Items.SYNCED_TABS_SUGGESTION_CLICKED -> { + Component.FEATURE_SYNCEDTABS == component && SyncedTabsFacts.Items.SYNCED_TABS_SUGGESTION_CLICKED == item -> { Event.SyncedTabSuggestionClicked } - Component.FEATURE_AWESOMEBAR to AwesomeBarFacts.Items.BOOKMARK_SUGGESTION_CLICKED -> { + Component.FEATURE_AWESOMEBAR == component && AwesomeBarFacts.Items.BOOKMARK_SUGGESTION_CLICKED == item -> { Event.BookmarkSuggestionClicked } - Component.FEATURE_AWESOMEBAR to AwesomeBarFacts.Items.CLIPBOARD_SUGGESTION_CLICKED -> { + Component.FEATURE_AWESOMEBAR == component && AwesomeBarFacts.Items.CLIPBOARD_SUGGESTION_CLICKED == item -> { Event.ClipboardSuggestionClicked } - Component.FEATURE_AWESOMEBAR to AwesomeBarFacts.Items.HISTORY_SUGGESTION_CLICKED -> { + Component.FEATURE_AWESOMEBAR == component && AwesomeBarFacts.Items.HISTORY_SUGGESTION_CLICKED == item -> { Event.HistorySuggestionClicked } - Component.FEATURE_AWESOMEBAR to AwesomeBarFacts.Items.SEARCH_ACTION_CLICKED -> { + Component.FEATURE_AWESOMEBAR == component && AwesomeBarFacts.Items.SEARCH_ACTION_CLICKED == item -> { Event.SearchActionClicked } - Component.FEATURE_AWESOMEBAR to AwesomeBarFacts.Items.SEARCH_SUGGESTION_CLICKED -> { + Component.FEATURE_AWESOMEBAR == component && AwesomeBarFacts.Items.SEARCH_SUGGESTION_CLICKED == item -> { Event.SearchSuggestionClicked } - Component.FEATURE_AWESOMEBAR to AwesomeBarFacts.Items.OPENED_TAB_SUGGESTION_CLICKED -> { + Component.FEATURE_AWESOMEBAR == component && AwesomeBarFacts.Items.OPENED_TAB_SUGGESTION_CLICKED == item -> { Event.OpenedTabSuggestionClicked } - Component.LIB_DATAPROTECT to SecurePrefsReliabilityExperiment.Companion.Actions.EXPERIMENT -> { + Component.LIB_DATAPROTECT == component && SecurePrefsReliabilityExperiment.Companion.Actions.EXPERIMENT == item -> { Event.SecurePrefsExperimentFailure(metadata?.get("javaClass") as String? ?: "null") } - Component.LIB_DATAPROTECT to SecurePrefsReliabilityExperiment.Companion.Actions.GET -> { + Component.LIB_DATAPROTECT == component && SecurePrefsReliabilityExperiment.Companion.Actions.GET == item -> { if (SecurePrefsReliabilityExperiment.Companion.Values.FAIL.v == value?.toInt()) { Event.SecurePrefsGetFailure(metadata?.get("javaClass") as String? ?: "null") } else { Event.SecurePrefsGetSuccess(value ?: "") } } - Component.LIB_DATAPROTECT to SecurePrefsReliabilityExperiment.Companion.Actions.WRITE -> { + Component.LIB_DATAPROTECT == component && SecurePrefsReliabilityExperiment.Companion.Actions.WRITE == item -> { if (SecurePrefsReliabilityExperiment.Companion.Values.FAIL.v == value?.toInt()) { Event.SecurePrefsWriteFailure(metadata?.get("javaClass") as String? ?: "null") } else { Event.SecurePrefsWriteSuccess } } - Component.LIB_DATAPROTECT to SecurePrefsReliabilityExperiment.Companion.Actions.RESET -> { + Component.LIB_DATAPROTECT == component && SecurePrefsReliabilityExperiment.Companion.Actions.RESET == item -> { Event.SecurePrefsReset } - Component.FEATURE_SEARCH to AdsTelemetry.SERP_ADD_CLICKED -> { + Component.FEATURE_SEARCH == component && AdsTelemetry.SERP_ADD_CLICKED == item -> { Event.SearchAdClicked(value!!) } - Component.FEATURE_SEARCH to AdsTelemetry.SERP_SHOWN_WITH_ADDS -> { + Component.FEATURE_SEARCH == component && AdsTelemetry.SERP_SHOWN_WITH_ADDS == item -> { Event.SearchWithAds(value!!) } - Component.FEATURE_SEARCH to InContentTelemetry.IN_CONTENT_SEARCH -> { + Component.FEATURE_SEARCH == component && InContentTelemetry.IN_CONTENT_SEARCH == item -> { Event.SearchInContent(value!!) } - Component.FEATURE_AUTOFILL to AutofillFacts.Items.AUTOFILL_REQUEST -> { + Component.FEATURE_AUTOFILL == component && AutofillFacts.Items.AUTOFILL_REQUEST == item -> { val hasMatchingLogins = metadata?.get(AutofillFacts.Metadata.HAS_MATCHING_LOGINS) as Boolean? if (hasMatchingLogins == true) { Event.AndroidAutofillRequestWithLogins @@ -315,21 +326,21 @@ internal class ReleaseMetricController( Event.AndroidAutofillRequestWithoutLogins } } - Component.FEATURE_AUTOFILL to AutofillFacts.Items.AUTOFILL_SEARCH -> { + Component.FEATURE_AUTOFILL == component && AutofillFacts.Items.AUTOFILL_SEARCH == item -> { if (action == Action.SELECT) { Event.AndroidAutofillSearchItemSelected } else { Event.AndroidAutofillSearchDisplayed } } - Component.FEATURE_AUTOFILL to AutofillFacts.Items.AUTOFILL_LOCK -> { + Component.FEATURE_AUTOFILL == component && AutofillFacts.Items.AUTOFILL_LOCK == item -> { if (action == Action.CONFIRM) { Event.AndroidAutofillUnlockSuccessful } else { Event.AndroidAutofillUnlockCanceled } } - Component.FEATURE_AUTOFILL to AutofillFacts.Items.AUTOFILL_CONFIRMATION -> { + Component.FEATURE_AUTOFILL == component && AutofillFacts.Items.AUTOFILL_CONFIRMATION == item -> { if (action == Action.CONFIRM) { Event.AndroidAutofillConfirmationSuccessful } else { diff --git a/app/src/main/java/org/mozilla/fenix/components/settings/FeatureFlagPreference.kt b/app/src/main/java/org/mozilla/fenix/components/settings/FeatureFlagPreference.kt index 0df122c5f4..e363b67b64 100644 --- a/app/src/main/java/org/mozilla/fenix/components/settings/FeatureFlagPreference.kt +++ b/app/src/main/java/org/mozilla/fenix/components/settings/FeatureFlagPreference.kt @@ -23,3 +23,26 @@ fun featureFlagPreference(key: String, default: Boolean, featureFlag: Boolean) = } else { DummyProperty() } + +private class LazyPreference(val key: String, val default: () -> Boolean) : + ReadWriteProperty { + private val property: ReadWriteProperty by lazy { + booleanPreference(key, default()) + } + + override fun getValue(thisRef: PreferencesHolder, property: KProperty<*>) = + this.property.getValue(thisRef, property) + + override fun setValue(thisRef: PreferencesHolder, property: KProperty<*>, value: Boolean) = + this.property.setValue(thisRef, property, value) +} + +/** + * Property delegate for getting and setting lazily a boolean shared preference gated by a feature flag. + */ +fun lazyFeatureFlagPreference(key: String, featureFlag: Boolean, default: () -> Boolean) = + if (featureFlag) { + LazyPreference(key, default) + } else { + DummyProperty() + } diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt index 2686f04114..9e1b84b769 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt @@ -92,7 +92,11 @@ class DefaultBrowserToolbarController( override fun handleToolbarClick() { metrics.track(Event.SearchBarTapped(Event.SearchBarTapped.Source.BROWSER)) - if (FeatureFlags.showHomeBehindSearch) { + // If we're displaying awesomebar search results, Home screen will not be visible (it's + // covered up with the search results). So, skip the navigation event in that case. + // If we don't, there's a visual flickr as we navigate to Home and then display search + // results on top it. + if (FeatureFlags.showHomeBehindSearch && currentSession?.content?.searchTerms.isNullOrBlank()) { navController.navigate( BrowserFragmentDirections.actionGlobalHome() ) diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarView.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarView.kt index f7e40f0507..026252d03d 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarView.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarView.kt @@ -4,6 +4,7 @@ package org.mozilla.fenix.components.toolbar +import android.graphics.Color import android.view.HapticFeedbackConstants import android.view.LayoutInflater import android.view.View @@ -13,7 +14,6 @@ import androidx.annotation.VisibleForTesting import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner -import kotlinx.android.extensions.LayoutContainer import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.domains.autocomplete.ShippedDomainsProvider import mozilla.components.browser.state.selector.selectedTab @@ -43,10 +43,7 @@ class BrowserToolbarView( private val interactor: BrowserToolbarInteractor, private val customTabSession: CustomTabSessionState?, private val lifecycleOwner: LifecycleOwner -) : LayoutContainer { - - override val containerView: View? - get() = container +) { private val settings = container.context.settings() @@ -127,7 +124,7 @@ class BrowserToolbarView( display.colors = display.colors.copy( text = primaryTextColor, securityIconSecure = primaryTextColor, - securityIconInsecure = primaryTextColor, + securityIconInsecure = Color.TRANSPARENT, menu = primaryTextColor, hint = secondaryTextColor, separator = separatorColor, diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/DefaultToolbarMenu.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/DefaultToolbarMenu.kt index f39d95d1b9..01e6c28b33 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/DefaultToolbarMenu.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/DefaultToolbarMenu.kt @@ -179,7 +179,7 @@ open class DefaultToolbarMenu( val installToHomescreen = BrowserMenuHighlightableItem( label = context.getString(R.string.browser_menu_install_on_homescreen), - startImageResource = R.drawable.ic_add_to_homescreen, + startImageResource = R.drawable.mozac_ic_add_to_home_screen, iconTintColorResource = primaryTextColor(), highlight = BrowserMenuHighlight.LowPriority( label = context.getString(R.string.browser_menu_install_on_homescreen), @@ -266,7 +266,7 @@ open class DefaultToolbarMenu( val addToHomeScreenItem = BrowserMenuImageText( label = context.getString(R.string.browser_menu_add_to_homescreen), - imageResource = R.drawable.ic_add_to_homescreen, + imageResource = R.drawable.mozac_ic_add_to_home_screen, iconTintColorResource = primaryTextColor(), isCollapsingMenuLimit = true ) { @@ -291,7 +291,7 @@ open class DefaultToolbarMenu( val settingsItem = BrowserMenuHighlightableItem( label = context.getString(R.string.browser_menu_settings), - startImageResource = R.drawable.ic_settings, + startImageResource = R.drawable.mozac_ic_settings, iconTintColorResource = if (hasAccountProblem) ThemeManager.resolveAttribute(R.attr.syncDisconnected, context) else primaryTextColor(), diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarIntegration.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarIntegration.kt index 5a248bf122..c0dd32aeb7 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarIntegration.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarIntegration.kt @@ -5,8 +5,6 @@ package org.mozilla.fenix.components.toolbar import android.content.Context -import android.content.res.Configuration -import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -104,49 +102,10 @@ class DefaultToolbarIntegration( toolbar.display.menuBuilder = toolbarMenu.menuBuilder toolbar.private = isPrivate - val drawable = - if (isPrivate) AppCompatResources.getDrawable( - context, - R.drawable.shield_dark - ) else when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { - Configuration.UI_MODE_NIGHT_UNDEFINED, // We assume light here per Android doc's recommendation - Configuration.UI_MODE_NIGHT_NO -> { - AppCompatResources.getDrawable(context, R.drawable.shield_light) - } - Configuration.UI_MODE_NIGHT_YES -> { - AppCompatResources.getDrawable(context, R.drawable.shield_dark) - } - else -> AppCompatResources.getDrawable(context, R.drawable.shield_light) - } - - toolbar.display.indicators = - if (context.settings().shouldUseTrackingProtection) { - listOf( - DisplayToolbar.Indicators.TRACKING_PROTECTION, - DisplayToolbar.Indicators.SECURITY, - DisplayToolbar.Indicators.EMPTY, - DisplayToolbar.Indicators.HIGHLIGHT - ) - } else { - listOf( - DisplayToolbar.Indicators.SECURITY, - DisplayToolbar.Indicators.EMPTY, - DisplayToolbar.Indicators.HIGHLIGHT - ) - } - context.settings().shouldUseTrackingProtection - - toolbar.display.icons = toolbar.display.icons.copy( - emptyIcon = null, - trackingProtectionTrackersBlocked = drawable!!, - trackingProtectionNothingBlocked = AppCompatResources.getDrawable( - context, - R.drawable.ic_tracking_protection_enabled - )!!, - trackingProtectionException = AppCompatResources.getDrawable( - context, - R.drawable.ic_tracking_protection_disabled - )!! + toolbar.display.indicators = listOf( + DisplayToolbar.Indicators.SECURITY, + DisplayToolbar.Indicators.EMPTY, + DisplayToolbar.Indicators.HIGHLIGHT ) val tabCounterMenu = FenixTabCounterMenu( @@ -154,8 +113,7 @@ class DefaultToolbarIntegration( onItemTapped = { interactor.onTabCounterMenuItemTapped(it) }, - iconColor = - if (isPrivate) { + iconColor = if (isPrivate) { ContextCompat.getColor(context, R.color.primary_text_private_theme) } else { null diff --git a/app/src/main/java/org/mozilla/fenix/compose/ClickableSubstringLink.kt b/app/src/main/java/org/mozilla/fenix/compose/ClickableSubstringLink.kt new file mode 100644 index 0000000000..89f27519e2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/ClickableSubstringLink.kt @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import mozilla.components.ui.colors.PhotonColors + +/** + * [Text] containing a substring styled as an URL informing when this is clicked. + * + * @param text Full text that will be displayed + * @param textColor [Color] of the normal text. The URL substring will have a default URL style applied. + * @param clickableStartIndex [text] index at which the URL substring starts. + * @param clickableEndIndex [text] index at which the URL substring ends. + * @param onClick Callback to be invoked only when the URL substring is clicked. + */ +@Composable +fun ClickableSubstringLink( + text: String, + textColor: Color, + clickableStartIndex: Int, + clickableEndIndex: Int, + onClick: () -> Unit +) { + val annotatedText = buildAnnotatedString { + append(text) + + addStyle( + SpanStyle(textColor), + start = 0, + end = clickableStartIndex + ) + + addStyle( + SpanStyle( + color = when (isSystemInDarkTheme()) { + true -> PhotonColors.Violet40 + false -> PhotonColors.Violet70 + } + ), + start = clickableStartIndex, + end = clickableEndIndex + ) + + addStyle( + SpanStyle(textColor), + start = clickableEndIndex, + end = text.length + ) + + addStyle( + SpanStyle(fontSize = 12.sp), + start = 0, + end = clickableEndIndex + ) + + addStringAnnotation( + tag = "link", + annotation = "", + start = clickableStartIndex, + end = clickableEndIndex + ) + } + + ClickableText( + text = annotatedText, + onClick = { + annotatedText + .getStringAnnotations("link", it, it) + .firstOrNull()?.let { + onClick() + } + } + ) +} + +@Composable +@Preview +private fun ClickableSubstringTextPreview() { + val text = "This text contains a link" + Box(modifier = Modifier.background(PhotonColors.White)) { + ClickableSubstringLink( + text, + PhotonColors.DarkGrey90, + text.indexOf("link"), + text.length + ) { } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/Image.kt b/app/src/main/java/org/mozilla/fenix/compose/Image.kt new file mode 100644 index 0000000000..d05b10a57e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/Image.kt @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import mozilla.components.support.images.compose.loader.ImageLoader +import mozilla.components.support.images.compose.loader.WithImage +import org.mozilla.fenix.components.components + +/** + * A composable that lays out and draws the image from a given URL while showing a default placeholder + * while that image is downloaded or a default fallback image when downloading failed. + * + * @param url URL from where the to download the image to be shown. + * @param modifier [Modifier] to be applied to the layout. + * @param private Whether or not this is a private request. Like in private browsing mode, + * private requests will not cache anything on disk and not send any cookies shared with the browser. + * @param targetSize Image size (width and height) the loaded image should be scaled to. + * @param contentDescription Localized text used by accessibility services to describe what this image represents. + * This should always be provided unless this image is used for decorative purposes, and does not represent + * a meaningful action that a user can take. + */ +@Composable +@Suppress("LongParameterList") +fun Image( + url: String, + modifier: Modifier = Modifier, + private: Boolean = false, + targetSize: Dp = 100.dp, + contentDescription: String? = null +) { + ImageLoader( + url = url, + client = components.core.client, + private = private, + targetSize = targetSize + ) { + WithImage { painter -> + androidx.compose.foundation.Image( + painter = painter, + modifier = modifier, + contentDescription = contentDescription, + ) + } + + WithDefaultPlaceholder(modifier, contentDescription) + + WithDefaultFallback(modifier, contentDescription) + } +} + +@Composable +@Preview +private fun ImagePreview() { + Image( + "https://mozilla.com", + Modifier.height(100.dp).width(200.dp) + ) +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/ImagesPlaceholder.kt b/app/src/main/java/org/mozilla/fenix/compose/ImagesPlaceholder.kt new file mode 100644 index 0000000000..e60fb4ef78 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/ImagesPlaceholder.kt @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import mozilla.components.support.images.compose.loader.Fallback +import mozilla.components.support.images.compose.loader.ImageLoaderScope +import mozilla.components.support.images.compose.loader.Placeholder +import mozilla.components.ui.colors.PhotonColors + +/** + * Renders the app default image placeholder while the image is still getting loaded. + * + * @param modifier [Modifier] allowing to control among others the dimensions and shape of the image. + * @param contentDescription Text provided to accessibility services to describe what this image represents. + * Defaults to [null] suited for an image used only for decorative purposes and not to be read by + * accessibility services. + */ +@Composable +internal fun ImageLoaderScope.WithDefaultPlaceholder( + modifier: Modifier, + contentDescription: String? = null +) { + Placeholder { + DefaultImagePlaceholder(modifier, contentDescription) + } +} + +/** + * Renders the app default image placeholder if loading the image failed. + * + * @param modifier [Modifier] allowing to control among others the dimensions and shape of the image. + * @param contentDescription Text provided to accessibility services to describe what this image represents. + * Defaults to [null] suited for an image used only for decorative purposes and not to be read by + * accessibility services. + */ +@Composable +internal fun ImageLoaderScope.WithDefaultFallback( + modifier: Modifier, + contentDescription: String? = null +) { + Fallback { + DefaultImagePlaceholder(modifier, contentDescription) + } +} + +/** + * Application default image placeholder. + * + * @param modifier [Modifier] allowing to control among others the dimensions and shape of the image. + * @param contentDescription Text provided to accessibility services to describe what this image represents. + * Defaults to [null] suited for an image used only for decorative purposes and not to be read by + * accessibility services. + */ +@Composable +internal fun DefaultImagePlaceholder( + modifier: Modifier, + contentDescription: String? = null +) { + val color = when (isSystemInDarkTheme()) { + true -> PhotonColors.DarkGrey30 + false -> PhotonColors.LightGrey30 + } + + Image(ColorPainter(color), contentDescription, modifier) +} + +@Composable +@Preview +private fun DefaultImagePlaceholderPreview() { + DefaultImagePlaceholder( + Modifier + .size(200.dp, 100.dp) + .clip(RoundedCornerShape(8.dp)) + ) +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/LazyListEagerFlingBehavior.kt b/app/src/main/java/org/mozilla/fenix/compose/LazyListEagerFlingBehavior.kt new file mode 100644 index 0000000000..c777d8a341 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/LazyListEagerFlingBehavior.kt @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * [FlingBehavior] for a [LazyRow] that will automatically scroll the list in the fling direction + * to fully show the next item. + */ +@Composable +fun EagerFlingBehavior( + lazyRowState: LazyListState +): FlingBehavior { + val scope = rememberCoroutineScope() + + return LazyListEagerFlingBehavior(lazyRowState, scope) +} + +private class LazyListEagerFlingBehavior( + private val lazyRowState: LazyListState, + private val scope: CoroutineScope +) : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + val firstItemIndex = lazyRowState.firstVisibleItemIndex + + val itemIndexToScrollTo = when (initialVelocity <= 0) { + true -> firstItemIndex + false -> firstItemIndex + 1 + } + + scope.launch { + lazyRowState.animateScrollToItem(itemIndexToScrollTo) + } + + return 0f // we've consumed the entire fling + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLarge.kt b/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLarge.kt new file mode 100644 index 0000000000..6d1eb8d776 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLarge.kt @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Default layout of a large tab shown in a list taking String arguments for title and caption. + * Has the following structure: + * ``` + * --------------------------------------------- + * | -------------- Title | + * | | Image | wrapped on | + * | | from | three rows if needed | + * | | imageUrl | | + * | -------------- Optional caption | + * --------------------------------------------- + * ``` + * + * @param imageUrl URL from where the to download a header image of the tab this composable renders. + * @param title Title off the tab this composable renders. + * @param caption Optional caption text. + * @param onClick Optional callback to be invoked when this composable is clicked. + */ +@Composable +fun ListItemTabLarge( + imageUrl: String, + title: String, + caption: String? = null, + onClick: (() -> Unit)? = null +) { + ListItemTabSurface(imageUrl, onClick) { + TabTitle(text = title, maxLines = 3) + + if (caption != null) { + TabSubtitle(text = caption) + } + } +} + +/** + * Default layout of a large tab shown in a list taking composable arguments for title and caption + * allowing as an exception to customize these elements. + * Has the following structure: + * ``` + * --------------------------------------------- + * | -------------- -------------------------- | + * | | | | Title | | + * | | Image | | composable | | + * | | from | -------------------------- | + * | | imageUrl | -------------------------- | + * | | | | Optional composable | | + * | -------------- -------------------------- | + * --------------------------------------------- + * ``` + * + * @param imageUrl URL from where the to download a header image of the tab this composable renders. + * @param title Composable rendering the title of the tab this composable represents. + * @param subtitle Optional tab caption composable. + * @param onClick Optional callback to be invoked when this composable is clicked. + */ +@Composable +fun ListItemTabLarge( + imageUrl: String, + onClick: () -> Unit, + title: @Composable () -> Unit, + subtitle: @Composable (() -> Unit)? = null +) { + ListItemTabSurface(imageUrl, onClick) { + title() + + subtitle?.invoke() + } +} + +/** + * Shared default configuration of a ListItemTabLarge Composable. + * + * @param imageUrl URL from where the to download a header image of the tab this composable renders. + * @param onClick Optional callback to be invoked when this composable is clicked. + * @param tabDetails [Composable] Displayed to the the end of the image. Allows for variation in the item text style. + */ +@Composable +private fun ListItemTabSurface( + imageUrl: String, + onClick: (() -> Unit)? = null, + tabDetails: @Composable () -> Unit +) { + var modifier = Modifier.size(328.dp, 116.dp) + if (onClick != null) modifier = modifier.then(Modifier.clickable { onClick() }) + + Card( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + backgroundColor = FirefoxTheme.colors.surface, + elevation = 6.dp + ) { + Row( + modifier = Modifier.padding(16.dp) + ) { + val (imageWidth, imageHeight) = 116.dp to 84.dp + val imageModifier = Modifier + .size(imageWidth, imageHeight) + .clip(RoundedCornerShape(8.dp)) + + Image(imageUrl, imageModifier, false, imageWidth) + + Spacer(Modifier.width(16.dp)) + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + tabDetails() + } + } + } +} + +@Composable +@Preview +private fun ListItemTabLargePreview() { + FirefoxTheme { + ListItemTabLarge( + imageUrl = "", + title = "This is a very long title for a tab but needs to be so for this preview", + caption = "And this is a caption" + ) { } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLargePlaceholder.kt b/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLargePlaceholder.kt new file mode 100644 index 0000000000..16b912a3b3 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLargePlaceholder.kt @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Placeholder of a [ListItemTabLarge] with the same dimensions but only a centered text. + * Has the following structure: + * ``` + * --------------------------------------------- + * | | + * | | + * | Placeholder text | + * | | + * | | + * --------------------------------------------- + * ``` + * + * @param text The only [String] that this will display. + * @param onClick Optional callback to be invoked when this composable is clicked. + */ +@Composable +fun ListItemTabLargePlaceholder( + text: String, + onClick: () -> Unit = { } +) { + Card( + modifier = Modifier + .size(328.dp, 116.dp) + .clickable { onClick() }, + shape = RoundedCornerShape(8.dp), + backgroundColor = FirefoxTheme.colors.surface, + elevation = 6.dp, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = text, + color = FirefoxTheme.colors.textPrimary, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TextStyle(fontSize = 20.sp), + ) + } + } +} + +@Composable +@Preview +private fun ListItemTabLargePlaceholderPreview() { + FirefoxTheme { + ListItemTabLargePlaceholder(text = "Item placeholder") + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/SectionHeader.kt b/app/src/main/java/org/mozilla/fenix/compose/SectionHeader.kt new file mode 100644 index 0000000000..a358fcd1eb --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/SectionHeader.kt @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import org.mozilla.fenix.R +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Default layout for the header of a screen section. + * + * @param text [String] to be styled as header and displayed. + * @param modifier [Modifier] to be applied to the [Text]. + */ +@Composable +fun SectionHeader( + text: String, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + text = text, + style = TextStyle( + fontFamily = FontFamily(Font(R.font.metropolis_semibold)), + fontSize = 20.sp, + lineHeight = 20.sp + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = FirefoxTheme.colors.textPrimary + ) +} + +/** + * Default layout for the header of a screen section. + * + * @param text [String] to be styled as header and displayed. + * @param modifier [Modifier] to be applied to the [Text]. + */ +@Composable +fun HomeSectionHeader( + text: String, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + text = text, + style = TextStyle( + fontFamily = FontFamily(Font(R.font.metropolis_semibold)), + fontSize = 16.sp, + lineHeight = 20.sp + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = FirefoxTheme.colors.textPrimary + ) +} + +@Composable +@Preview +private fun HeadingTextPreview() { + SectionHeader(text = "Section title") +} + +@Composable +@Preview +private fun HomeHeadingTextPreview() { + HomeSectionHeader(text = "Home section title") +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/SelectableChip.kt b/app/src/main/java/org/mozilla/fenix/compose/SelectableChip.kt new file mode 100644 index 0000000000..11876397a8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/SelectableChip.kt @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import mozilla.components.ui.colors.PhotonColors +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Default layout of a selectable chip. + * + * @param text [String] displayed in this chip. Ideally should only be one word. + * @param isSelected Whether this should be shown as selected. + * @param onClick Callback for when the user taps this. + */ +@Composable +fun SelectableChip( + text: String, + isSelected: Boolean, + onClick: () -> Unit +) { + val contentColor = when (isSystemInDarkTheme()) { + true -> PhotonColors.LightGrey10 + false -> if (isSelected) PhotonColors.LightGrey10 else PhotonColors.DarkGrey90 + } + + @Suppress("MagicNumber") + val backgroundColor = when (isSystemInDarkTheme()) { + true -> if (isSelected) PhotonColors.Violet50 else PhotonColors.DarkGrey50 + // Custom color codes matching the Figma design. + false -> if (isSelected) { Color(0xFF312A65) } else { Color(0x1420123A) } + } + + Box( + modifier = Modifier + .selectable(isSelected) { onClick() } + .clip(MaterialTheme.shapes.small) + .background(backgroundColor) + .padding(16.dp, 10.dp) + ) { + Text( + text = text.capitalize(Locale.current), + style = TextStyle(fontSize = 14.sp), + color = contentColor + ) + } +} + +@Composable +@Preview +private fun SelectableChipPreview() { + FirefoxTheme { + Box(Modifier.fillMaxSize().background(FirefoxTheme.colors.surface)) { + SelectableChip("Chirp", false) { } + SelectableChip(text = "Chirp", isSelected = true) { } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/StaggeredHorizontalGrid.kt b/app/src/main/java/org/mozilla/fenix/compose/StaggeredHorizontalGrid.kt new file mode 100644 index 0000000000..aca7c8dbd7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/StaggeredHorizontalGrid.kt @@ -0,0 +1,137 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Displays a list of items as a staggered horizontal grid placing them on ltr rows and continuing + * on as many below rows as needed to place all items. + * + * In an effort to best utilize the available row space this can mix the items such that narrower ones + * are placed on the same row as wider ones if the otherwise next item doesn't fit. + * + * @param modifier [Modifier] to be applied to the layout. + * @param horizontalItemsSpacing Minimum horizontal space between items. Does not add spacing to layout bounds. + * @param verticalItemsSpacing Vertical space between items + * @param arrangement How the items will be horizontally aligned and spaced. + * @param content The children composables to be laid out. + */ +@Composable +fun StaggeredHorizontalGrid( + modifier: Modifier = Modifier, + horizontalItemsSpacing: Dp = 0.dp, + verticalItemsSpacing: Dp = 8.dp, + arrangement: Arrangement.Horizontal = Arrangement.Start, + content: @Composable () -> Unit +) { + val currentLayoutDirection = LocalLayoutDirection.current + + Layout(content, modifier) { items, constraints -> + val horizontalItemsSpacingPixels = horizontalItemsSpacing.roundToPx() + val verticalItemsSpacingPixels = verticalItemsSpacing.roundToPx() + var totalHeight = 0 + val itemsRows = mutableListOf>() + val notYetPlacedItems = items.map { + it.measure(constraints) + }.toMutableList() + + fun getIndexOfNextPlaceableThatFitsRow(available: List, currentWidth: Int): Int { + return available.indexOfFirst { + currentWidth + it.width <= constraints.maxWidth + } + } + + // Populate each row with as many items as possible combining wider with narrower items. + // This will change the order of shown categories. + var (currentRow, currentWidth) = mutableListOf() to 0 + while (notYetPlacedItems.isNotEmpty()) { + if (currentRow.isEmpty()) { + currentRow.add( + notYetPlacedItems[0].also { + currentWidth += it.width + horizontalItemsSpacingPixels + totalHeight += it.height + verticalItemsSpacingPixels + } + ) + notYetPlacedItems.removeAt(0) + } else { + val nextPlaceableThatFitsIndex = getIndexOfNextPlaceableThatFitsRow(notYetPlacedItems, currentWidth) + if (nextPlaceableThatFitsIndex >= 0) { + currentRow.add( + notYetPlacedItems[nextPlaceableThatFitsIndex].also { + currentWidth += it.width + horizontalItemsSpacingPixels + } + ) + notYetPlacedItems.removeAt(nextPlaceableThatFitsIndex) + } else { + itemsRows.add(currentRow) + currentRow = mutableListOf() + currentWidth = 0 + } + } + } + if (currentRow.isNotEmpty()) { + itemsRows.add(currentRow) + } + totalHeight -= verticalItemsSpacingPixels + + // Place each item from each row on screen. + layout(constraints.maxWidth, totalHeight) { + itemsRows.forEachIndexed { rowIndex, itemRow -> + val itemsSizes = IntArray(itemRow.size) { + itemRow[it].width + when (currentLayoutDirection == LayoutDirection.Ltr) { + true -> if (it < itemRow.lastIndex) horizontalItemsSpacingPixels else 0 + false -> if (it > 0) horizontalItemsSpacingPixels else 0 + } + } + val itemsPositions = IntArray(itemsSizes.size) { 0 } + with(arrangement) { + arrange(constraints.maxWidth, itemsSizes, currentLayoutDirection, itemsPositions) + } + + itemRow.forEachIndexed { itemIndex, item -> + item.place( + x = itemsPositions[itemIndex], + y = (rowIndex * item.height) + (rowIndex * verticalItemsSpacingPixels) + ) + } + } + } + } +} + +@Composable +@Preview +private fun StaggeredHorizontalGridPreview() { + FirefoxTheme { + Box(Modifier.background(FirefoxTheme.colors.surface)) { + StaggeredHorizontalGrid( + horizontalItemsSpacing = 8.dp, + arrangement = Arrangement.Center + ) { + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" + .split(" ") + .forEach { + Text(text = it, color = Color.Red, modifier = Modifier.border(3.dp, Color.Blue)) + } + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/TabSubtitle.kt b/app/src/main/java/org/mozilla/fenix/compose/TabSubtitle.kt new file mode 100644 index 0000000000..747c93e798 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/TabSubtitle.kt @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Default layout for a tab composable caption. + * + * @param text Tab caption. + * @param modifier Optional [Modifier] to be applied to the layout. + */ +@Composable +fun TabSubtitle( + text: String, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + maxLines = 1, + text = text, + style = TextStyle(fontSize = 12.sp), + overflow = TextOverflow.Ellipsis, + color = FirefoxTheme.colors.textSecondary + ) +} + +@Composable +@Preview +private fun TabSubtitlePreview() { + FirefoxTheme { + Box(Modifier.background(FirefoxTheme.colors.surface)) { + TabSubtitle( + "Awesome tab subtitle", + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/TabSubtitleWithInterdot.kt b/app/src/main/java/org/mozilla/fenix/compose/TabSubtitleWithInterdot.kt new file mode 100644 index 0000000000..9d0de51a26 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/TabSubtitleWithInterdot.kt @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Special caption text for a tab layout shown on one line. + * + * This will combine [firstText] with a interdot and then [secondText] ensuring that the second text + * (which is assumed to be smaller) always fills as much space as needed with the [firstText] automatically + * being resized to be smaller with an added ellipsis characters if needed. + * + * Possible results: + * ``` + * - when both texts would fit the screen + * ------------------------------------------ + * |firstText · secondText | + * ------------------------------------------ + * + * - when both text do not fit, second is shown in entirety, first is ellipsised. + * ------------------------------------------ + * |longerFirstTextOrSmallSc... · secondText| + * ------------------------------------------ + * ``` + * + * @param firstText Text shown at the start of the row. + * @param secondText Text shown at the end of the row. + */ +@Composable +fun TabSubtitleWithInterdot( + firstText: String, + secondText: String, +) { + val currentLayoutDirection = LocalLayoutDirection.current + + Layout( + content = { + TabSubtitle(text = firstText) + TabSubtitle(text = " \u00b7 ") + TabSubtitle(text = secondText) + } + ) { items, constraints -> + + // We need to measure from the end to start to ensure the secondItem will always be on screen + // and depending on secondItem's width and interdot's width the firstItem is automatically resized. + val secondItem = items[2].measure(constraints) + val interdot = items[1].measure( + constraints.copy(maxWidth = constraints.maxWidth - secondItem.width) + ) + val firstItem = items[0].measure( + constraints.copy(maxWidth = constraints.maxWidth - secondItem.width - interdot.width) + ) + + layout(constraints.maxWidth, constraints.maxHeight) { + val itemsPositions = IntArray(items.size) + with(Arrangement.Start) { + arrange( + constraints.maxWidth, + intArrayOf(firstItem.width, interdot.width, secondItem.width), + currentLayoutDirection, + itemsPositions + ) + } + + val placementHeight = constraints.maxHeight - firstItem.height + listOf(firstItem, interdot, secondItem).forEachIndexed { index, item -> + item.place(itemsPositions[index], placementHeight) + } + } + } +} + +@Composable +@Preview +private fun TabSubtitleWithInterdotPreview() { + FirefoxTheme { + Box(Modifier.background(FirefoxTheme.colors.surface)) { + TabSubtitleWithInterdot( + firstText = "firstText", + secondText = "secondText", + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/TabTitle.kt b/app/src/main/java/org/mozilla/fenix/compose/TabTitle.kt new file mode 100644 index 0000000000..bcca9ebf2f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/TabTitle.kt @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Default layout for a tab composable title. + * + * @param text Tab title + * @param maxLines Maximum number of lines for [text] to span, wrapping if necessary. + * If the text exceeds the given number of lines it will be ellipsized. + * @param modifier Optional [Modifier] to be applied to the layout. + */ +@Composable +fun TabTitle( + text: String, + maxLines: Int, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + maxLines = maxLines, + text = text, + style = TextStyle(fontSize = 14.sp), + overflow = TextOverflow.Ellipsis, + color = FirefoxTheme.colors.textPrimary + ) +} + +@Composable +private fun TabTitlePreview() { + FirefoxTheme { + Box(Modifier.background(FirefoxTheme.colors.surface)) { + TabTitle( + "Awesome tab title", + 2 + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/crashes/CrashReporterFragment.kt b/app/src/main/java/org/mozilla/fenix/crashes/CrashReporterFragment.kt index 6f74525ea0..8adfdd3065 100644 --- a/app/src/main/java/org/mozilla/fenix/crashes/CrashReporterFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/crashes/CrashReporterFragment.kt @@ -9,9 +9,9 @@ import android.view.View import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_crash_reporter.* import mozilla.components.lib.crash.Crash import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentCrashReporterBinding import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.requireComponents @@ -25,10 +25,12 @@ class CrashReporterFragment : Fragment(R.layout.fragment_crash_reporter) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val binding = FragmentCrashReporterBinding.bind(view) + val args: CrashReporterFragmentArgs by navArgs() val crash = Crash.fromIntent(args.crashIntent) - title.text = getString(R.string.tab_crash_title_2, getString(R.string.app_name)) + binding.title.text = getString(R.string.tab_crash_title_2, getString(R.string.app_name)) val controller = CrashReporterController( crash, @@ -38,17 +40,17 @@ class CrashReporterFragment : Fragment(R.layout.fragment_crash_reporter) { settings = requireContext().settings() ) - restoreTabButton.apply { + binding.restoreTabButton.apply { increaseTapArea(TAP_INCREASE_DP) setOnClickListener { - controller.handleCloseAndRestore(sendCrashCheckbox.isChecked) + controller.handleCloseAndRestore(binding.sendCrashCheckbox.isChecked) } } - closeTabButton.apply { + binding.closeTabButton.apply { increaseTapArea(TAP_INCREASE_DP) setOnClickListener { - controller.handleCloseAndRemove(sendCrashCheckbox.isChecked) + controller.handleCloseAndRemove(binding.sendCrashCheckbox.isChecked) } } } diff --git a/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabsIntegration.kt b/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabsIntegration.kt index 0d5a6fb0f1..98e2955766 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabsIntegration.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabsIntegration.kt @@ -15,7 +15,6 @@ import mozilla.components.support.base.feature.LifecycleAwareFeature import mozilla.components.support.base.feature.UserInteractionHandler import org.mozilla.fenix.R import org.mozilla.fenix.components.toolbar.ToolbarMenu -import org.mozilla.fenix.ext.settings class CustomTabsIntegration( store: BrowserStore, @@ -32,29 +31,10 @@ class CustomTabsIntegration( // Remove toolbar shadow toolbar.elevation = 0f - val uncoloredEtpShield = getDrawable(activity, R.drawable.ic_tracking_protection_enabled)!! - - toolbar.display.icons = toolbar.display.icons.copy( - // Custom private tab backgrounds have bad contrast against the colored shield - trackingProtectionTrackersBlocked = uncoloredEtpShield, - trackingProtectionNothingBlocked = uncoloredEtpShield, - trackingProtectionException = getDrawable( - activity, - R.drawable.ic_tracking_protection_disabled - )!! - ) - toolbar.display.displayIndicatorSeparator = false - if (activity.settings().shouldUseTrackingProtection) { - toolbar.display.indicators = listOf( - DisplayToolbar.Indicators.SECURITY, - DisplayToolbar.Indicators.TRACKING_PROTECTION - ) - } else { - toolbar.display.indicators = listOf( - DisplayToolbar.Indicators.SECURITY - ) - } + toolbar.display.indicators = listOf( + DisplayToolbar.Indicators.SECURITY + ) // If in private mode, override toolbar background to use private color // See #5334 diff --git a/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt b/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt index a8331af697..0603c0a6c6 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt @@ -8,7 +8,6 @@ import android.content.Intent import androidx.annotation.VisibleForTesting import androidx.navigation.NavDestination import androidx.navigation.NavDirections -import kotlinx.android.synthetic.main.activity_home.* import mozilla.components.browser.state.selector.findCustomTab import mozilla.components.browser.state.state.SessionState import mozilla.components.concept.engine.manifest.WebAppManifestParser diff --git a/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserFragment.kt index a1d88d9c66..13c13eda3a 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserFragment.kt @@ -11,10 +11,9 @@ import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.isVisible import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.component_browser_top_toolbar.* -import kotlinx.android.synthetic.main.fragment_browser.* import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.state.state.SessionState +import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.concept.engine.manifest.WebAppManifestParser import mozilla.components.concept.engine.manifest.getOrNull import mozilla.components.feature.contextmenu.ContextMenuCandidate @@ -36,8 +35,8 @@ import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents -import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.runIfFragmentIsAttached +import org.mozilla.fenix.ext.settings /** * Fragment used for browsing the web within external apps. @@ -58,6 +57,8 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler val customTabSessionId = customTabSessionId ?: return val activity = requireActivity() val components = activity.components + val toolbar = binding.root.findViewById(R.id.toolbar) + val manifest = args.webAppManifest?.let { json -> WebAppManifestParser().parse(json).getOrNull() } customTabsIntegration.set( @@ -105,9 +106,9 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler browserToolbarView.view.isVisible = toolbarVisible webAppToolbarShouldBeVisible = toolbarVisible if (!toolbarVisible) { - engineView.setDynamicToolbarMaxHeight(0) + binding.engineView.setDynamicToolbarMaxHeight(0) val browserEngine = - swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams + binding.swipeRefresh.layoutParams as CoordinatorLayout.LayoutParams browserEngine.bottomMargin = 0 } }, @@ -178,32 +179,20 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler } override fun navToQuickSettingsSheet(tab: SessionState, sitePermissions: SitePermissions?) { - val directions = ExternalAppBrowserFragmentDirections - .actionGlobalQuickSettingsSheetDialogFragment( - sessionId = tab.id, - url = tab.content.url, - title = tab.content.title, - isSecured = tab.content.securityInfo.secure, - sitePermissions = sitePermissions, - gravity = getAppropriateLayoutGravity(), - certificateName = tab.content.securityInfo.issuer, - permissionHighlights = tab.content.permissionHighlights - ) - nav(R.id.externalAppBrowserFragment, directions) - } - - override fun navToTrackingProtectionPanel(tab: SessionState) { requireComponents.useCases.trackingProtectionUseCases.containsException(tab.id) { contains -> runIfFragmentIsAttached { - val isEnabled = tab.trackingProtection.enabled && !contains - val directions = - ExternalAppBrowserFragmentDirections - .actionGlobalTrackingProtectionPanelDialogFragment( - sessionId = tab.id, - url = tab.content.url, - trackingProtectionEnabled = isEnabled, - gravity = getAppropriateLayoutGravity() - ) + val directions = ExternalAppBrowserFragmentDirections + .actionGlobalQuickSettingsSheetDialogFragment( + sessionId = tab.id, + url = tab.content.url, + title = tab.content.title, + isSecured = tab.content.securityInfo.secure, + sitePermissions = sitePermissions, + gravity = getAppropriateLayoutGravity(), + certificateName = tab.content.securityInfo.issuer, + permissionHighlights = tab.content.permissionHighlights, + isTrackingProtectionEnabled = tab.trackingProtection.enabled && !contains + ) nav(R.id.externalAppBrowserFragment, directions) } } diff --git a/app/src/main/java/org/mozilla/fenix/datastore/DataStores.kt b/app/src/main/java/org/mozilla/fenix/datastore/DataStores.kt new file mode 100644 index 0000000000..df7d85d188 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/datastore/DataStores.kt @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore + +/** + * Application / process unique [DataStore] for IO operations related to Pocket recommended stories selected categories. + */ +internal val Context.pocketStoriesSelectedCategoriesDataStore: DataStore by dataStore( + fileName = "pocket_recommendations_selected_categories.pb", + serializer = SelectedPocketStoriesCategorySerializer +) diff --git a/app/src/main/java/org/mozilla/fenix/datastore/SelectedPocketStoriesCategorySerializer.kt b/app/src/main/java/org/mozilla/fenix/datastore/SelectedPocketStoriesCategorySerializer.kt new file mode 100644 index 0000000000..53acafe369 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/datastore/SelectedPocketStoriesCategorySerializer.kt @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.datastore + +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream + +/** + * Serializer for [SelectedPocketStoriesCategories] defined in selected_pocket_stories_categories.proto. + */ +@Suppress("BlockingMethodInNonBlockingContext") +object SelectedPocketStoriesCategorySerializer : Serializer { + override val defaultValue: SelectedPocketStoriesCategories = SelectedPocketStoriesCategories.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): SelectedPocketStoriesCategories { + return SelectedPocketStoriesCategories.parseFrom(input) + } + + override suspend fun writeTo(t: SelectedPocketStoriesCategories, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/downloads/DynamicDownloadDialog.kt b/app/src/main/java/org/mozilla/fenix/downloads/DynamicDownloadDialog.kt index 3b2b2b3b33..b6904b96de 100644 --- a/app/src/main/java/org/mozilla/fenix/downloads/DynamicDownloadDialog.kt +++ b/app/src/main/java/org/mozilla/fenix/downloads/DynamicDownloadDialog.kt @@ -9,12 +9,11 @@ import android.view.View import android.view.ViewGroup import android.webkit.MimeTypeMap import androidx.coordinatorlayout.widget.CoordinatorLayout -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.download_dialog_layout.view.* import mozilla.components.browser.state.state.content.DownloadState import mozilla.components.feature.downloads.AbstractFetchDownloadService import mozilla.components.feature.downloads.toMegabyteOrKilobyteString import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.DownloadDialogLayoutBinding import org.mozilla.fenix.ext.settings /** @@ -24,28 +23,25 @@ import org.mozilla.fenix.ext.settings */ @Suppress("LongParameterList") class DynamicDownloadDialog( - private val container: ViewGroup, + private val context: Context, private val downloadState: DownloadState?, private val didFail: Boolean, private val tryAgain: (String) -> Unit, private val onCannotOpenFile: (DownloadState) -> Unit, - private val view: View, + private val binding: DownloadDialogLayoutBinding, private val toolbarHeight: Int, private val onDismiss: () -> Unit -) : LayoutContainer { +) { - override val containerView: View? - get() = container - - private val settings = container.context.settings() + private val settings = context.settings() init { - setupDownloadDialog(view) + setupDownloadDialog() } - private fun setupDownloadDialog(view: View) { + private fun setupDownloadDialog() { if (downloadState == null) return - view.apply { + binding.root.apply { if (layoutParams is CoordinatorLayout.LayoutParams) { (layoutParams as CoordinatorLayout.LayoutParams).apply { @@ -61,39 +57,39 @@ class DynamicDownloadDialog( if (settings.shouldUseBottomToolbar) { val params: ViewGroup.MarginLayoutParams = - view.layoutParams as ViewGroup.MarginLayoutParams + binding.root.layoutParams as ViewGroup.MarginLayoutParams params.bottomMargin = toolbarHeight } if (didFail) { - view.download_dialog_title.text = - container.context.getString(R.string.mozac_feature_downloads_failed_notification_text2) + binding.downloadDialogTitle.text = + context.getString(R.string.mozac_feature_downloads_failed_notification_text2) - view.download_dialog_icon.setImageResource( + binding.downloadDialogIcon.setImageResource( mozilla.components.feature.downloads.R.drawable.mozac_feature_download_ic_download_failed ) - view.download_dialog_action_button.apply { + binding.downloadDialogActionButton.apply { text = context.getString( mozilla.components.feature.downloads.R.string.mozac_feature_downloads_button_try_again ) setOnClickListener { tryAgain(downloadState.id) - dismiss(view) + dismiss() } } } else { - val titleText = container.context.getString( + val titleText = context.getString( R.string.mozac_feature_downloads_completed_notification_text2 ) + " (${downloadState.contentLength?.toMegabyteOrKilobyteString()})" - view.download_dialog_title.text = titleText + binding.downloadDialogTitle.text = titleText - view.download_dialog_icon.setImageResource( + binding.downloadDialogIcon.setImageResource( mozilla.components.feature.downloads.R.drawable.mozac_feature_download_ic_download_complete ) - view.download_dialog_action_button.apply { + binding.downloadDialogActionButton.apply { text = context.getString( mozilla.components.feature.downloads.R.string.mozac_feature_downloads_button_open ) @@ -107,28 +103,28 @@ class DynamicDownloadDialog( onCannotOpenFile(downloadState) } - dismiss(view) + dismiss() } } } - view.download_dialog_close_button.setOnClickListener { - dismiss(view) + binding.downloadDialogCloseButton.setOnClickListener { + dismiss() } - view.download_dialog_filename.text = downloadState.fileName + binding.downloadDialogFilename.text = downloadState.fileName } fun show() { - view.visibility = View.VISIBLE + binding.root.visibility = View.VISIBLE - (view.layoutParams as CoordinatorLayout.LayoutParams).apply { - (behavior as DynamicDownloadDialogBehavior).forceExpand(view) + (binding.root.layoutParams as CoordinatorLayout.LayoutParams).apply { + (behavior as DynamicDownloadDialogBehavior).forceExpand(binding.root) } } - private fun dismiss(view: View) { - view.visibility = View.GONE + private fun dismiss() { + binding.root.visibility = View.GONE onDismiss() } diff --git a/app/src/main/java/org/mozilla/fenix/exceptions/ExceptionsView.kt b/app/src/main/java/org/mozilla/fenix/exceptions/ExceptionsView.kt index f42fe66aca..0ff6dd89c4 100644 --- a/app/src/main/java/org/mozilla/fenix/exceptions/ExceptionsView.kt +++ b/app/src/main/java/org/mozilla/fenix/exceptions/ExceptionsView.kt @@ -7,11 +7,10 @@ package org.mozilla.fenix.exceptions import android.view.LayoutInflater import android.view.ViewGroup import android.widget.FrameLayout +import androidx.annotation.VisibleForTesting import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.component_exceptions.* -import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ComponentExceptionsBinding /** * View that contains and configures the Exceptions List @@ -19,23 +18,28 @@ import org.mozilla.fenix.R abstract class ExceptionsView( container: ViewGroup, protected val interactor: ExceptionsInteractor -) : LayoutContainer { +) { - override val containerView: FrameLayout = LayoutInflater.from(container.context) - .inflate(R.layout.component_exceptions, container, true) - .findViewById(R.id.exceptions_wrapper) + @VisibleForTesting + internal val binding = ComponentExceptionsBinding.inflate( + LayoutInflater.from(container.context), + container, + true + ) + + val containerView: FrameLayout = binding.exceptionsWrapper protected abstract val exceptionsAdapter: ExceptionsAdapter init { - exceptions_list.apply { + binding.exceptionsList.apply { layoutManager = LinearLayoutManager(containerView.context) } } fun update(items: List) { - exceptions_empty_view.isVisible = items.isEmpty() - exceptions_list.isVisible = items.isNotEmpty() + binding.exceptionsEmptyView.isVisible = items.isEmpty() + binding.exceptionsList.isVisible = items.isNotEmpty() exceptionsAdapter.updateData(items) } } diff --git a/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsFragment.kt b/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsFragment.kt index b9f6c19df1..1708d0127f 100644 --- a/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsFragment.kt @@ -11,13 +11,13 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.asLiveData import androidx.lifecycle.lifecycleScope -import kotlinx.android.synthetic.main.fragment_exceptions.view.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.plus import mozilla.components.lib.state.ext.consumeFrom import org.mozilla.fenix.R import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.databinding.FragmentExceptionsBinding import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar @@ -39,8 +39,12 @@ class LoginExceptionsFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_exceptions, container, false) + ): View { + val binding = FragmentExceptionsBinding.inflate( + inflater, + container, + false + ) exceptionsStore = StoreProvider.get(this) { ExceptionsFragmentStore( ExceptionsFragmentState(items = emptyList()) @@ -51,11 +55,11 @@ class LoginExceptionsFragment : Fragment() { loginExceptionStorage = requireComponents.core.loginExceptionStorage ) exceptionsView = LoginExceptionsView( - view.exceptionsLayout, + binding.exceptionsLayout, exceptionsInteractor ) subscribeToLoginExceptions() - return view + return binding.root } private fun subscribeToLoginExceptions() { diff --git a/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsView.kt b/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsView.kt index 7d83cd40b6..ef94223cf6 100644 --- a/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsView.kt +++ b/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsView.kt @@ -6,7 +6,6 @@ package org.mozilla.fenix.exceptions.login import android.view.ViewGroup import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.component_exceptions.* import mozilla.components.feature.logins.exceptions.LoginException import org.mozilla.fenix.R import org.mozilla.fenix.exceptions.ExceptionsView @@ -19,10 +18,10 @@ class LoginExceptionsView( override val exceptionsAdapter = LoginExceptionsAdapter(interactor) init { - exceptions_learn_more.isVisible = false - exceptions_empty_message.text = + binding.exceptionsLearnMore.isVisible = false + binding.exceptionsEmptyMessage.text = containerView.context.getString(R.string.preferences_passwords_exceptions_description_empty) - exceptions_list.apply { + binding.exceptionsList.apply { adapter = exceptionsAdapter } } diff --git a/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/ExceptionsFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/ExceptionsFragmentStore.kt index 6fa287e6d1..6b8d7c14d8 100644 --- a/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/ExceptionsFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/ExceptionsFragmentStore.kt @@ -6,14 +6,21 @@ package org.mozilla.fenix.exceptions.trackingprotection import mozilla.components.concept.engine.content.blocking.TrackingProtectionException import mozilla.components.lib.state.Action +import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.State import mozilla.components.lib.state.Store /** * The [Store] for holding the [ExceptionsFragmentState] and applying [ExceptionsFragmentAction]s. */ -class ExceptionsFragmentStore(initialState: ExceptionsFragmentState) : - Store(initialState, ::exceptionsStateReducer) +class ExceptionsFragmentStore( + initialState: ExceptionsFragmentState = ExceptionsFragmentState(), + middlewares: List> = emptyList() +) : Store( + initialState, + ::exceptionsStateReducer, + middlewares +) /** * Actions to dispatch through the `ExceptionsStore` to modify `ExceptionsState` through the reducer. diff --git a/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragment.kt b/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragment.kt index eef5c4eb5f..3c04aefd59 100644 --- a/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragment.kt @@ -9,12 +9,12 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import kotlinx.android.synthetic.main.fragment_exceptions.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.lib.state.ext.consumeFrom import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.databinding.FragmentExceptionsBinding import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar @@ -37,8 +37,12 @@ class TrackingProtectionExceptionsFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_exceptions, container, false) + ): View { + val binding = FragmentExceptionsBinding.inflate( + inflater, + container, + false + ) exceptionsStore = StoreProvider.get(this) { ExceptionsFragmentStore( ExceptionsFragmentState(items = emptyList()) @@ -50,11 +54,11 @@ class TrackingProtectionExceptionsFragment : Fragment() { trackingProtectionUseCases = requireComponents.useCases.trackingProtectionUseCases ) exceptionsView = TrackingProtectionExceptionsView( - view.exceptionsLayout, + binding.exceptionsLayout, exceptionsInteractor ) exceptionsInteractor.reloadExceptions() - return view + return binding.root } @ExperimentalCoroutinesApi diff --git a/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsView.kt b/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsView.kt index b312e939e1..2529a9b813 100644 --- a/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsView.kt +++ b/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsView.kt @@ -6,7 +6,6 @@ package org.mozilla.fenix.exceptions.trackingprotection import android.text.method.LinkMovementMethod import android.view.ViewGroup -import kotlinx.android.synthetic.main.component_exceptions.* import mozilla.components.concept.engine.content.blocking.TrackingProtectionException import org.mozilla.fenix.exceptions.ExceptionsView import org.mozilla.fenix.ext.addUnderline @@ -19,11 +18,11 @@ class TrackingProtectionExceptionsView( override val exceptionsAdapter = TrackingProtectionExceptionsAdapter(interactor) init { - exceptions_list.apply { + binding.exceptionsList.apply { adapter = exceptionsAdapter } - with(exceptions_learn_more) { + with(binding.exceptionsLearnMore) { addUnderline() movementMethod = LinkMovementMethod.getInstance() diff --git a/app/src/main/java/org/mozilla/fenix/experiments/Experiments.kt b/app/src/main/java/org/mozilla/fenix/experiments/Experiments.kt index 6f83567f3f..cd5d7f9ddb 100644 --- a/app/src/main/java/org/mozilla/fenix/experiments/Experiments.kt +++ b/app/src/main/java/org/mozilla/fenix/experiments/Experiments.kt @@ -14,7 +14,8 @@ package org.mozilla.fenix.experiments enum class FeatureId(val jsonName: String) { NIMBUS_VALIDATION("nimbus-validation"), ANDROID_KEYSTORE("fenix-android-keystore"), - DEFAULT_BROWSER("fenix-default-browser") + DEFAULT_BROWSER("fenix-default-browser"), + HOME_PAGE("homescreen") } /** diff --git a/app/src/main/java/org/mozilla/fenix/experiments/NimbusFeatures.kt b/app/src/main/java/org/mozilla/fenix/experiments/NimbusFeatures.kt new file mode 100644 index 0000000000..8636339c8b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/experiments/NimbusFeatures.kt @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.experiments + +import android.content.Context +import org.mozilla.experiments.nimbus.mapKeysAsEnums +import org.mozilla.fenix.Config +import org.mozilla.fenix.FeatureFlags +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.getVariables + +/** + * Component for exposing nimbus Feature Variables. + * For more information see https://experimenter.info/feature-variables-and-me + * + * @param context - A [Context] for accessing the feature variables from nimbus. + */ +class NimbusFeatures(private val context: Context) { + + val homeScreen: HomeScreenFeatures by lazy { + HomeScreenFeatures(context) + } + + /** + * Component that indicates which features should be active on the home screen. + */ + class HomeScreenFeatures(private val context: Context) { + /** + * `FeatureId.HOME_PAGE` feature; the complete JSON, is shown here: + * + * ```json + * { + * "sections-enabled": { + * "topSites": true, + * "recentlySaved": false, + * "jumpBackIn": false, + * "pocket": false, + * "recentExplorations": false + * } + * } + * ``` + */ + + /** + * This enum accompanies the `FeatureId.HOME_PAGE` feature. + * + * These names here should match the names of entries in the JSON. + */ + @Suppress("EnumNaming") + private enum class HomeScreenSection(val default: Boolean) { + topSites(true), + recentlySaved(false), + jumpBackIn(false), + pocket(false), + recentExplorations(false); + + companion object { + /** + * CreateS a map with the corresponding default values for each sections. + */ + fun toMap(context: Context): Map { + return values().associate { section -> + val channelDefault = if (section == pocket && Config.channel.isNightlyOrDebug) { + FeatureFlags.isPocketRecommendationsFeatureEnabled(context) + } else { + Config.channel.isNightlyOrDebug + } + section to (channelDefault || section.default) + } + } + } + } + + private val homeScreenFeatures: Map by lazy { + val experiments = context.components.analytics.experiments + val variables = experiments.getVariables(FeatureId.HOME_PAGE, false) + val sections: Map = + variables.getBoolMap("sections-enabled")?.mapKeysAsEnums() + ?: HomeScreenSection.toMap(context) + sections + } + + /** + * Indicates if the recently tabs feature is active. + */ + fun isRecentlyTabsActive(): Boolean { + return homeScreenFeatures[HomeScreenSection.jumpBackIn] == true + } + + /** + * Indicates if the recently saved feature is active. + */ + fun isRecentlySavedActive(): Boolean { + return homeScreenFeatures[HomeScreenSection.recentlySaved] == true + } + + /** + * Indicates if the recently exploration feature is active. + */ + fun isRecentExplorationsActive(): Boolean { + return homeScreenFeatures[HomeScreenSection.recentExplorations] == true + } + + /** + * Indicates if the pocket recommendations feature is active. + */ + fun isPocketRecommendationsActive(): Boolean { + return homeScreenFeatures[HomeScreenSection.pocket] == true + } + + /** + * Indicates if the top sites feature is active. + */ + fun isTopSitesActive(): Boolean { + return homeScreenFeatures[HomeScreenSection.topSites] == true + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/experiments/NimbusSetup.kt b/app/src/main/java/org/mozilla/fenix/experiments/NimbusSetup.kt index 2919ddadc9..34b5451eeb 100644 --- a/app/src/main/java/org/mozilla/fenix/experiments/NimbusSetup.kt +++ b/app/src/main/java/org/mozilla/fenix/experiments/NimbusSetup.kt @@ -13,7 +13,7 @@ import mozilla.components.service.nimbus.NimbusAppInfo import mozilla.components.service.nimbus.NimbusDisabled import mozilla.components.service.nimbus.NimbusServerSettings import mozilla.components.support.base.log.logger.Logger -import org.mozilla.experiments.nimbus.internal.NimbusErrorException +import org.mozilla.experiments.nimbus.internal.NimbusException import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.R import org.mozilla.fenix.ext.components @@ -24,7 +24,7 @@ fun createNimbus(context: Context, url: String?): NimbusApi { val errorReporter: ((String, Throwable) -> Unit) = reporter@{ message, e -> Logger.error("Nimbus error: $message", e) - if (e is NimbusErrorException && !e.isReportableError()) { + if (e is NimbusException && !e.isReportableError()) { return@reporter } @@ -63,7 +63,10 @@ fun createNimbus(context: Context, url: String?): NimbusApi { // Note: Using BuildConfig.BUILD_TYPE is important here so that it matches the value // passed into Glean. `Config.channel.toString()` turned out to be non-deterministic // and would mostly produce the value `Beta` and rarely would produce `beta`. - channel = BuildConfig.BUILD_TYPE + channel = BuildConfig.BUILD_TYPE, + customTargetingAttributes = mapOf( + "isFirstRun" to context.settings().isFirstRun.toString() + ) ) Nimbus(context, appInfo, serverSettings, errorReporter).apply { // This performs the minimal amount of work required to load branch and enrolment data @@ -106,10 +109,10 @@ fun createNimbus(context: Context, url: String?): NimbusApi { * * This fix should be upstreamed as part of: https://github.com/mozilla/application-services/issues/4333 */ -fun NimbusErrorException.isReportableError(): Boolean { +fun NimbusException.isReportableError(): Boolean { return when (this) { - is NimbusErrorException.RequestError, - is NimbusErrorException.ResponseError -> false + is NimbusException.RequestException, + is NimbusException.ResponseException -> false else -> true } } diff --git a/app/src/main/java/org/mozilla/fenix/ext/BrowserState.kt b/app/src/main/java/org/mozilla/fenix/ext/BrowserState.kt index a76b802b3a..4b573c374a 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/BrowserState.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/BrowserState.kt @@ -9,22 +9,25 @@ import mozilla.components.browser.state.selector.selectedNormalTab import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.TabSessionState import mozilla.components.feature.tabs.ext.hasMediaPlayed +import org.mozilla.fenix.home.recenttabs.RecentTab +import org.mozilla.fenix.tabstray.browser.TabGroup +import org.mozilla.fenix.tabstray.browser.maxActiveTime +import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm +import kotlin.math.max /** - * Get the last opened normal tab and the last tab with in progress media, if available. + * Get the last opened normal tab, last tab with in progress media and last search term group, if available. * - * @return A list of the last opened tab and the last tab with in progress media + * @return A list of the last opened tab, last tab with in progress media and last search term group * if distinct and available or an empty list. */ -fun BrowserState.asRecentTabs(): List { - return mutableListOf().apply { +fun BrowserState.asRecentTabs(): List { + return mutableListOf().apply { val lastOpenedNormalTab = lastOpenedNormalTab - lastOpenedNormalTab?.let { add(it) } - inProgressMediaTab - ?.takeUnless { it == lastOpenedNormalTab } - ?.let { - add(it) - } + + lastOpenedNormalTab?.let { add(RecentTab.Tab(it)) } + + lastSearchGroup?.let { add(it) } } } @@ -35,6 +38,15 @@ fun BrowserState.asRecentTabs(): List { val BrowserState.lastOpenedNormalTab: TabSessionState? get() = selectedNormalTab ?: normalTabs.maxByOrNull { it.lastAccess } +/** + * Get the second-to-last accessed normal tab. + */ +val BrowserState.secondToLastOpenedNormalTab: TabSessionState? + get() = when { + normalTabs.size <= 1 -> null + else -> normalTabs.sortedByDescending { it.lastAccess }[1] + } + /** * Get the last tab with in progress media. */ @@ -42,3 +54,48 @@ val BrowserState.inProgressMediaTab: TabSessionState? get() = normalTabs .filter { it.hasMediaPlayed() } .maxByOrNull { it.lastMediaAccessState.lastMediaAccess } + +/** + * Get the most recent search term group. + */ +val BrowserState.lastSearchGroup: RecentTab.SearchGroup? + get() { + val tabGroup = normalTabs.toSearchGroup().lastOrNull { it.tabs.count() > 1 } ?: return null + val firstTab = tabGroup.tabs.firstOrNull() ?: return null + + return RecentTab.SearchGroup( + tabGroup.searchTerm, + firstTab.id, + firstTab.content.url, + firstTab.content.thumbnail, + tabGroup.tabs.count() + ) + } + +/** + * Get search term groups sorted by last access time. + */ +fun List.toSearchGroup(): List { + val data = filter { + it.isNormalTabActiveWithSearchTerm(maxActiveTime) + }.groupBy { + when { + it.content.searchTerms.isNotBlank() -> it.content.searchTerms + else -> it.historyMetadata?.searchTerm ?: "" + }.lowercase() + } + + return data.map { mapEntry -> + val searchTerm = mapEntry.key.replaceFirstChar(Char::uppercase) + val groupTabs = mapEntry.value + val groupMax = groupTabs.fold(0L) { acc, tab -> + max(tab.lastAccess, acc) + } + + TabGroup( + searchTerm = searchTerm, + tabs = groupTabs, + lastAccess = groupMax + ) + }.sortedBy { it.lastAccess } +} diff --git a/app/src/main/java/org/mozilla/fenix/ext/Glean.kt b/app/src/main/java/org/mozilla/fenix/ext/Glean.kt deleted file mode 100644 index 9964aec421..0000000000 --- a/app/src/main/java/org/mozilla/fenix/ext/Glean.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.ext - -import mozilla.telemetry.glean.private.TimingDistributionMetricType - -/** - * A reimplementation of [TimingDistributionMetricType.measure] that address unintuitive - * issues around non-local returns: see https://bugzilla.mozilla.org/show_bug.cgi?id=1699505. - * This should be removed once that bug is resolved. That method's kdoc is as follows: - * - * Convenience method to simplify measuring a function or block of code. - * - * If the measured function throws, the measurement is canceled and the exception rethrown. - */ -@Suppress("TooGenericExceptionCaught") -fun TimingDistributionMetricType.measureNoInline(funcToMeasure: () -> U): U { - val timerId = start() - - val returnValue = try { - funcToMeasure() - } catch (e: Exception) { - cancel(timerId) - throw e - } - - stopAndAccumulate(timerId) - return returnValue -} diff --git a/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt b/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt new file mode 100644 index 0000000000..49212f58b6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.ext + +import androidx.annotation.VisibleForTesting +import mozilla.components.service.pocket.PocketRecommendedStory +import org.mozilla.fenix.home.HomeFragmentState +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory + +/** + * Get the list of stories to be displayed based on the user selected categories. + * + * @param neededStoriesCount how many stories are intended to be displayed. + * This only impacts filtered results guaranteeing an even spread of stories from each category. + * + * @return a list of [PocketRecommendedStory]es from the currently selected categories. + */ +fun HomeFragmentState.getFilteredStories( + neededStoriesCount: Int +): List { + if (pocketStoriesCategoriesSelections.isEmpty()) { + return pocketStoriesCategories + .find { + it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME + }?.stories + ?.sortedBy { it.timesShown } + ?.take(neededStoriesCount) ?: emptyList() + } + + val oldestSortedCategories = pocketStoriesCategoriesSelections + .sortedByDescending { it.selectionTimestamp } + .mapNotNull { selectedCategory -> + pocketStoriesCategories.find { + it.name == selectedCategory.name + } + } + + val filteredStoriesCount = getFilteredStoriesCount( + oldestSortedCategories, neededStoriesCount + ) + + return oldestSortedCategories + .flatMap { category -> + category.stories.sortedBy { it.timesShown }.take(filteredStoriesCount[category.name]!!) + }.take(neededStoriesCount) +} + +/** + * Get how many stories needs to be shown from each currently selected category. + * + * @param selectedCategories ordered list of categories from which to return results. + * @param neededStoriesCount how many stories are intended to be displayed. + * This impacts the results by guaranteeing an even spread of stories from each category in that stories count. + * + * @return a mapping of how many stories are to be shown from each category from [selectedCategories]. + */ +@VisibleForTesting +@Suppress("ReturnCount", "NestedBlockDepth") +internal fun getFilteredStoriesCount( + selectedCategories: List, + neededStoriesCount: Int +): Map { + val totalStoriesInFilteredCategories = selectedCategories.fold(0) { availableStories, category -> + availableStories + category.stories.size + } + + when (totalStoriesInFilteredCategories > neededStoriesCount) { + true -> { + val storiesCountFromEachCategory = mutableMapOf() + var currentFilteredStoriesCount = 0 + + for (i in 0 until selectedCategories.maxOf { it.stories.size }) { + selectedCategories.forEach { category -> + if (category.stories.getOrNull(i) != null) { + storiesCountFromEachCategory[category.name] = + storiesCountFromEachCategory[category.name]?.inc() ?: 1 + + if (++currentFilteredStoriesCount == neededStoriesCount) { + return storiesCountFromEachCategory + } + } + } + } + } + false -> { + return selectedCategories.map { it.name to it.stories.size }.toMap() + } + } + + return emptyMap() +} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataFeature.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataFeature.kt index 6d079ed320..d5655c92fb 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataFeature.kt +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataFeature.kt @@ -15,6 +15,9 @@ import mozilla.components.support.base.feature.LifecycleAwareFeature import org.mozilla.fenix.home.HomeFragment import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentStore +import kotlin.math.max + +private const val DEFAULT_MAX_RESULTS = 9 /** * View-bound feature that retrieves a list of history metadata and dispatches updates to the @@ -24,12 +27,15 @@ import org.mozilla.fenix.home.HomeFragmentStore * @param historyMetadataStorage The storage manages [HistoryMetadata]. * @param scope The [CoroutineScope] used to retrieve a list of history metadata. * @param ioDispatcher The [CoroutineDispatcher] for performing read/write operations. + * @param maxResults The maximum number of metadata groups that should be added to + * the store and displayed on the [HomeFragment]. */ class HistoryMetadataFeature( private val homeStore: HomeFragmentStore, private val historyMetadataStorage: HistoryMetadataStorage, private val scope: CoroutineScope, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val maxResults: Int = DEFAULT_MAX_RESULTS ) : LifecycleAwareFeature { private var job: Job? = null @@ -37,16 +43,33 @@ class HistoryMetadataFeature( override fun start() { job = scope.launch(ioDispatcher) { // For now, group the queried list of [HistoryMetadata] according to their search term. - // This feature will later be used to generate more groups. + // This feature will later be used to generate different groups and highlights. val historyMetadata = historyMetadataStorage.getHistoryMetadataSince(Long.MIN_VALUE) .filter { it.totalViewTime > 0 && it.key.searchTerm != null } .groupBy { it.key.searchTerm!! } + .mapValues { group -> + // Within a group, we dedupe entries based on their url so we don't display + // a page multiple times in the same group, and we sum up the total view time + // of deduped entries while making sure to keep the latest updatedAt value. + val metadataInGroup = group.value + val metadataUrlGroups = metadataInGroup.groupBy { metadata -> metadata.key.url } + metadataUrlGroups.map { metadata -> + metadata.value.reduce { acc, elem -> + acc.copy( + totalViewTime = acc.totalViewTime + elem.totalViewTime, + updatedAt = max(acc.updatedAt, elem.updatedAt) + ) + } + } + } .map { (title, data) -> HistoryMetadataGroup( title = title, historyMetadata = data ) } + .sortedByDescending { it.lastUpdated() } + .take(maxResults) homeStore.dispatch(HomeFragmentAction.HistoryMetadataChange(historyMetadata)) } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataGroup.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataGroup.kt index 801244a314..05ac7cdd0b 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataGroup.kt +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataGroup.kt @@ -11,10 +11,11 @@ import mozilla.components.concept.storage.HistoryMetadata * * @property title The title of the group. * @property historyMetadata A list of [HistoryMetadata] records that matches the title. - * @property expanded Whether or not the group is expanded. */ data class HistoryMetadataGroup( val title: String, - val historyMetadata: List, - val expanded: Boolean = false + val historyMetadata: List = emptyList() ) + +// The last updated time of the group is based on the most recently updated item in the group +fun HistoryMetadataGroup.lastUpdated(): Long = historyMetadata.maxOf { it.updatedAt } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddleware.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddleware.kt index 335206c823..c1c2a8cbd8 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddleware.kt +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddleware.kt @@ -6,6 +6,7 @@ package org.mozilla.fenix.historymetadata import mozilla.components.browser.state.action.BrowserAction import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.action.EngineAction import mozilla.components.browser.state.action.HistoryMetadataAction import mozilla.components.browser.state.action.MediaSessionAction import mozilla.components.browser.state.action.TabListAction @@ -29,6 +30,10 @@ class HistoryMetadataMiddleware( private val logger = Logger("HistoryMetadataMiddleware") + // Tracks whether a page load is in progress that was triggered directly by the app + // e.g. via the toolbar as opposed to via web content. + private var directLoadTriggered: Boolean = false + @Suppress("ComplexMethod") override fun invoke( context: MiddlewareContext, @@ -67,17 +72,28 @@ class HistoryMetadataMiddleware( } } } - is ContentAction.UpdateLoadingStateAction -> { + is ContentAction.UpdateUrlAction -> { context.state.findNormalTab(action.sessionId)?.let { tab -> val selectedTab = tab.id == context.state.selectedTabId - if (!tab.content.loading && action.loading && selectedTab) { - // When a page starts loading (e.g. user navigated away by - // clicking on a link) we update metadata for the selected - // (i.e. previous) url of this tab. + // When page url changes (e.g. user navigated away by clicking on a link) + // we update metadata for the selected (i.e. previous) url of this tab. + // We don't update metadata for cases or reload or restore. + // In case of a reload it's not necessary - metadata will be updated when + // user moves away from the page or tab. + // In case of restore, it's both unnecessary (like for a reload) and + // problematic, since our lastAccess time will be from before the tab was + // restored, resulting in an incorrect (too long) viewTime observation, as if + // the user was looking at the page while the browser wasn't even running. + if (selectedTab && action.url != tab.content.url) { updateHistoryMetadata(tab) } } } + is EngineAction.LoadUrlAction -> { + // This isn't an ideal fix as we shouldn't have to hold any state in the middleware: + // https://github.com/mozilla-mobile/android-components/issues/11034 + directLoadTriggered = true + } } next(action) @@ -86,19 +102,19 @@ class HistoryMetadataMiddleware( // changes introduced by the action. These handlers rely on up-to-date tab state, which // is why they're in the "post" section. when (action) { + is TabListAction.AddTabAction -> { + if (!action.tab.content.private) { + createHistoryMetadataIfNeeded(context, action.tab) + } + } // NB: sometimes this fires multiple times after the page finished loading. is ContentAction.UpdateHistoryStateAction -> { context.state.findNormalTab(action.sessionId)?.let { tab -> - // When history state is ready, we can record metadata for this page. - val knownHistoryMetadata = tab.historyMetadata - val metadataPresentForUrl = knownHistoryMetadata != null && - knownHistoryMetadata.url == tab.content.url - // Record metadata for tab if there is no metadata present, or if url of the - // tab changes since we last recorded metadata. - if (!metadataPresentForUrl) { - createHistoryMetadata(context, tab) - } + createHistoryMetadataIfNeeded(context, tab) } + + // Once we get a history update let's reset the flag for future loads. + directLoadTriggered = false } // NB: this could be called bunch of times in quick succession. is MediaSessionAction.UpdateMediaMetadataAction -> { @@ -109,26 +125,74 @@ class HistoryMetadataMiddleware( } } + private fun createHistoryMetadataIfNeeded( + context: MiddlewareContext, + tab: TabSessionState + ) { + // When history state is ready, we can record metadata for this page. + val knownHistoryMetadata = tab.historyMetadata + val metadataPresentForUrl = knownHistoryMetadata != null && + knownHistoryMetadata.url == tab.content.url + // Record metadata for tab if there is no metadata present, or if url of the + // tab changes since we last recorded metadata. + if (!metadataPresentForUrl) { + createHistoryMetadata(context, tab) + } + } + private fun createHistoryMetadata(context: MiddlewareContext, tab: TabSessionState) { val tabParent = tab.getParent(context.store) val previousUrlIndex = tab.content.history.currentIndex - 1 + val tabMetadataHasSearchTerms = !tab.historyMetadata?.searchTerm.isNullOrBlank() - // Obtain search terms and referrer url either from tab parent, or from the history stack. + // Obtain search terms and referrer url either from tab parent, from the history stack, or + // from the tab itself. + // At a high level, there are two main cases here - 1) either the tab was opened as a 'new tab' + // via the search results page, or 2) a page was opened in the same tab as the search results page. + // Details about the New Tab case: + // - we obtain search terms via tab's parent (the search results page) + // - however, it's possible that parent changed (e.g. user navigated away from the search + // results page). + // - our approach below is to capture search terms from the parent within the tab.historyMetadata + // state on the first load of the tab, and then rely on this data for subsequent page loads on that tab. + // - this way, once a tab becomes part of the search group, it won't leave this search group + // unless a direct navigation event happens. val (searchTerm, referrerUrl) = when { - tabParent != null -> { + // Loading page opened in a New Tab for the first time. + tabParent != null && !tabMetadataHasSearchTerms -> { val searchTerms = tabParent.content.searchTerms.takeUnless { it.isEmpty() } ?: context.state.search.parseSearchTerms(tabParent.content.url) searchTerms to tabParent.content.url } - previousUrlIndex >= 0 -> { - val previousUrl = tab.content.history.items[previousUrlIndex].uri - val searchTerms = context.state.search.parseSearchTerms(previousUrl) + // We only want to inspect the previous url in history if the user navigated via + // web content i.e., they followed a link, not if the user navigated directly via + // toolbar. + !directLoadTriggered && previousUrlIndex >= 0 -> { + // Once a tab is within the search group, only a direct load event (via the toolbar) can change that. + val (searchTerms, referrerUrl) = if (tabMetadataHasSearchTerms) { + tab.historyMetadata?.searchTerm to tab.historyMetadata?.referrerUrl + } else { + val previousUrl = tab.content.history.items[previousUrlIndex].uri + context.state.search.parseSearchTerms(previousUrl) to previousUrl + } + if (searchTerms != null) { - searchTerms to previousUrl + searchTerms to referrerUrl } else { null to null } } + // In certain redirect cases, we won't have a previous url in the history stack of the tab, + // but will have the search terms already set on the tab from having gone through this logic + // for the redirecting url. So we leave this tab within the search group it's already in + // unless a new direct load (via the toolbar) was triggered. + tabMetadataHasSearchTerms && !(directLoadTriggered && previousUrlIndex >= 0) -> { + tab.historyMetadata?.searchTerm to tab.historyMetadata?.referrerUrl + } + // We had no search terms, no history stack, and no parent. + // This would be the case for any page loaded directly via the toolbar including + // a search results page itself. For now, the original search results page is not + // part of the search group: https://github.com/mozilla-mobile/fenix/issues/21659. else -> null to null } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataService.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataService.kt index 6f9df959ae..5b708a2b07 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataService.kt +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataService.kt @@ -5,7 +5,7 @@ package org.mozilla.fenix.historymetadata import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.launch import mozilla.components.browser.state.state.TabSessionState import mozilla.components.concept.storage.DocumentType @@ -13,6 +13,8 @@ import mozilla.components.concept.storage.HistoryMetadataKey import mozilla.components.concept.storage.HistoryMetadataObservation import mozilla.components.concept.storage.HistoryMetadataStorage import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.utils.NamedThreadFactory +import java.util.concurrent.Executors /** * Service for managing (creating, updating, deleting) history metadata. @@ -51,11 +53,18 @@ interface HistoryMetadataService { class DefaultHistoryMetadataService( private val storage: HistoryMetadataStorage, - private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) + private val scope: CoroutineScope = CoroutineScope( + Executors.newSingleThreadExecutor( + NamedThreadFactory("HistoryMetadataService") + ).asCoroutineDispatcher() + ) ) : HistoryMetadataService { private val logger = Logger("DefaultHistoryMetadataService") + // NB: this map is only accessed from a single-thread executor (dispatcher of `scope`). + private val tabsLastUpdated = mutableMapOf() + override fun createMetadata(tab: TabSessionState, searchTerms: String?, referrerUrl: String?): HistoryMetadataKey { logger.debug("Creating metadata for tab ${tab.id}") @@ -81,13 +90,33 @@ class DefaultHistoryMetadataService( } override fun updateMetadata(key: HistoryMetadataKey, tab: TabSessionState) { - logger.debug("Updating metadata for tab $tab") + val now = System.currentTimeMillis() + val lastAccess = tab.lastAccess + if (lastAccess == 0L) { + logger.debug("Not updating metadata for tab $tab - lastAccess=0") + return + } else { + logger.debug("Updating metadata for tab $tab") + } + // If it's possible that multiple threads overlap and run this block simultaneously, we + // may over-observe, and record when we didn't intend to. + // To make these cases easier to reason through (and likely correct), + // `scope` is a single-threaded dispatcher. Execution of these blocks is thus serialized. scope.launch { + val lastUpdated = tabsLastUpdated[tab.id] ?: 0 + if (lastUpdated > lastAccess) { + logger.debug( + "Failed to update metadata because it was already recorded or lastAccess is incorrect" + ) + return@launch + } + val viewTimeObservation = HistoryMetadataObservation.ViewTimeObservation( - viewTime = (System.currentTimeMillis() - tab.lastAccess).toInt() + viewTime = (now - lastAccess).toInt() ) storage.noteHistoryMetadataObservation(key, viewTimeObservation) + tabsLastUpdated[tab.id] = now } } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataController.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataController.kt index 6d783ffd11..489be6c6a9 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataController.kt +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataController.kt @@ -7,17 +7,20 @@ package org.mozilla.fenix.historymetadata.controller import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.PRIVATE import androidx.navigation.NavController -import mozilla.components.concept.storage.HistoryMetadataKey -import mozilla.components.feature.tabs.TabsUseCases -import org.mozilla.fenix.BrowserDirection -import org.mozilla.fenix.HomeActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import mozilla.components.browser.state.action.HistoryMetadataAction +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.storage.HistoryMetadataStorage import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.historymetadata.HistoryMetadataGroup import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentDirections import org.mozilla.fenix.home.HomeFragmentStore -import org.mozilla.fenix.utils.Settings +import org.mozilla.fenix.library.history.toHistoryMetadata /** * An interface that handles the view manipulation of the history metadata in the Home screen. @@ -25,48 +28,33 @@ import org.mozilla.fenix.utils.Settings interface HistoryMetadataController { /** - * @see [HistoryMetadataInteractor.onHistoryMetadataItemClicked] + * @see [HistoryMetadataInteractor.onHistoryMetadataShowAllClicked] */ - fun handleHistoryMetadataItemClicked(url: String, historyMetadata: HistoryMetadataKey) + fun handleHistoryShowAllClicked() /** - * @see [HistoryMetadataInteractor.onHistoryMetadataShowAllClicked] + * @see [HistoryMetadataInteractor.onHistoryMetadataGroupClicked] */ - fun handleHistoryShowAllClicked() + fun handleHistoryMetadataGroupClicked(historyMetadataGroup: HistoryMetadataGroup) /** - * @see [HistoryMetadataInteractor.onToggleHistoryMetadataGroupExpanded] + * @see [HistoryMetadataInteractor.onRemoveGroup] */ - fun handleToggleHistoryMetadataGroupExpanded(historyMetadataGroup: HistoryMetadataGroup) + fun handleRemoveGroup(searchTerm: String) } /** * The default implementation of [HistoryMetadataController]. */ class DefaultHistoryMetadataController( - private val activity: HomeActivity, - private val settings: Settings, - private val homeFragmentStore: HomeFragmentStore, - private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase, - private val navController: NavController + private val store: BrowserStore, + private val homeStore: HomeFragmentStore, + private val navController: NavController, + private val storage: HistoryMetadataStorage, + private val scope: CoroutineScope, + private val metrics: MetricController ) : HistoryMetadataController { - override fun handleHistoryMetadataItemClicked( - url: String, - historyMetadata: HistoryMetadataKey - ) { - val tabId = selectOrAddUseCase.invoke( - url = url, - historyMetadata = historyMetadata - ) - - if (settings.openNextTabInDesktopMode) { - activity.handleRequestDesktopMode(tabId) - } - - activity.openToBrowser(BrowserDirection.FromHome) - } - override fun handleHistoryShowAllClicked() { dismissSearchDialogIfDisplayed() navController.navigate( @@ -74,14 +62,29 @@ class DefaultHistoryMetadataController( ) } - override fun handleToggleHistoryMetadataGroupExpanded(historyMetadataGroup: HistoryMetadataGroup) { - homeFragmentStore.dispatch( - HomeFragmentAction.HistoryMetadataExpanded( - historyMetadataGroup + override fun handleHistoryMetadataGroupClicked(historyMetadataGroup: HistoryMetadataGroup) { + navController.navigate( + HomeFragmentDirections.actionGlobalHistoryMetadataGroup( + title = historyMetadataGroup.title, + historyMetadataItems = historyMetadataGroup.historyMetadata + .map { it.toHistoryMetadata() }.toTypedArray() ) ) } + override fun handleRemoveGroup(searchTerm: String) { + // We want to update the UI right away in response to user action without waiting for the IO. + // First, dispatch actions that will clean up search groups in the two stores that have + // metadata-related state. + store.dispatch(HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm)) + homeStore.dispatch(HomeFragmentAction.DisbandSearchGroupAction(searchTerm = searchTerm)) + // Then, perform the expensive IO work of removing search groups from storage. + scope.launch { + storage.deleteHistoryMetadata(searchTerm) + } + metrics.track(Event.RecentSearchesGroupDeleted) + } + @VisibleForTesting(otherwise = PRIVATE) fun dismissSearchDialogIfDisplayed() { if (navController.currentDestination?.id == R.id.searchDialogFragment) { diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractor.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractor.kt index 6f2e085f4a..adb8b006ae 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractor.kt @@ -4,7 +4,6 @@ package org.mozilla.fenix.historymetadata.interactor -import mozilla.components.concept.storage.HistoryMetadataKey import org.mozilla.fenix.historymetadata.HistoryMetadataGroup /** @@ -12,15 +11,6 @@ import org.mozilla.fenix.historymetadata.HistoryMetadataGroup */ interface HistoryMetadataInteractor { - /** - * Selects an existing tab with the matching [HistoryMetadataKey] or adds a new tab with the - * given [url]. Called when a user clicks on a history metadata item. - * - * @param url The URL to open. - * @param historyMetadata The [HistoryMetadataKey] to match for an existing tab. - */ - fun onHistoryMetadataItemClicked(url: String, historyMetadata: HistoryMetadataKey) - /** * Shows the history fragment. Called when a user clicks on the "Show all" button besides the * history metadata header. @@ -28,10 +18,17 @@ interface HistoryMetadataInteractor { fun onHistoryMetadataShowAllClicked() /** - * Toggles whether or not a history metadata group is expanded. Called when a user clicks on - * a history metadata group. + * Navigates to the history metadata group fragment to display the group. Called when a user + * clicks on a history metadata group. * * @param historyMetadataGroup The [HistoryMetadataGroup] to toggle its expanded state. */ - fun onToggleHistoryMetadataGroupExpanded(historyMetadataGroup: HistoryMetadataGroup) + fun onHistoryMetadataGroupClicked(historyMetadataGroup: HistoryMetadataGroup) + + /** + * Removes a history metadata group with the given search term from the homescreen. + * + * @param searchTerm The search term to be removed. + */ + fun onRemoveGroup(searchTerm: String) } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataGroupViewHolder.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataGroupViewHolder.kt index 5a103db523..398184241f 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataGroupViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataGroupViewHolder.kt @@ -5,34 +5,64 @@ package org.mozilla.fenix.historymetadata.view import android.view.View -import kotlinx.android.synthetic.main.history_metadata_group.* +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import mozilla.components.lib.state.ext.observeAsComposableState import org.mozilla.fenix.R -import org.mozilla.fenix.historymetadata.HistoryMetadataGroup +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor +import org.mozilla.fenix.home.HomeFragmentStore +import org.mozilla.fenix.theme.FirefoxTheme import org.mozilla.fenix.utils.view.ViewHolder /** * View holder for a history metadata group item. * - * @property interactor [HistoryMetadataInteractor] which will have delegated to all user - * interactions. + * @param composeView [ComposeView] which will be populated with Jetpack Compose UI content. + * @param store [HomeFragmentStore] containing the list of history metadata groups to be displayed. + * @property interactor [HistoryMetadataInteractor] which will have delegated to all user interactions. + * @property metrics [MetricController] that handles telemetry events. */ class HistoryMetadataGroupViewHolder( - view: View, - private val interactor: HistoryMetadataInteractor -) : ViewHolder(view) { + val composeView: ComposeView, + private val store: HomeFragmentStore, + private val interactor: HistoryMetadataInteractor, + private val metrics: MetricController +) : ViewHolder(composeView) { - fun bind(historyMetadataGroup: HistoryMetadataGroup) { - history_metadata_group_title.text = historyMetadataGroup.title + init { + val horizontalPadding = composeView.resources.getDimensionPixelSize(R.dimen.home_item_horizontal_margin) + composeView.setPadding(horizontalPadding, 0, horizontalPadding, 0) - itemView.isActivated = historyMetadataGroup.expanded + composeView.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + composeView.setContent { + val recentVisits = store.observeAsComposableState { state -> state.historyMetadata } - itemView.setOnClickListener { - interactor.onToggleHistoryMetadataGroupExpanded(historyMetadataGroup) + FirefoxTheme { + RecentlyVisited( + recentVisits = recentVisits.value ?: emptyList(), + menuItems = listOfNotNull( + RecentVisitMenuItem( + title = stringResource(R.string.recently_visited_menu_item_remove), + onClick = { group -> + interactor.onRemoveGroup(group.title) + } + ) + ), + onRecentVisitClick = { historyMetadataGroup, pageNumber -> + metrics.track(Event.HistoryRecentSearchesTapped(pageNumber.toString())) + interactor.onHistoryMetadataGroupClicked(historyMetadataGroup) + } + ) + } } } companion object { - const val LAYOUT_ID = R.layout.history_metadata_group + val LAYOUT_ID = View.generateViewId() } } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolder.kt index 6118dbf7de..d8186aa612 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolder.kt @@ -5,8 +5,8 @@ package org.mozilla.fenix.historymetadata.view import android.view.View -import kotlinx.android.synthetic.main.history_metadata_header.* import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.HistoryMetadataHeaderBinding import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor import org.mozilla.fenix.utils.view.ViewHolder @@ -22,7 +22,8 @@ class HistoryMetadataHeaderViewHolder( ) : ViewHolder(view) { init { - show_all_button.setOnClickListener { + val binding = HistoryMetadataHeaderBinding.bind(view) + binding.showAllButton.setOnClickListener { interactor.onHistoryMetadataShowAllClicked() } } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataViewHolder.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataViewHolder.kt deleted file mode 100644 index 43f0dff7f1..0000000000 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataViewHolder.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.historymetadata.view - -import android.view.View -import kotlinx.android.synthetic.main.history_metadata_list_row.* -import mozilla.components.browser.icons.BrowserIcons -import mozilla.components.browser.state.state.ContentState -import mozilla.components.concept.storage.HistoryMetadata -import org.mozilla.fenix.R -import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.loadIntoView -import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor -import org.mozilla.fenix.utils.view.ViewHolder - -/** - * View holder for a history metadata item. - * - * @property interactor [HistoryMetadataInteractor] which will have delegated to all user - * interactions. - * @property icons an instance of [BrowserIcons] for rendering the sites icon if one isn't found - * in [ContentState.icon]. - */ -class HistoryMetadataViewHolder( - view: View, - private val interactor: HistoryMetadataInteractor, - private val icons: BrowserIcons = view.context.components.core.icons -) : ViewHolder(view) { - - fun bind(historyMetadata: HistoryMetadata) { - history_metadata_title.text = if (historyMetadata.title.isNullOrEmpty()) { - historyMetadata.key.url - } else { - historyMetadata.title - } - - icons.loadIntoView(history_metadata_icon, historyMetadata.key.url) - - itemView.setOnClickListener { - interactor.onHistoryMetadataItemClicked(historyMetadata.key.url, historyMetadata.key) - } - } - - companion object { - const val LAYOUT_ID = R.layout.history_metadata_list_row - } -} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentVisitMenuItem.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentVisitMenuItem.kt new file mode 100644 index 0000000000..d0edcf2df3 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentVisitMenuItem.kt @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.historymetadata.view + +import org.mozilla.fenix.historymetadata.HistoryMetadataGroup + +/** + * A menu item in the recent visit dropdown menu. + * + * @property title The menu item title. + * @property onClick Invoked when the user clicks on the menu item. + */ +data class RecentVisitMenuItem( + val title: String, + val onClick: (HistoryMetadataGroup) -> Unit +) diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentlyVisited.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentlyVisited.kt new file mode 100644 index 0000000000..06da9eea56 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentlyVisited.kt @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.historymetadata.view + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.mozilla.fenix.R +import org.mozilla.fenix.historymetadata.HistoryMetadataGroup +import org.mozilla.fenix.theme.FirefoxTheme + +// Number of recently visited items per column. +private const val VISITS_PER_COLUMN = 3 + +/** + * A list of recently visited items. + * + * @param recentVisits List of [HistoryMetadataGroup] to display. + * @param menuItems List of [RecentVisitMenuItem] to display in a recent visit dropdown menu. + * @param onRecentVisitClick Invoked when the user clicks on a recent visit. + */ +@Composable +fun RecentlyVisited( + recentVisits: List, + menuItems: List, + onRecentVisitClick: (HistoryMetadataGroup, Int) -> Unit = { _, _ -> } +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + backgroundColor = FirefoxTheme.colors.surface, + elevation = 6.dp + ) { + LazyRow( + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + val itemsList = recentVisits.chunked(VISITS_PER_COLUMN) + + itemsIndexed(itemsList) { pageIndex, items -> + Column( + modifier = Modifier.fillMaxWidth() + ) { + items.forEachIndexed { index, recentVisit -> + RecentVisitItem( + recentVisit = recentVisit, + menuItems = menuItems, + showDividerLine = index < items.size - 1, + onRecentVisitClick = onRecentVisitClick, + pageNumber = pageIndex + 1 + ) + } + } + } + } + } +} + +/** + * A recent visit item. + * + * @param recentVisit The [HistoryMetadataGroup] to display. + * @param menuItems List of [RecentVisitMenuItem] to display in a recent visit dropdown menu. + * @param onRecentVisitClick Invoked when the user clicks on a recent visit. + * @param pageNumber which page is the item on. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RecentVisitItem( + recentVisit: HistoryMetadataGroup, + menuItems: List, + showDividerLine: Boolean, + onRecentVisitClick: (HistoryMetadataGroup, Int) -> Unit = { _, _ -> }, + pageNumber: Int +) { + var menuExpanded by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .combinedClickable( + onClick = { onRecentVisitClick(recentVisit, pageNumber) }, + onLongClick = { menuExpanded = true } + ) + .size(268.dp, 56.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_multiple_tabs), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.fillMaxSize() + ) { + Text( + text = recentVisit.title, + modifier = Modifier.padding(top = 7.dp, bottom = 2.dp), + color = FirefoxTheme.colors.textPrimary, + fontSize = 16.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + + RecentlyVisitedCaption(recentVisit.historyMetadata.size) + + if (showDividerLine) { + Divider( + modifier = Modifier.padding(top = 9.dp), + color = FirefoxTheme.colors.dividerLine, + thickness = 0.5.dp + ) + } + } + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.background(color = FirefoxTheme.colors.surface) + .height(52.dp) + .scrollable( + state = ScrollState(0), + orientation = Orientation.Vertical, + enabled = false + ) + ) { + for (item in menuItems) { + DropdownMenuItem( + onClick = { + menuExpanded = false + item.onClick(recentVisit) + }, + modifier = Modifier.fillMaxHeight() + ) { + Text( + text = item.title, + color = FirefoxTheme.colors.textPrimary, + maxLines = 1, + modifier = Modifier.align(Alignment.Top) + .padding(top = 6.dp) + .scrollable( + state = ScrollState(0), + orientation = Orientation.Vertical, + enabled = false + ).fillMaxHeight() + ) + } + } + } + } +} + +/** + * The caption text for a recent visit. + * + * @param count Number of recently visited items to display in the caption. + */ +@Composable +private fun RecentlyVisitedCaption(count: Int) { + val stringId = if (count == 1) { + R.string.history_search_group_site + } else { + R.string.history_search_group_sites + } + + Text( + text = String.format(LocalContext.current.getString(stringId), count), + color = FirefoxTheme.colors.textSecondary, + fontSize = 12.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) +} + +@ExperimentalFoundationApi +@Composable +@Preview +private fun RecentlyVisitedPreview() { + FirefoxTheme { + RecentlyVisited( + recentVisits = listOf( + HistoryMetadataGroup(title = "running shoes"), + HistoryMetadataGroup(title = "mozilla"), + HistoryMetadataGroup(title = "firefox"), + HistoryMetadataGroup(title = "pocket") + ), + menuItems = emptyList() + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index 962cb17f08..4528215437 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -5,8 +5,8 @@ package org.mozilla.fenix.home import android.animation.Animator +import android.annotation.SuppressLint import android.content.Context -import android.content.DialogInterface import android.content.res.Configuration import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable @@ -22,7 +22,6 @@ import android.widget.Button import android.widget.LinearLayout import android.widget.PopupWindow import androidx.annotation.VisibleForTesting -import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet @@ -43,10 +42,8 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.fragment_home.* -import kotlinx.android.synthetic.main.fragment_home.view.* -import kotlinx.android.synthetic.main.no_collections_message.view.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -78,12 +75,12 @@ import mozilla.components.ui.tabcounter.TabCounterMenu import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.Config import org.mozilla.fenix.FeatureFlags -import org.mozilla.fenix.GleanMetrics.PerfStartup import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.BrowserAnimator.Companion.getToolbarNavOptions import org.mozilla.fenix.browser.BrowserFragmentDirections import org.mozilla.fenix.browser.browsingmode.BrowsingMode +import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.PrivateShortcutCreateManager import org.mozilla.fenix.components.StoreProvider @@ -95,11 +92,15 @@ import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.components.tips.providers.MasterPasswordTipProvider import org.mozilla.fenix.components.toolbar.FenixTabCounterMenu import org.mozilla.fenix.components.toolbar.ToolbarPosition +import org.mozilla.fenix.databinding.FragmentHomeBinding +import org.mozilla.fenix.datastore.pocketStoriesSelectedCategoriesDataStore +import org.mozilla.fenix.ext.asRecentTabs +import org.mozilla.fenix.experiments.FeatureId import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.hideToolbar -import org.mozilla.fenix.ext.measureNoInline import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.nav +import org.mozilla.fenix.ext.recordExposureEvent import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.runIfFragmentIsAttached import org.mozilla.fenix.ext.settings @@ -108,12 +109,15 @@ import org.mozilla.fenix.historymetadata.controller.DefaultHistoryMetadataContro import org.mozilla.fenix.home.mozonline.showPrivacyPopWindow import org.mozilla.fenix.home.recentbookmarks.RecentBookmarksFeature import org.mozilla.fenix.home.recentbookmarks.controller.DefaultRecentBookmarksController +import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recenttabs.RecentTabsListFeature import org.mozilla.fenix.home.recenttabs.controller.DefaultRecentTabsController import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor import org.mozilla.fenix.home.sessioncontrol.SessionControlView import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.DefaultPocketStoriesController +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.DefaultTopSitesView import org.mozilla.fenix.onboarding.FenixOnboarding import org.mozilla.fenix.settings.SupportUtils @@ -132,20 +136,24 @@ class HomeFragment : Fragment() { private val args by navArgs() private lateinit var bundleArgs: Bundle + private var _binding: FragmentHomeBinding? = null + private val binding get() = _binding!! + private val homeViewModel: HomeScreenViewModel by activityViewModels() private val snackbarAnchorView: View? get() = when (requireContext().settings().toolbarPosition) { - ToolbarPosition.BOTTOM -> toolbarLayout + ToolbarPosition.BOTTOM -> binding.toolbarLayout ToolbarPosition.TOP -> null } private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager private val collectionStorageObserver = object : TabCollectionStorage.Observer { + @SuppressLint("NotifyDataSetChanged") override fun onCollectionRenamed(tabCollection: TabCollection, title: String) { lifecycleScope.launch(Main) { - view?.sessionControlRecyclerView?.adapter?.notifyDataSetChanged() + binding.sessionControlRecyclerView.adapter?.notifyDataSetChanged() } showRenamedSnackbar() } @@ -175,7 +183,7 @@ class HomeFragment : Fragment() { private val historyMetadataFeature = ViewBoundFeatureWrapper() @VisibleForTesting - internal var getMenuButton: () -> MenuButton? = { menuButton } + internal var getMenuButton: () -> MenuButton? = { binding.menuButton } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -200,13 +208,13 @@ class HomeFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? = PerfStartup.homeFragmentOnCreateView.measureNoInline { - val view = inflater.inflate(R.layout.fragment_home, container, false) + ): View { + _binding = FragmentHomeBinding.inflate(inflater, container, false) val activity = activity as HomeActivity val components = requireComponents currentMode = CurrentMode( - view.context, + requireContext(), onboarding, browsingModeManager, ::dispatchModeChanges @@ -233,12 +241,34 @@ class HomeFragment : Fragment() { recentBookmarks = emptyList(), showCollectionPlaceholder = components.settings.showCollectionsPlaceholderOnHome, showSetAsDefaultBrowserCard = components.settings.shouldShowSetAsDefaultBrowserCard(), - recentTabs = emptyList(), + // Provide an initial state for recent tabs to prevent re-rendering on the home screen. + // This will otherwise cause a visual jump as the section gets rendered from no state + // to some state. + recentTabs = getRecentTabs(components), historyMetadata = emptyList() + ), + listOf( + PocketUpdatesMiddleware( + lifecycleScope, + requireComponents.core.pocketStoriesService, + requireContext().pocketStoriesSelectedCategoriesDataStore + ) ) ) } + lifecycleScope.launch(IO) { + if (requireContext().settings().showPocketRecommendationsFeature) { + val categories = components.core.pocketStoriesService.getStories() + .groupBy { story -> story.category } + .map { (category, stories) -> PocketRecommendedStoriesCategory(category, stories) } + + homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesCategoriesChange(categories)) + } else { + homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesChange(emptyList())) + } + } + topSitesFeature.set( feature = TopSitesFeature( view = DefaultTopSitesView(homeFragmentStore), @@ -246,21 +276,21 @@ class HomeFragment : Fragment() { config = ::getTopSitesConfig ), owner = viewLifecycleOwner, - view = view + view = binding.root ) - if (FeatureFlags.showRecentTabsFeature) { + if (requireContext().settings().showRecentTabsFeature) { recentTabsListFeature.set( feature = RecentTabsListFeature( browserStore = components.core.store, homeStore = homeFragmentStore ), owner = viewLifecycleOwner, - view = view + view = binding.root ) } - if (FeatureFlags.recentBookmarksFeature) { + if (requireContext().settings().showRecentBookmarksFeature) { recentBookmarksFeature.set( feature = RecentBookmarksFeature( homeStore = homeFragmentStore, @@ -270,11 +300,11 @@ class HomeFragment : Fragment() { scope = viewLifecycleOwner.lifecycleScope ), owner = viewLifecycleOwner, - view = view + view = binding.root ) } - if (requireContext().settings().historyMetadataFeature) { + if (requireContext().settings().historyMetadataUIFeature) { historyMetadataFeature.set( feature = HistoryMetadataFeature( homeStore = homeFragmentStore, @@ -282,7 +312,7 @@ class HomeFragment : Fragment() { scope = viewLifecycleOwner.lifecycleScope ), owner = viewLifecycleOwner, - view = view + view = binding.root ) } @@ -303,7 +333,7 @@ class HomeFragment : Fragment() { viewLifecycleScope = viewLifecycleOwner.lifecycleScope, hideOnboarding = ::hideOnboardingAndOpenSearch, registerCollectionStorageObserver = ::registerCollectionStorageObserver, - showDeleteCollectionPrompt = ::showDeleteCollectionPrompt, + removeCollectionWithUndo = ::removeCollectionWithUndo, showTabTray = ::openTabsTray, handleSwipedItemDeletionCancel = ::handleSwipedItemDeletionCancel ), @@ -318,28 +348,38 @@ class HomeFragment : Fragment() { navController = findNavController() ), historyMetadataController = DefaultHistoryMetadataController( - activity = activity, - settings = components.settings, - homeFragmentStore = homeFragmentStore, - selectOrAddUseCase = components.useCases.tabsUseCases.selectOrAddTab, - navController = findNavController() + navController = findNavController(), + homeStore = homeFragmentStore, + storage = components.core.historyStorage, + scope = viewLifecycleOwner.lifecycleScope, + store = components.core.store, + metrics = requireComponents.analytics.metrics + ), + pocketStoriesController = DefaultPocketStoriesController( + homeActivity = activity, + homeStore = homeFragmentStore, + navController = findNavController(), + metrics = requireComponents.analytics.metrics ) ) - updateLayout(view) + updateLayout(binding.root) sessionControlView = SessionControlView( - view.sessionControlRecyclerView, + homeFragmentStore, + binding.sessionControlRecyclerView, viewLifecycleOwner, sessionControlInteractor, homeViewModel ) - updateSessionControlView(view) + updateSessionControlView() - appBarLayout = view.homeAppBar + appBarLayout = binding.homeAppBar activity.themeManager.applyStatusBarTheme(activity) - view + + requireContext().components.analytics.experiments.recordExposureEvent(FeatureId.HOME_PAGE) + return binding.root } override fun onConfigurationChanged(newConfig: Configuration) { @@ -371,24 +411,24 @@ class HomeFragment : Fragment() { * data in our store. The [View.consumeFrom] coroutine dispatch * doesn't get run right away which means that we won't draw on the first layout pass. */ - private fun updateSessionControlView(view: View) { + private fun updateSessionControlView() { if (browsingModeManager.mode == BrowsingMode.Private) { - view.consumeFrom(homeFragmentStore, viewLifecycleOwner) { + binding.root.consumeFrom(homeFragmentStore, viewLifecycleOwner) { sessionControlView?.update(it) } } else { sessionControlView?.update(homeFragmentStore.state) - view.consumeFrom(homeFragmentStore, viewLifecycleOwner) { - sessionControlView?.update(it) + binding.root.consumeFrom(homeFragmentStore, viewLifecycleOwner) { + sessionControlView?.update(it, shouldReportMetrics = true) } } } private fun updateLayout(view: View) { - when (view.context.settings().toolbarPosition) { + when (requireContext().settings().toolbarPosition) { ToolbarPosition.TOP -> { - view.toolbarLayout.layoutParams = CoordinatorLayout.LayoutParams( + binding.toolbarLayout.layoutParams = CoordinatorLayout.LayoutParams( ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.WRAP_CONTENT ).apply { @@ -396,21 +436,21 @@ class HomeFragment : Fragment() { } ConstraintSet().apply { - clone(view.toolbarLayout) - clear(view.bottom_bar.id, BOTTOM) - clear(view.bottomBarShadow.id, BOTTOM) - connect(view.bottom_bar.id, TOP, PARENT_ID, TOP) - connect(view.bottomBarShadow.id, TOP, view.bottom_bar.id, BOTTOM) - connect(view.bottomBarShadow.id, BOTTOM, PARENT_ID, BOTTOM) - applyTo(view.toolbarLayout) + clone(binding.toolbarLayout) + clear(binding.bottomBar.id, BOTTOM) + clear(binding.bottomBarShadow.id, BOTTOM) + connect(binding.bottomBar.id, TOP, PARENT_ID, TOP) + connect(binding.bottomBarShadow.id, TOP, binding.bottomBar.id, BOTTOM) + connect(binding.bottomBarShadow.id, BOTTOM, PARENT_ID, BOTTOM) + applyTo(binding.toolbarLayout) } - view.bottom_bar.background = AppCompatResources.getDrawable( + binding.bottomBar.background = AppCompatResources.getDrawable( view.context, view.context.theme.resolveAttribute(R.attr.bottomBarBackgroundTop) ) - view.homeAppBar.updateLayoutParams { + binding.homeAppBar.updateLayoutParams { topMargin = resources.getDimensionPixelSize(R.dimen.home_fragment_top_toolbar_header_margin) } @@ -421,87 +461,78 @@ class HomeFragment : Fragment() { } @Suppress("LongMethod", "ComplexMethod") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) = - PerfStartup.homeFragmentOnViewCreated.measureNoInline { - super.onViewCreated(view, savedInstanceState) - context?.metrics?.track(Event.HomeScreenDisplayed) - - observeSearchEngineChanges() - createHomeMenu(requireContext(), WeakReference(view.menuButton)) - createTabCounterMenu(view) - - view.menuButton.setColorFilter( - ContextCompat.getColor( - requireContext(), - ThemeManager.resolveAttribute(R.attr.primaryText, requireContext()) - ) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + context?.metrics?.track(Event.HomeScreenDisplayed) + + observeSearchEngineChanges() + createHomeMenu(requireContext(), WeakReference(binding.menuButton)) + createTabCounterMenu() + + binding.menuButton.setColorFilter( + ContextCompat.getColor( + requireContext(), + ThemeManager.resolveAttribute(R.attr.primaryText, requireContext()) ) + ) - view.toolbar.compoundDrawablePadding = - view.resources.getDimensionPixelSize(R.dimen.search_bar_search_engine_icon_padding) - view.toolbar_wrapper.setOnClickListener { - navigateToSearch() - requireComponents.analytics.metrics.track(Event.SearchBarTapped(Event.SearchBarTapped.Source.HOME)) - } + binding.toolbar.compoundDrawablePadding = + view.resources.getDimensionPixelSize(R.dimen.search_bar_search_engine_icon_padding) + binding.toolbarWrapper.setOnClickListener { + navigateToSearch() + requireComponents.analytics.metrics.track(Event.SearchBarTapped(Event.SearchBarTapped.Source.HOME)) + } - view.toolbar_wrapper.setOnLongClickListener { - ToolbarPopupWindow.show( - WeakReference(it), - handlePasteAndGo = sessionControlInteractor::onPasteAndGo, - handlePaste = sessionControlInteractor::onPaste, - copyVisible = false - ) - true - } + binding.toolbarWrapper.setOnLongClickListener { + ToolbarPopupWindow.show( + WeakReference(it), + handlePasteAndGo = sessionControlInteractor::onPasteAndGo, + handlePaste = sessionControlInteractor::onPaste, + copyVisible = false + ) + true + } - view.tab_button.setOnClickListener { - if (FeatureFlags.showStartOnHomeSettings) { - requireComponents.analytics.metrics.track(Event.StartOnHomeOpenTabsTray) - } - openTabsTray() + binding.tabButton.setOnClickListener { + if (FeatureFlags.showStartOnHomeSettings) { + requireComponents.analytics.metrics.track(Event.StartOnHomeOpenTabsTray) } + openTabsTray() + } - PrivateBrowsingButtonView( - privateBrowsingButton, - browsingModeManager - ) { newMode -> - if (newMode == BrowsingMode.Private) { - requireContext().settings().incrementNumTimesPrivateModeOpened() - } - - if (onboarding.userHasBeenOnboarded()) { - homeFragmentStore.dispatch( - HomeFragmentAction.ModeChange(Mode.fromBrowsingMode(newMode)) - ) - } - } + PrivateBrowsingButtonView(binding.privateBrowsingButton, browsingModeManager) { newMode -> + sessionControlInteractor.onPrivateModeButtonClicked( + newMode, + onboarding.userHasBeenOnboarded() + ) + } - consumeFrom(requireComponents.core.store) { - updateTabCounter(it) - } + consumeFrom(requireComponents.core.store) { + updateTabCounter(it) + } - homeViewModel.sessionToDelete?.also { - if (it == ALL_NORMAL_TABS || it == ALL_PRIVATE_TABS) { - removeAllTabsAndShowSnackbar(it) - } else { - removeTabAndShowSnackbar(it) - } + homeViewModel.sessionToDelete?.also { + if (it == ALL_NORMAL_TABS || it == ALL_PRIVATE_TABS) { + removeAllTabsAndShowSnackbar(it) + } else { + removeTabAndShowSnackbar(it) } + } - homeViewModel.sessionToDelete = null + homeViewModel.sessionToDelete = null - updateTabCounter(requireComponents.core.store.state) + updateTabCounter(requireComponents.core.store.state) - if (bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR)) { - navigateToSearch() - } else if (bundleArgs.getLong(FOCUS_ON_COLLECTION, -1) >= 0) { - // No need to scroll to async'd loaded TopSites if we want to scroll to collections. - homeViewModel.shouldScrollToTopSites = false - /* Triggered when the user has added a tab to a collection and has tapped - * the View action on the [TabsTrayDialogFragment] snackbar.*/ - scrollAndAnimateCollection(bundleArgs.getLong(FOCUS_ON_COLLECTION, -1)) - } + if (bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR)) { + navigateToSearch() + } else if (bundleArgs.getLong(FOCUS_ON_COLLECTION, -1) >= 0) { + // No need to scroll to async'd loaded TopSites if we want to scroll to collections. + homeViewModel.shouldScrollToTopSites = false + /* Triggered when the user has added a tab to a collection and has tapped + * the View action on the [TabsTrayDialogFragment] snackbar.*/ + scrollAndAnimateCollection(bundleArgs.getLong(FOCUS_ON_COLLECTION, -1)) } + } private fun observeSearchEngineChanges() { consumeFlow(store) { flow -> @@ -514,15 +545,15 @@ class HomeFragment : Fragment() { val searchIcon = BitmapDrawable(requireContext().resources, searchEngine.icon) searchIcon.setBounds(0, 0, iconSize, iconSize) - search_engine_icon?.setImageDrawable(searchIcon) + binding.searchEngineIcon.setImageDrawable(searchIcon) } else { - search_engine_icon.setImageDrawable(null) + binding.searchEngineIcon.setImageDrawable(null) } } } } - private fun createTabCounterMenu(view: View) { + private fun createTabCounterMenu() { val browsingModeManager = (activity as HomeActivity).browsingModeManager val mode = browsingModeManager.mode @@ -535,7 +566,7 @@ class HomeFragment : Fragment() { } val tabCounterMenu = FenixTabCounterMenu( - view.context, + requireContext(), onItemTapped, iconColor = if (mode == BrowsingMode.Private) { ContextCompat.getColor(requireContext(), R.color.primary_text_private_theme) @@ -550,7 +581,7 @@ class HomeFragment : Fragment() { } tabCounterMenu.updateMenu(showOnly = inverseBrowsingMode) - view.tab_button.setOnLongClickListener { + binding.tabButton.setOnLongClickListener { tabCounterMenu.menuController.show(anchor = it) true } @@ -613,6 +644,7 @@ class HomeFragment : Fragment() { _sessionControlInteractor = null sessionControlView = null appBarLayout = null + _binding = null bundleArgs.clear() } @@ -641,7 +673,10 @@ class HomeFragment : Fragment() { ).getTip() }, showCollectionPlaceholder = components.settings.showCollectionsPlaceholderOnHome, - recentTabs = emptyList(), + // Provide an initial state for recent tabs to prevent re-rendering on the home screen. + // This will otherwise cause a visual jump as the section gets rendered from no state + // to some state. + recentTabs = getRecentTabs(components), recentBookmarks = emptyList(), historyMetadata = emptyList() ) @@ -668,7 +703,7 @@ class HomeFragment : Fragment() { isDisplayedWithBrowserToolbar = false ) .setText(it.context.getString(R.string.onboarding_firefox_account_sync_is_on)) - .setAnchorView(toolbarLayout) + .setAnchorView(binding.toolbarLayout) .show() } } @@ -706,34 +741,25 @@ class HomeFragment : Fragment() { } } - private fun showDeleteCollectionPrompt( - tabCollection: TabCollection, - title: String?, - message: String, - wasSwiped: Boolean, - handleSwipedItemDeletionCancel: () -> Unit - ) { - val context = context ?: return - AlertDialog.Builder(context).apply { - setTitle(title) - setMessage(message) - setNegativeButton(R.string.tab_collection_dialog_negative) { dialog: DialogInterface, _ -> - if (wasSwiped) { - handleSwipedItemDeletionCancel() - } - dialog.cancel() - } - setPositiveButton(R.string.tab_collection_dialog_positive) { dialog: DialogInterface, _ -> - // Use fragment's lifecycle; the view may be gone by the time dialog is interacted with. - lifecycleScope.launch(IO) { - context.components.core.tabCollectionStorage.removeCollection(tabCollection) - context.components.analytics.metrics.track(Event.CollectionRemoved) - }.invokeOnCompletion { - dialog.dismiss() - } - } - create() - }.show() + @VisibleForTesting + internal fun removeCollectionWithUndo(tabCollection: TabCollection) { + val snackbarMessage = getString(R.string.snackbar_collection_deleted) + + lifecycleScope.allowUndo( + requireView(), + snackbarMessage, + getString(R.string.snackbar_deleted_undo), + { + requireComponents.core.tabCollectionStorage.createCollection(tabCollection) + }, + operation = { }, + elevation = TOAST_ELEVATION, + anchorView = null + ) + + lifecycleScope.launch(IO) { + requireComponents.core.tabCollectionStorage.removeCollection(tabCollection) + } } override fun onResume() { @@ -786,12 +812,12 @@ class HomeFragment : Fragment() { } // We want to show the popup only after privateBrowsingButton is available. // Otherwise, we will encounter an activity token error. - privateBrowsingButton.post { + binding.privateBrowsingButton.post { runIfFragmentIsAttached { context.settings().showedPrivateModeContextualFeatureRecommender = true context.settings().lastCfrShownTimeInMillis = System.currentTimeMillis() privateBrowsingRecommend.showAsDropDown( - privateBrowsingButton, 0, CFR_Y_OFFSET, Gravity.TOP or Gravity.END + binding.privateBrowsingButton, 0, CFR_Y_OFFSET, Gravity.TOP or Gravity.END ) } } @@ -853,6 +879,14 @@ class HomeFragment : Fragment() { ) requireComponents.analytics.metrics.track(Event.HomeMenuSettingsItemClicked) } + HomeMenu.Item.CustomizeHome -> { + context.metrics.track(Event.HomeScreenCustomizedHomeClicked) + hideOnboardingIfNeeded() + nav( + R.id.homeFragment, + HomeFragmentDirections.actionGlobalCustomizationFragment() + ) + } is HomeMenu.Item.SyncAccount -> { hideOnboardingIfNeeded() val directions = when (it.accountState) { @@ -1117,12 +1151,23 @@ class HomeFragment : Fragment() { browserState.normalTabs.size } - view?.tab_button?.setCountWithAnimation(tabCount) - view?.add_tabs_to_collections_button?.isVisible = tabCount > 0 + binding.tabButton.setCountWithAnimation(tabCount) + // The add_tabs_to_collections_button is added at runtime. We need to search for it in the same way. + sessionControlView?.view?.findViewById(R.id.add_tabs_to_collections_button) + ?.isVisible = tabCount > 0 } + @SuppressLint("NotifyDataSetChanged") private fun handleSwipedItemDeletionCancel() { - view?.sessionControlRecyclerView?.adapter?.notifyDataSetChanged() + binding.sessionControlRecyclerView.adapter?.notifyDataSetChanged() + } + + private fun getRecentTabs(components: Components): List { + return if (components.settings.showRecentTabsFeature) { + components.core.store.state.asRecentTabs() + } else { + emptyList() + } } companion object { @@ -1143,5 +1188,8 @@ class HomeFragment : Fragment() { private const val FADE_ANIM_DURATION = 150L private const val CFR_WIDTH_DIVIDER = 1.7 private const val CFR_Y_OFFSET = -20 + + // Elevation for undo toasts + internal const val TOAST_ELEVATION = 80f } } diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt index 70fbf88523..61e597ac04 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt @@ -5,7 +5,6 @@ package org.mozilla.fenix.home import android.graphics.Bitmap -import mozilla.components.browser.state.state.TabSessionState import mozilla.components.concept.storage.BookmarkNode import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.top.sites.TopSite @@ -13,8 +12,14 @@ import mozilla.components.lib.state.Action import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.State import mozilla.components.lib.state.Store +import mozilla.components.service.pocket.PocketRecommendedStory import org.mozilla.fenix.components.tips.Tip +import org.mozilla.fenix.ext.getFilteredStories import org.mozilla.fenix.historymetadata.HistoryMetadataGroup +import org.mozilla.fenix.home.recenttabs.RecentTab +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_TO_SHOW_COUNT +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory /** * The [Store] for holding the [HomeFragmentState] and applying [HomeFragmentAction]s. @@ -42,13 +47,16 @@ data class Tab( * @property expandedCollections A set containing the ids of the [TabCollection] that are expanded * in the [HomeFragment]. * @property mode The state of the [HomeFragment] UI. - * @property tabs The list of opened [Tab] in the [HomeFragment]. * @property topSites The list of [TopSite] in the [HomeFragment]. * @property tip The current [Tip] to show on the [HomeFragment]. * @property showCollectionPlaceholder If true, shows a placeholder when there are no collections. - * @property recentTabs The list of recent [TabSessionState] in the [HomeFragment]. + * @property showSetAsDefaultBrowserCard If true, shows the default browser card + * @property recentTabs The list of recent [RecentTab] in the [HomeFragment]. * @property recentBookmarks The list of recently saved [BookmarkNode]s to show on the [HomeFragment]. * @property historyMetadata The list of [HistoryMetadataGroup]. + * @property pocketStories The list of currently shown [PocketRecommendedStory]s. + * @property pocketStoriesCategories All [PocketRecommendedStory] categories. + * Also serves as an in memory cache of all stories mapped by category allowing for quick stories filtering. */ data class HomeFragmentState( val collections: List = emptyList(), @@ -58,9 +66,12 @@ data class HomeFragmentState( val tip: Tip? = null, val showCollectionPlaceholder: Boolean = false, val showSetAsDefaultBrowserCard: Boolean = false, - val recentTabs: List = emptyList(), + val recentTabs: List = emptyList(), val recentBookmarks: List = emptyList(), - val historyMetadata: List = emptyList() + val historyMetadata: List = emptyList(), + val pocketStories: List = emptyList(), + val pocketStoriesCategories: List = emptyList(), + val pocketStoriesCategoriesSelections: List = emptyList() ) : State sealed class HomeFragmentAction : Action { @@ -70,7 +81,7 @@ sealed class HomeFragmentAction : Action { val collections: List, val tip: Tip? = null, val showCollectionPlaceholder: Boolean, - val recentTabs: List, + val recentTabs: List, val recentBookmarks: List, val historyMetadata: List ) : @@ -83,14 +94,25 @@ sealed class HomeFragmentAction : Action { data class ModeChange(val mode: Mode) : HomeFragmentAction() data class TopSitesChange(val topSites: List) : HomeFragmentAction() data class RemoveTip(val tip: Tip) : HomeFragmentAction() - data class RecentTabsChange(val recentTabs: List) : HomeFragmentAction() + data class RecentTabsChange(val recentTabs: List) : HomeFragmentAction() data class RecentBookmarksChange(val recentBookmarks: List) : HomeFragmentAction() data class HistoryMetadataChange(val historyMetadata: List) : HomeFragmentAction() - data class HistoryMetadataExpanded(val historyMetadataGroup: HistoryMetadataGroup) : HomeFragmentAction() + data class DisbandSearchGroupAction(val searchTerm: String) : HomeFragmentAction() + data class SelectPocketStoriesCategory(val categoryName: String) : HomeFragmentAction() + data class DeselectPocketStoriesCategory(val categoryName: String) : HomeFragmentAction() + data class PocketStoriesShown(val storiesShown: List) : HomeFragmentAction() + data class PocketStoriesChange(val pocketStories: List) : HomeFragmentAction() + data class PocketStoriesCategoriesChange(val storiesCategories: List) : + HomeFragmentAction() + data class PocketStoriesCategoriesSelectionsChange( + val storiesCategories: List, + val categoriesSelected: List + ) : HomeFragmentAction() object RemoveCollectionsPlaceholder : HomeFragmentAction() object RemoveSetDefaultBrowserCard : HomeFragmentAction() } +@Suppress("ReturnCount", "LongMethod") private fun homeFragmentStateReducer( state: HomeFragmentState, action: HomeFragmentAction @@ -129,17 +151,73 @@ private fun homeFragmentStateReducer( is HomeFragmentAction.RecentTabsChange -> state.copy(recentTabs = action.recentTabs) is HomeFragmentAction.RecentBookmarksChange -> state.copy(recentBookmarks = action.recentBookmarks) is HomeFragmentAction.HistoryMetadataChange -> state.copy(historyMetadata = action.historyMetadata) - is HomeFragmentAction.HistoryMetadataExpanded -> { - state.copy( - historyMetadata = state.historyMetadata.toMutableList() - .map { - if (it == action.historyMetadataGroup) { - it.copy(expanded = it.expanded.not()) - } else { - it + is HomeFragmentAction.DisbandSearchGroupAction -> state.copy( + historyMetadata = state.historyMetadata.filter { it.title.lowercase() != action.searchTerm.lowercase() } + ) + is HomeFragmentAction.SelectPocketStoriesCategory -> { + val updatedCategoriesState = state.copy( + pocketStoriesCategoriesSelections = + state.pocketStoriesCategoriesSelections + PocketRecommendedStoriesSelectedCategory( + name = action.categoryName + ) + ) + + // Selecting a category means the stories to be displayed needs to also be changed. + return updatedCategoriesState.copy( + pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) + ) + } + is HomeFragmentAction.DeselectPocketStoriesCategory -> { + val updatedCategoriesState = state.copy( + pocketStoriesCategoriesSelections = state.pocketStoriesCategoriesSelections.filterNot { + it.name == action.categoryName + } + ) + + // Deselecting a category means the stories to be displayed needs to also be changed. + return updatedCategoriesState.copy( + pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) + ) + } + is HomeFragmentAction.PocketStoriesCategoriesChange -> { + val updatedCategoriesState = state.copy(pocketStoriesCategories = action.storiesCategories) + // Whenever categories change stories to be displayed needs to also be changed. + return updatedCategoriesState.copy( + pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) + ) + } + is HomeFragmentAction.PocketStoriesCategoriesSelectionsChange -> { + val updatedCategoriesState = state.copy( + pocketStoriesCategories = action.storiesCategories, + pocketStoriesCategoriesSelections = action.categoriesSelected + ) + // Whenever categories change stories to be displayed needs to also be changed. + return updatedCategoriesState.copy( + pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) + ) + } + is HomeFragmentAction.PocketStoriesChange -> state.copy(pocketStories = action.pocketStories) + is HomeFragmentAction.PocketStoriesShown -> { + var updatedCategories = state.pocketStoriesCategories + action.storiesShown.forEach { shownStory -> + updatedCategories = updatedCategories.map { category -> + when (category.name == shownStory.category) { + true -> { + category.copy( + stories = category.stories.map { story -> + when (story.title == shownStory.title) { + true -> story.copy(timesShown = story.timesShown.inc()) + false -> story + } + } + ) } + false -> category } - ) + } + } + + state.copy(pocketStoriesCategories = updatedCategories) } } } diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt b/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt index a2a7d413c5..d63618e43f 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt @@ -23,6 +23,7 @@ import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.OAuthAccount import mozilla.components.support.ktx.android.content.getColorFromAttr +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.accounts.AccountState import org.mozilla.fenix.components.accounts.FenixAccountManager @@ -49,6 +50,7 @@ class HomeMenu( data class SyncAccount(val accountState: AccountState) : Item() object WhatsNew : Item() object Help : Item() + object CustomizeHome : Item() object Settings : Item() object Quit : Item() object ReconnectSync : Item() @@ -161,17 +163,25 @@ class HomeMenu( val helpItem = BrowserMenuImageText( context.getString(R.string.browser_menu_help), - R.drawable.ic_help, + R.drawable.mozac_ic_help, primaryTextColor ) { onItemTapped.invoke(Item.Help) } + val customizeHomeItem = BrowserMenuImageText( + context.getString(R.string.browser_menu_customize_home), + R.drawable.ic_customize, + primaryTextColor + ) { + onItemTapped.invoke(Item.CustomizeHome) + } + // Use nimbus to set the icon and title. val variables = experiments.getVariables(FeatureId.NIMBUS_VALIDATION) val settingsItem = BrowserMenuImageText( variables.getText("settings-title") ?: context.getString(R.string.browser_menu_settings), - variables.getDrawableResource("settings-icon") ?: R.drawable.ic_settings, + variables.getDrawableResource("settings-icon") ?: R.drawable.mozac_ic_settings, primaryTextColor ) { onItemTapped.invoke(Item.Settings) @@ -200,6 +210,7 @@ class HomeMenu( BrowserMenuDivider(), whatsNewItem, helpItem, + if (FeatureFlags.customizeHome) customizeHomeItem else null, settingsItem, if (settings.shouldDeleteBrowsingDataOnQuit) quitItem else null ).also { items -> diff --git a/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt b/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt new file mode 100644 index 0000000000..07df63ae0e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home + +import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.Store +import mozilla.components.service.pocket.PocketRecommendedStory +import mozilla.components.service.pocket.PocketStoriesService +import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories +import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories.SelectedPocketStoriesCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory + +/** + * [HomeFragmentStore] middleware reacting in response to Pocket related [Action]s. + * + * @param coroutineScope [CoroutineScope] used for long running operations like disk IO. + * @param pocketStoriesService [PocketStoriesService] used for updating details about the Pocket recommended stories. + * @param selectedPocketCategoriesDataStore [DataStore] used for reading or persisting details about the + * currently selected Pocket recommended stories categories. + */ +class PocketUpdatesMiddleware( + private val coroutineScope: CoroutineScope, + private val pocketStoriesService: PocketStoriesService, + private val selectedPocketCategoriesDataStore: DataStore +) : Middleware { + override fun invoke( + context: MiddlewareContext, + next: (HomeFragmentAction) -> Unit, + action: HomeFragmentAction + ) { + // Pre process actions + when (action) { + is HomeFragmentAction.PocketStoriesCategoriesChange -> { + // Intercept the original action which would only update categories and + // dispatch a new action which also updates which categories are selected by the user + // from previous locally persisted data. + restoreSelectedCategories( + coroutineScope = coroutineScope, + currentCategories = action.storiesCategories, + store = context.store, + selectedPocketCategoriesDataStore = selectedPocketCategoriesDataStore + ) + } + else -> { + // no-op + } + } + + next(action) + + // Post process actions + when (action) { + is HomeFragmentAction.PocketStoriesShown -> { + persistStories( + coroutineScope = coroutineScope, + pocketStoriesService = pocketStoriesService, + updatedStories = action.storiesShown.map { + it.copy(timesShown = it.timesShown.inc()) + } + ) + } + is HomeFragmentAction.SelectPocketStoriesCategory, + is HomeFragmentAction.DeselectPocketStoriesCategory -> { + persistSelectedCategories( + coroutineScope = coroutineScope, + currentCategoriesSelections = context.state.pocketStoriesCategoriesSelections, + selectedPocketCategoriesDataStore = selectedPocketCategoriesDataStore + ) + } + else -> { + // no-op + } + } + } +} + +/** + * Persist [updatedStories] for making their details available in between app restarts. + * + * @param coroutineScope [CoroutineScope] used for reading the locally persisted data. + * @param pocketStoriesService [PocketStoriesService] used for updating details about the Pocket recommended stories. + * @param updatedStories the list of stories to persist. + */ +@VisibleForTesting +internal fun persistStories( + coroutineScope: CoroutineScope, + pocketStoriesService: PocketStoriesService, + updatedStories: List +) { + coroutineScope.launch { + pocketStoriesService.updateStoriesTimesShown( + updatedStories + ) + } +} + +/** + * Persist [currentCategoriesSelections] for making this details available in between app restarts. + * + * @param coroutineScope [CoroutineScope] used for reading the locally persisted data. + * @param currentCategoriesSelections Currently selected Pocket recommended stories categories. + * @param selectedPocketCategoriesDataStore - DataStore used for persisting [currentCategoriesSelections]. + */ +@VisibleForTesting +internal fun persistSelectedCategories( + coroutineScope: CoroutineScope, + currentCategoriesSelections: List, + selectedPocketCategoriesDataStore: DataStore +) { + val selectedCategories = currentCategoriesSelections + .map { + SelectedPocketStoriesCategory.newBuilder().apply { + name = it.name + selectionTimestamp = it.selectionTimestamp + }.build() + } + + // Irrespective of the current selections or their number overwrite everything we had. + coroutineScope.launch { + selectedPocketCategoriesDataStore.updateData { data -> + data.newBuilderForType().addAllValues(selectedCategories).build() + } + } +} + +/** + * Combines [currentCategories] with the locally persisted data about previously selected categories + * and emits a new [HomeFragmentAction.PocketStoriesCategoriesSelectionsChange] to update these in store. + * + * @param coroutineScope [CoroutineScope] used for reading the locally persisted data. + * @param currentCategories Stories categories currently available + * @param store [Store] that will be updated. + * @param selectedPocketCategoriesDataStore [DataStore] containing details about the previously selected + * stories categories. + */ +@VisibleForTesting +internal fun restoreSelectedCategories( + coroutineScope: CoroutineScope, + currentCategories: List, + store: Store, + selectedPocketCategoriesDataStore: DataStore +) { + coroutineScope.launch { + selectedPocketCategoriesDataStore.data.collect { persistedSelectedCategories -> + store.dispatch( + HomeFragmentAction.PocketStoriesCategoriesSelectionsChange( + currentCategories, + persistedSelectedCategories.valuesList.map { + PocketRecommendedStoriesSelectedCategory( + name = it.name, + selectionTimestamp = it.selectionTimestamp + ) + } + ) + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/controller/RecentBookmarksController.kt b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/controller/RecentBookmarksController.kt index 66eaf473fd..456c1dc55e 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/controller/RecentBookmarksController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/controller/RecentBookmarksController.kt @@ -12,6 +12,8 @@ import mozilla.components.concept.storage.BookmarkNode import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components import org.mozilla.fenix.home.HomeFragmentDirections import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor @@ -47,9 +49,11 @@ class DefaultRecentBookmarksController( newTab = true, from = BrowserDirection.FromHome ) + activity.components.core.metrics.track(Event.BookmarkClicked) } override fun handleShowAllBookmarksClicked() { + activity.components.core.metrics.track(Event.ShowAllBookmarks) dismissSearchDialogIfDisplayed() navController.navigate( HomeFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id) diff --git a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarkItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarkItemViewHolder.kt index ddc484ef62..3d5a67cf77 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarkItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarkItemViewHolder.kt @@ -5,13 +5,9 @@ package org.mozilla.fenix.home.recentbookmarks.view import android.view.View -import kotlinx.android.synthetic.main.recent_bookmark_item.bookmark_title -import kotlinx.android.synthetic.main.recent_bookmark_item.bookmark_subtitle -import kotlinx.android.synthetic.main.recent_bookmark_item.bookmark_item -import kotlinx.android.synthetic.main.recent_bookmark_item.favicon_image import mozilla.components.concept.storage.BookmarkNode -import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.RecentBookmarkItemBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor @@ -23,15 +19,16 @@ class RecentBookmarkItemViewHolder( ) : ViewHolder(view) { fun bind(bookmark: BookmarkNode) { - bookmark_title.text = bookmark.title ?: bookmark.url - bookmark_subtitle.text = bookmark.url?.tryGetHostFromUrl() ?: bookmark.title ?: "" + val binding = RecentBookmarkItemBinding.bind(view) - bookmark_item.setOnClickListener { + binding.bookmarkTitle.text = bookmark.title ?: bookmark.url + + binding.bookmarkItem.setOnClickListener { interactor.onRecentBookmarkClicked(bookmark) } bookmark.url?.let { - view.context.components.core.icons.loadIntoView(favicon_image, it) + view.context.components.core.icons.loadIntoView(binding.faviconImage, it) } } diff --git a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksViewHolder.kt index 0b408c9940..e423472245 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksViewHolder.kt @@ -8,30 +8,35 @@ import android.view.View import androidx.navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL -import kotlinx.android.synthetic.main.component_recent_bookmarks.view.* -import kotlinx.android.synthetic.main.recent_bookmarks_header.* import mozilla.components.concept.storage.BookmarkNode import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.databinding.ComponentRecentBookmarksBinding import org.mozilla.fenix.home.recentbookmarks.RecentBookmarksItemAdapter import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor import org.mozilla.fenix.utils.view.ViewHolder class RecentBookmarksViewHolder( view: View, - val interactor: RecentBookmarksInteractor + val interactor: RecentBookmarksInteractor, + val metrics: MetricController ) : ViewHolder(view) { private val recentBookmarksAdapter = RecentBookmarksItemAdapter(interactor) init { + val recentBookmarksBinding = ComponentRecentBookmarksBinding.bind(view) + val recentBookmarksHeaderBinding = recentBookmarksBinding.recentBookmarksHeader + val linearLayoutManager = LinearLayoutManager(view.context, HORIZONTAL, false) - view.recent_bookmarks_list.apply { + recentBookmarksBinding.recentBookmarksList.apply { adapter = recentBookmarksAdapter layoutManager = linearLayoutManager } - showAllBookmarksButton.setOnClickListener { + recentBookmarksHeaderBinding.showAllBookmarksButton.setOnClickListener { dismissSearchDialogIfDisplayed() interactor.onShowAllBookmarksClicked() } @@ -39,6 +44,10 @@ class RecentBookmarksViewHolder( fun bind(bookmarks: List) { recentBookmarksAdapter.submitList(bookmarks) + + if (bookmarks.isNotEmpty()) { + metrics.track(Event.RecentBookmarksShown) + } } private fun dismissSearchDialogIfDisplayed() { diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/RecentTabsListFeature.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/RecentTabsListFeature.kt index 658945ea48..f4b1816d96 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/RecentTabsListFeature.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/RecentTabsListFeature.kt @@ -4,16 +4,17 @@ package org.mozilla.fenix.home.recenttabs +import android.graphics.Bitmap import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect -import mozilla.components.browser.state.selector.normalTabs +import kotlinx.coroutines.flow.map import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.lib.state.helpers.AbstractBinding -import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.fenix.ext.asRecentTabs -import org.mozilla.fenix.ext.lastOpenedNormalTab import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentStore @@ -28,20 +29,39 @@ class RecentTabsListFeature( ) : AbstractBinding(browserStore) { override suspend fun onState(flow: Flow) { + // Listen for changes regarding the currently selected tab, in progress media tab + // and search term groups. flow - // Listen for changes regarding the currently selected tab and the in progress media tab - // and also for changes (close, undo) in normal tabs that could involve these. - .ifAnyChanged { - val lastOpenedNormalTab = it.lastOpenedNormalTab - arrayOf( - lastOpenedNormalTab?.id, - lastOpenedNormalTab?.content?.title, - lastOpenedNormalTab?.content?.icon, - it.normalTabs - ) - } + .map { it.asRecentTabs() } + .ifChanged() .collect { - homeStore.dispatch(HomeFragmentAction.RecentTabsChange(browserStore.state.asRecentTabs())) + homeStore.dispatch(HomeFragmentAction.RecentTabsChange(it)) } } } + +sealed class RecentTab { + /** + * A tab that was recently viewed + * + * @param state Recently viewed [TabSessionState] + */ + data class Tab(val state: TabSessionState) : RecentTab() + + /** + * A search term group that was recently viewed + * + * @param searchTerm The search term that was recently viewed + * @param tabId The id of the tab that was recently viewed + * @param url The url that was recently viewed + * @param thumbnail The thumbnail of the search term that was recently viewed + * @param count The number of tabs in the search term group + */ + data class SearchGroup( + val searchTerm: String, + val tabId: String, + val url: String, + val thumbnail: Bitmap?, + val count: Int + ) : RecentTab() +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabController.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabController.kt index 2e14c66ed2..4fccfeacd7 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabController.kt @@ -26,6 +26,11 @@ interface RecentTabController { */ fun handleRecentTabClicked(tabId: String) + /** + * @see [RecentTabInteractor.onRecentSearchGroupClicked] + */ + fun handleRecentSearchGroupClicked(tabId: String) + /** * @see [RecentTabInteractor.onRecentTabShowAllClicked] */ @@ -62,6 +67,11 @@ class DefaultRecentTabsController( navController.navigate(HomeFragmentDirections.actionGlobalTabsTrayFragment()) } + override fun handleRecentSearchGroupClicked(tabId: String) { + selectTabUseCase.invoke(tabId) + navController.navigate(HomeFragmentDirections.actionGlobalTabsTrayFragment()) + } + @VisibleForTesting(otherwise = PRIVATE) fun dismissSearchDialogIfDisplayed() { if (navController.currentDestination?.id == R.id.searchDialogFragment) { diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/interactor/RecentTabInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/interactor/RecentTabInteractor.kt index a2568108b5..c40a3d44bf 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/interactor/RecentTabInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/interactor/RecentTabInteractor.kt @@ -15,6 +15,13 @@ interface RecentTabInteractor { */ fun onRecentTabClicked(tabId: String) + /** + * Opens the tabs tray and scroll to the search group. Called when a user clicks on a search group. + * + * @param tabId The ID of the tab to open. + */ + fun onRecentSearchGroupClicked(tabId: String) + /** * Show the tabs tray. Called when a user clicks on the "Show all" button besides the recent * tabs. diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt deleted file mode 100644 index b2e6ad625d..0000000000 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.home.recenttabs.view - -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.content.res.AppCompatResources -import mozilla.components.support.ktx.android.content.getColorFromAttr -import mozilla.components.support.ktx.android.util.dpToPx -import org.mozilla.fenix.R - -private const val TOP_MARGIN_DP = 1 - -/** - * All possible positions of a recent tab in relation to others when shown in the "Jump back in" section. - */ -enum class RecentTabsItemPosition { - /** - * This is the only tab to be shown in this section. - */ - SINGLE, - - /** - * This item is to be shown at the top of the section with others below it. - */ - TOP, - - /** - * This item is to be shown between others in this section. - */ - MIDDLE, - - /** - * This item is to be shown at the bottom of the section with others above it. - */ - BOTTOM -} - -/** - * Helpers for setting various layout properties for the view from a [RecentTabViewHolder]. - * - * Depending on the provided [RecentTabsItemPosition]: - * - sets a different background so that the entire section possibly containing - * more such items would have rounded corners but sibling items not. - * - sets small margins for the items so that there's a clear separation between siblings - */ -sealed class RecentTabViewDecorator { - /** - * Apply the decoration to [itemView]. - */ - abstract operator fun invoke(itemView: View): View - - companion object { - /** - * Get the appropriate decorator to set view background / margins depending on the position - * of that view in the recent tabs section. - */ - fun forPosition(position: RecentTabsItemPosition) = when (position) { - RecentTabsItemPosition.SINGLE -> SingleTabDecoration - RecentTabsItemPosition.TOP -> TopTabDecoration - RecentTabsItemPosition.MIDDLE -> MiddleTabDecoration - RecentTabsItemPosition.BOTTOM -> BottomTabDecoration - } - } - - /** - * Decorator for a view shown in the recent tabs section that will update it to express - * that that item is the single one shown in this section. - */ - object SingleTabDecoration : RecentTabViewDecorator() { - override fun invoke(itemView: View): View { - val context = itemView.context - - itemView.background = - AppCompatResources.getDrawable(context, R.drawable.card_list_row_background) - - return itemView - } - } - - /** - * Decorator for a view shown in the recent tabs section that will update it to express - * that this is an item shown at the top of the section and there are others below it. - */ - object TopTabDecoration : RecentTabViewDecorator() { - override fun invoke(itemView: View): View { - val context = itemView.context - - itemView.background = - AppCompatResources.getDrawable(context, R.drawable.rounded_top_corners) - - return itemView - } - } - - /** - * Decorator for a view shown in the recent tabs section that will update it to express - * that this is an item shown has other recents tabs to be shown on top or below it. - */ - object MiddleTabDecoration : RecentTabViewDecorator() { - override fun invoke(itemView: View): View { - val context = itemView.context - - itemView.setBackgroundColor(context.getColorFromAttr(R.attr.above)) - - (itemView.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin = - TOP_MARGIN_DP.dpToPx(context.resources.displayMetrics) - - return itemView - } - } - - /** - * Decorator for a view shown in the recent tabs section that will update it to express - * that this is an item shown at the bottom of the section and there are others above it. - */ - object BottomTabDecoration : RecentTabViewDecorator() { - override fun invoke(itemView: View): View { - val context = itemView.context - - itemView.background = - AppCompatResources.getDrawable(context, R.drawable.rounded_bottom_corners) - - (itemView.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin = - TOP_MARGIN_DP.dpToPx(context.resources.displayMetrics) - - return itemView - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt index 849b4d28c8..b88c858ecb 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt @@ -5,55 +5,49 @@ package org.mozilla.fenix.home.recenttabs.view import android.view.View -import mozilla.components.browser.icons.BrowserIcons -import mozilla.components.browser.state.state.ContentState -import mozilla.components.browser.state.state.TabSessionState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import mozilla.components.lib.state.ext.observeAsComposableState import org.mozilla.fenix.R -import org.mozilla.fenix.databinding.RecentTabsListRowBinding -import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.loadIntoView +import org.mozilla.fenix.home.HomeFragmentStore import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor +import org.mozilla.fenix.theme.FirefoxTheme import org.mozilla.fenix.utils.view.ViewHolder /** * View holder for a recent tab item. * + * @param composeView [ComposeView] which will be populated with Jetpack Compose UI content. + * @param store [HomeFragmentStore] containing the list of recent tabs to be displayed. * @param interactor [RecentTabInteractor] which will have delegated to all user interactions. - * @param icons an instance of [BrowserIcons] for rendering the sites icon if one isn't found - * in [ContentState.icon]. */ class RecentTabViewHolder( - private val view: View, - private val interactor: RecentTabInteractor, - private val icons: BrowserIcons = view.context.components.core.icons -) : ViewHolder(view) { - - fun bindTab(tab: TabSessionState): View { - // A page may take a while to retrieve a title, so let's show the url until we get one. - - val biding = RecentTabsListRowBinding.bind(view) - - biding.recentTabTitle.text = if (tab.content.title.isNotEmpty()) { - tab.content.title - } else { - tab.content.url - } - - if (tab.content.icon != null) { - biding.recentTabIcon.setImageBitmap(tab.content.icon) - } else { - icons.loadIntoView(biding.recentTabIcon, tab.content.url) - } - biding.recentTabIcon.setImageBitmap(tab.content.icon) - - itemView.setOnClickListener { - interactor.onRecentTabClicked(tab.id) + val composeView: ComposeView, + private val store: HomeFragmentStore, + private val interactor: RecentTabInteractor +) : ViewHolder(composeView) { + + init { + val horizontalPadding = composeView.resources.getDimensionPixelSize(R.dimen.home_item_horizontal_margin) + composeView.setPadding(horizontalPadding, 0, horizontalPadding, 0) + + composeView.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + composeView.setContent { + val recentTabs = store.observeAsComposableState { state -> state.recentTabs } + + FirefoxTheme { + RecentTabs( + recentTabs = recentTabs.value ?: emptyList(), + onRecentTabClick = { interactor.onRecentTabClicked(it) }, + onRecentSearchGroupClicked = { interactor.onRecentSearchGroupClicked(it) } + ) + } } - - return itemView } companion object { - const val LAYOUT_ID = R.layout.recent_tabs_list_row + val LAYOUT_ID = View.generateViewId() } } diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt new file mode 100644 index 0000000000..0b1fe7850d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt @@ -0,0 +1,415 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@file:Suppress("MagicNumber") + +package org.mozilla.fenix.home.recenttabs.view + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import mozilla.components.browser.icons.compose.Loader +import mozilla.components.browser.icons.compose.Placeholder +import mozilla.components.browser.icons.compose.WithIcon +import mozilla.components.concept.base.images.ImageLoadRequest +import mozilla.components.support.ktx.kotlin.getRepresentativeSnippet +import mozilla.components.ui.colors.PhotonColors +import org.mozilla.fenix.R +import org.mozilla.fenix.components.components +import org.mozilla.fenix.home.recenttabs.RecentTab +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * A list of recent tabs to jump back to. + * + * @param recentTabs List of [RecentTab] to display. + * @param onRecentTabClick Invoked when the user clicks on a recent tab. + * @param onRecentSearchGroupClicked Invoked when the user clicks on a recent search group. + */ +@Composable +fun RecentTabs( + recentTabs: List, + onRecentTabClick: (String) -> Unit = {}, + onRecentSearchGroupClicked: (String) -> Unit = {} +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + recentTabs.forEach { tab -> + when (tab) { + is RecentTab.Tab -> { + RecentTabItem( + tabId = tab.state.id, + url = tab.state.content.url, + title = tab.state.content.title, + thumbnail = tab.state.content.thumbnail, + onRecentTabClick = onRecentTabClick + ) + } + is RecentTab.SearchGroup -> { + if (components.settings.searchTermTabGroupsAreEnabled) { + RecentSearchGroupItem( + searchTerm = tab.searchTerm, + tabId = tab.tabId, + count = tab.count, + onSearchGroupClicked = onRecentSearchGroupClicked + ) + } + } + } + } + } +} + +/** + * A recent tab item. + * + * @param tabId The id of the tab. + * @param url The loaded URL of the tab. + * @param title The title of the tab. + * @param thumbnail The icon of the tab. + * @param onRecentTabClick Invoked when the user clicks on a recent tab. + */ +@Suppress("LongParameterList") +@Composable +private fun RecentTabItem( + tabId: String, + url: String, + title: String, + icon: Bitmap? = null, + thumbnail: Bitmap? = null, + onRecentTabClick: (String) -> Unit = {} +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(112.dp) + .clickable { onRecentTabClick(tabId) }, + shape = RoundedCornerShape(8.dp), + backgroundColor = FirefoxTheme.colors.surface, + elevation = 6.dp + ) { + Row( + modifier = Modifier.padding(16.dp) + ) { + RecentTabImage( + url = url, + tabId = tabId, + modifier = Modifier.size(108.dp, 80.dp) + .clip(RoundedCornerShape(8.dp)), + thumbnail = thumbnail + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + RecentTabTitle(title = title) + + Row { + RecentTabIcon( + url = url, + modifier = Modifier.size(18.dp, 18.dp) + .clip(RoundedCornerShape(2.dp)), + icon = icon + ) + + Spacer(modifier = Modifier.width(8.dp)) + + RecentTabSubtitle(subtitle = url) + } + } + } + } +} + +/** + * A recent search group item. + * + * @param searchTerm The search term for the group. + * @param tabId The id of the last accessed tab in the group. + * @param count Count of how many tabs belongs to the group. + * @param onSearchGroupClicked Invoked when the user clicks on a group. + */ +@Suppress("LongParameterList") +@Composable +private fun RecentSearchGroupItem( + searchTerm: String, + tabId: String, + count: Int, + onSearchGroupClicked: (String) -> Unit = {} +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(112.dp) + .clickable { onSearchGroupClicked(tabId) }, + shape = RoundedCornerShape(8.dp), + backgroundColor = FirefoxTheme.colors.surface, + elevation = 6.dp + ) { + Row( + modifier = Modifier.padding(16.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_search_group_thumbnail), + contentDescription = null, + modifier = Modifier.size(108.dp, 80.dp), + contentScale = ContentScale.FillWidth, + alignment = Alignment.Center + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + RecentTabTitle(title = stringResource(R.string.recent_tabs_search_term, searchTerm)) + + Row { + Icon( + painter = painterResource(id = R.drawable.ic_all_tabs), + modifier = Modifier.size(18.dp), + contentDescription = null, + tint = FirefoxTheme.colors.textSecondary + ) + + Spacer(modifier = Modifier.width(8.dp)) + + RecentTabSubtitle(subtitle = stringResource(R.string.recent_tabs_search_term_count, count)) + } + } + } + } +} + +/** + * A recent tab image. + * + * @param url The loaded URL of the tab. + * @param modifier [Modifier] used to draw the image content. + * @param tabId The id of the tab. + * @param contentScale [ContentScale] used to draw image content. + * @param alignment [Alignment] used to draw the image content. + * @param thumbnail The icon of the tab. Fallback to loading the icon from the [url] if the [thumbnail] + * is null. + */ +@Composable +@Suppress("LongParameterList") +private fun RecentTabImage( + url: String, + modifier: Modifier = Modifier, + tabId: String? = null, + thumbnail: Bitmap? = null, + contentScale: ContentScale = ContentScale.FillWidth, + alignment: Alignment = Alignment.TopCenter +) { + when { + thumbnail != null -> { + Image( + painter = BitmapPainter(thumbnail.asImageBitmap()), + contentDescription = null, + modifier = modifier, + contentScale = contentScale, + alignment = alignment + ) + } + else -> { + Card( + modifier = modifier, + backgroundColor = colorResource(id = R.color.photonGrey20) + ) { + components.core.icons.Loader(url) { + Placeholder { + Box( + modifier = Modifier.background( + color = when (isSystemInDarkTheme()) { + true -> PhotonColors.DarkGrey30 + false -> PhotonColors.LightGrey30 + } + ) + ) + } + + WithIcon { icon -> + Box( + modifier = Modifier.size(36.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = icon.painter, + contentDescription = null, + modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Fit + ) + } + } + } + + if (tabId != null) { + ThumbnailImage( + tabId = tabId, + modifier = modifier, + contentScale = contentScale, + alignment = alignment + ) + } + } + } + } +} + +/** + * A recent tab icon. + * + * @param url The loaded URL of the tab. + * @param modifier [Modifier] used to draw the image content. + * @param contentScale [ContentScale] used to draw image content. + * @param alignment [Alignment] used to draw the image content. + * @param icon The icon of the tab. Fallback to loading the icon from the [url] if the [icon] + * is null. + */ +@Composable +private fun RecentTabIcon( + url: String, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + alignment: Alignment = Alignment.Center, + icon: Bitmap? = null +) { + when { + icon != null -> { + Image( + painter = BitmapPainter(icon.asImageBitmap()), + contentDescription = null, + modifier = modifier, + contentScale = contentScale, + alignment = alignment + ) + } + else -> { + components.core.icons.Loader(url) { + Placeholder { + Box( + modifier = Modifier.background( + color = when (isSystemInDarkTheme()) { + true -> PhotonColors.DarkGrey30 + false -> PhotonColors.LightGrey30 + } + ) + ) + } + + WithIcon { icon -> + Image( + painter = icon.painter, + contentDescription = null, + modifier = modifier, + contentScale = ContentScale.Fit + ) + } + } + } + } +} + +/** + * A recent tab title. + * + * @param title The title of the tab. + */ +@Composable +private fun RecentTabTitle(title: String) { + Text( + text = title, + color = FirefoxTheme.colors.textPrimary, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 2 + ) +} + +/** + * A recent tab subtitle. + * + * @param subtitle The loaded URL of the tab. + */ +@Composable +private fun RecentTabSubtitle(subtitle: String) { + Text( + text = subtitle.getRepresentativeSnippet(), + color = FirefoxTheme.colors.textSecondary, + fontSize = 12.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) +} + +@Composable +private fun ThumbnailImage( + tabId: String, + modifier: Modifier, + contentScale: ContentScale, + alignment: Alignment +) { + val rememberBitmap = remember(tabId) { mutableStateOf(null) } + val size = LocalDensity.current.run { 108.dp.toPx().toInt() } + val request = ImageLoadRequest(tabId, size) + val storage = components.core.thumbnailStorage + val bitmap = rememberBitmap.value + + LaunchedEffect(tabId) { + rememberBitmap.value = storage.loadThumbnail(request).await()?.asImageBitmap() + } + + if (bitmap != null) { + val painter = BitmapPainter(bitmap) + Image( + painter = painter, + contentDescription = null, + modifier = modifier, + contentScale = contentScale, + alignment = alignment + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabsHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabsHeaderViewHolder.kt index 37e959957a..32304f6a8d 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabsHeaderViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabsHeaderViewHolder.kt @@ -22,7 +22,6 @@ class RecentTabsHeaderViewHolder( ) : ViewHolder(view) { init { - val binding = RecentTabsHeaderBinding.bind(view) binding.showAllButton.setOnClickListener { dismissSearchDialogIfDisplayed() diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt index e24d99917b..90f3b0ec54 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt @@ -8,13 +8,12 @@ import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.annotation.LayoutRes +import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import mozilla.components.browser.state.state.TabSessionState import mozilla.components.concept.storage.BookmarkNode -import mozilla.components.concept.storage.HistoryMetadata import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.top.sites.TopSite import mozilla.components.feature.top.sites.TopSite.Type.FRECENT @@ -23,15 +22,14 @@ import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.historymetadata.view.HistoryMetadataGroupViewHolder import org.mozilla.fenix.historymetadata.view.HistoryMetadataHeaderViewHolder -import org.mozilla.fenix.historymetadata.view.HistoryMetadataViewHolder +import org.mozilla.fenix.home.HomeFragmentStore import org.mozilla.fenix.home.OnboardingState import org.mozilla.fenix.home.recentbookmarks.view.RecentBookmarksViewHolder -import org.mozilla.fenix.home.recenttabs.view.RecentTabViewDecorator import org.mozilla.fenix.home.recenttabs.view.RecentTabViewHolder import org.mozilla.fenix.home.recenttabs.view.RecentTabsHeaderViewHolder -import org.mozilla.fenix.home.recenttabs.view.RecentTabsItemPosition import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionHeaderViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder +import org.mozilla.fenix.home.sessioncontrol.viewholders.CustomizeHomeButtonViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.NoCollectionsMessageViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.PrivateBrowsingDescriptionViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.TabInCollectionViewHolder @@ -42,12 +40,11 @@ import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingFi import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingHeaderViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingManualSignInViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingPrivacyNoticeViewHolder -import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingPrivateBrowsingViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingSectionHeaderViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingThemePickerViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingToolbarPositionPickerViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingTrackingProtectionViewHolder -import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingWhatsNewViewHolder +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesViewHolder import org.mozilla.fenix.home.tips.ButtonTipViewHolder import mozilla.components.feature.tab.collections.Tab as ComponentTab @@ -155,45 +152,18 @@ sealed class AdapterItem(@LayoutRes val viewType: Int) { object OnboardingTrackingProtection : AdapterItem(OnboardingTrackingProtectionViewHolder.LAYOUT_ID) - object OnboardingPrivateBrowsing : AdapterItem(OnboardingPrivateBrowsingViewHolder.LAYOUT_ID) object OnboardingPrivacyNotice : AdapterItem(OnboardingPrivacyNoticeViewHolder.LAYOUT_ID) object OnboardingFinish : AdapterItem(OnboardingFinishViewHolder.LAYOUT_ID) object OnboardingToolbarPositionPicker : AdapterItem(OnboardingToolbarPositionPickerViewHolder.LAYOUT_ID) - object OnboardingWhatsNew : AdapterItem(OnboardingWhatsNewViewHolder.LAYOUT_ID) + object CustomizeHomeButton : AdapterItem(CustomizeHomeButtonViewHolder.LAYOUT_ID) object RecentTabsHeader : AdapterItem(RecentTabsHeaderViewHolder.LAYOUT_ID) - data class RecentTabItem( - val tab: TabSessionState, - val position: RecentTabsItemPosition - ) : AdapterItem(RecentTabViewHolder.LAYOUT_ID) { - override fun sameAs(other: AdapterItem) = other is RecentTabItem && tab.id == other.tab.id && - position == other.position - - override fun contentsSameAs(other: AdapterItem): Boolean { - val otherItem = other as RecentTabItem - // We only care about updating if the title and icon have changed because that is - // all we show today. This should be updated if we want to show updates for more. - return tab.content.title == otherItem.tab.content.title && - tab.content.icon == otherItem.tab.content.icon - } - } + object RecentTabItem : AdapterItem(RecentTabViewHolder.LAYOUT_ID) object HistoryMetadataHeader : AdapterItem(HistoryMetadataHeaderViewHolder.LAYOUT_ID) - - data class HistoryMetadataGroup(val historyMetadataGroup: org.mozilla.fenix.historymetadata.HistoryMetadataGroup) : - AdapterItem(HistoryMetadataGroupViewHolder.LAYOUT_ID) { - override fun sameAs(other: AdapterItem) = - other is HistoryMetadataGroup && historyMetadataGroup == other.historyMetadataGroup - - override fun contentsSameAs(other: AdapterItem): Boolean { - (other as? HistoryMetadataGroup)?.let { - return it.historyMetadataGroup.expanded == this.historyMetadataGroup.expanded && - it.historyMetadataGroup.title == this.historyMetadataGroup.title - } ?: return false - } - } + object HistoryMetadataGroup : AdapterItem(HistoryMetadataGroupViewHolder.LAYOUT_ID) data class RecentBookmarks(val recentBookmarks: List) : AdapterItem(RecentBookmarksViewHolder.LAYOUT_ID) { @@ -220,12 +190,8 @@ sealed class AdapterItem(@LayoutRes val viewType: Int) { } } - data class HistoryMetadataItem(val historyMetadata: HistoryMetadata) : AdapterItem( - HistoryMetadataViewHolder.LAYOUT_ID - ) { - override fun sameAs(other: AdapterItem) = - other is HistoryMetadataItem && historyMetadata.key.url == other.historyMetadata.key.url - } + object PocketStoriesItem : + AdapterItem(PocketStoriesViewHolder.LAYOUT_ID) /** * True if this item represents the same value as other. Used by [AdapterItemDiffCallback]. @@ -253,15 +219,36 @@ class AdapterItemDiffCallback : DiffUtil.ItemCallback() { } } +@Suppress("LongParameterList") class SessionControlAdapter( + private val store: HomeFragmentStore, private val interactor: SessionControlInteractor, private val viewLifecycleOwner: LifecycleOwner, private val components: Components ) : ListAdapter(AdapterItemDiffCallback()) { // This method triggers the ComplexMethod lint error when in fact it's quite simple. - @SuppressWarnings("ComplexMethod") + @SuppressWarnings("ComplexMethod", "LongMethod", "ReturnCount") override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + when (viewType) { + PocketStoriesViewHolder.LAYOUT_ID -> return PocketStoriesViewHolder( + composeView = ComposeView(parent.context), + store = store, + interactor = interactor + ) + RecentTabViewHolder.LAYOUT_ID -> return RecentTabViewHolder( + composeView = ComposeView(parent.context), + store = store, + interactor = interactor + ) + HistoryMetadataGroupViewHolder.LAYOUT_ID -> return HistoryMetadataGroupViewHolder( + composeView = ComposeView(parent.context), + store = store, + interactor = interactor, + metrics = components.analytics.metrics + ) + } + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) return when (viewType) { ButtonTipViewHolder.LAYOUT_ID -> ButtonTipViewHolder(view, interactor) @@ -293,38 +280,42 @@ class SessionControlAdapter( OnboardingTrackingProtectionViewHolder.LAYOUT_ID -> OnboardingTrackingProtectionViewHolder( view ) - OnboardingPrivateBrowsingViewHolder.LAYOUT_ID -> OnboardingPrivateBrowsingViewHolder( - view, - interactor - ) OnboardingPrivacyNoticeViewHolder.LAYOUT_ID -> OnboardingPrivacyNoticeViewHolder( view, interactor ) + CustomizeHomeButtonViewHolder.LAYOUT_ID -> CustomizeHomeButtonViewHolder(view, interactor) OnboardingFinishViewHolder.LAYOUT_ID -> OnboardingFinishViewHolder(view, interactor) - OnboardingWhatsNewViewHolder.LAYOUT_ID -> OnboardingWhatsNewViewHolder(view, interactor) OnboardingToolbarPositionPickerViewHolder.LAYOUT_ID -> OnboardingToolbarPositionPickerViewHolder( view ) ExperimentDefaultBrowserCardViewHolder.LAYOUT_ID -> ExperimentDefaultBrowserCardViewHolder(view, interactor) RecentTabsHeaderViewHolder.LAYOUT_ID -> RecentTabsHeaderViewHolder(view, interactor) - RecentTabViewHolder.LAYOUT_ID -> RecentTabViewHolder(view, interactor) RecentBookmarksViewHolder.LAYOUT_ID -> { - RecentBookmarksViewHolder(view, interactor) + RecentBookmarksViewHolder(view, interactor, components.analytics.metrics) } HistoryMetadataHeaderViewHolder.LAYOUT_ID -> HistoryMetadataHeaderViewHolder( view, interactor ) - HistoryMetadataGroupViewHolder.LAYOUT_ID -> HistoryMetadataGroupViewHolder( - view, - interactor - ) - HistoryMetadataViewHolder.LAYOUT_ID -> HistoryMetadataViewHolder(view, interactor) else -> throw IllegalStateException() } } + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + when (holder) { + is RecentTabViewHolder, + is PocketStoriesViewHolder -> { + // no op + // This previously called "composeView.disposeComposition" which would have the + // entire Composable destroyed and recreated when this View is scrolled off or on screen again. + // This View already listens and maps store updates. Avoid creating and binding new Views. + // The composition will live until the ViewTreeLifecycleOwner to which it's attached to is destroyed. + } + else -> super.onViewRecycled(holder) + } + } + override fun getItemViewType(position: Int) = getItem(position).viewType override fun onBindViewHolder( @@ -372,22 +363,16 @@ class SessionControlAdapter( is OnboardingAutomaticSignInViewHolder -> holder.bind( (item as AdapterItem.OnboardingAutomaticSignIn).state.withAccount ) - is RecentTabViewHolder -> { - val (tab, tabPosition) = item as AdapterItem.RecentTabItem - holder.bindTab(tab).apply { - RecentTabViewDecorator.forPosition(tabPosition).invoke(this) - } - } is RecentBookmarksViewHolder -> { holder.bind( (item as AdapterItem.RecentBookmarks).recentBookmarks ) } - is HistoryMetadataViewHolder -> { - holder.bind((item as AdapterItem.HistoryMetadataItem).historyMetadata) - } - is HistoryMetadataGroupViewHolder -> { - holder.bind((item as AdapterItem.HistoryMetadataGroup).historyMetadataGroup) + is HistoryMetadataGroupViewHolder, + is RecentTabViewHolder, + is PocketStoriesViewHolder -> { + // no-op. This ViewHolder receives the HomeStore as argument and will observe that + // without the need for us to manually update from here the data to be displayed. } } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt index 635def8265..1123325984 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt @@ -27,8 +27,10 @@ import mozilla.components.feature.top.sites.TopSite import mozilla.components.support.ktx.android.view.showKeyboard import mozilla.components.support.ktx.kotlin.isUrl import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.browser.BrowserFragmentDirections import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.collections.SaveCollectionStep import org.mozilla.fenix.components.TabCollectionStorage @@ -40,11 +42,15 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.openSetDefaultBrowserOption +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.home.HomeFragment import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentDirections +import org.mozilla.fenix.home.HomeFragmentState import org.mozilla.fenix.home.HomeFragmentStore +import org.mozilla.fenix.home.Mode import org.mozilla.fenix.settings.SupportUtils +import org.mozilla.fenix.settings.SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS import org.mozilla.fenix.utils.Settings import mozilla.components.feature.tab.collections.Tab as ComponentTab @@ -119,16 +125,6 @@ interface SessionControlController { */ fun handleStartBrowsingClicked() - /** - * @see [OnboardingInteractor.onOpenSettingsClicked] - */ - fun handleOpenSettingsClicked() - - /** - * @see [OnboardingInteractor.onWhatsNewGetAnswersClicked] - */ - fun handleWhatsNewGetAnswersClicked() - /** * @see [OnboardingInteractor.onReadPrivacyNoticeClicked] */ @@ -178,6 +174,26 @@ interface SessionControlController { * @see [ExperimentCardInteractor.onCloseExperimentCardClicked] */ fun handleCloseExperimentCard() + + /** + * @see [TabSessionInteractor.onPrivateModeButtonClicked] + */ + fun handlePrivateModeButtonClicked(newMode: BrowsingMode, userHasBeenOnboarded: Boolean) + + /** + * @see [CustomizeHomeIteractor.openCustomizeHomePage] + */ + fun handleCustomizeHomeTapped() + + /** + * @see [OnboardingInteractor.showOnboardingDialog] + */ + fun handleShowOnboardingDialog() + + /** + * @see [SessionControlInteractor.reportSessionMetrics] + */ + fun handleReportSessionMetrics(state: HomeFragmentState) } @Suppress("TooManyFunctions", "LargeClass") @@ -197,13 +213,7 @@ class DefaultSessionControlController( private val viewLifecycleScope: CoroutineScope, private val hideOnboarding: () -> Unit, private val registerCollectionStorageObserver: () -> Unit, - private val showDeleteCollectionPrompt: ( - tabCollection: TabCollection, - title: String?, - message: String, - wasSwiped: Boolean, - handleSwipedItemDeletionCancel: () -> Unit - ) -> Unit, + private val removeCollectionWithUndo: (tabCollection: TabCollection) -> Unit, private val showTabTray: () -> Unit, private val handleSwipedItemDeletionCancel: () -> Unit ) : SessionControlController { @@ -266,19 +276,7 @@ class DefaultSessionControlController( metrics.track(Event.CollectionTabRemoved) if (collection.tabs.size == 1) { - val title = activity.resources.getString( - R.string.delete_tab_and_collection_dialog_title, - collection.title - ) - val message = - activity.resources.getString(R.string.delete_tab_and_collection_dialog_message) - showDeleteCollectionPrompt( - collection, - title, - message, - wasSwiped, - handleSwipedItemDeletionCancel - ) + removeCollectionWithUndo(collection) } else { viewLifecycleScope.launch { tabCollectionStorage.removeTabFromCollection(collection, tab) @@ -296,9 +294,7 @@ class DefaultSessionControlController( } override fun handleDeleteCollectionTapped(collection: TabCollection) { - val message = - activity.resources.getString(R.string.tab_collection_dialog_message, collection.title) - showDeleteCollectionPrompt(collection, null, message, false, handleSwipedItemDeletionCancel) + removeCollectionWithUndo(collection) } override fun handleOpenInPrivateTabClicked(topSite: TopSite) { @@ -316,8 +312,7 @@ class DefaultSessionControlController( override fun handlePrivateBrowsingLearnMoreClicked() { dismissSearchDialogIfDisplayed() activity.openToBrowserAndLoad( - searchTermOrURL = SupportUtils.getGenericSumoURLForTopic - (SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS), + searchTermOrURL = SupportUtils.getGenericSumoURLForTopic(PRIVATE_BROWSING_MYTHS), newTab = true, from = BrowserDirection.FromHome ) @@ -358,8 +353,10 @@ class DefaultSessionControlController( override fun handleRemoveTopSiteClicked(topSite: TopSite) { metrics.track(Event.TopSiteRemoved) - if (topSite.url == SupportUtils.POCKET_TRENDING_URL) { - metrics.track(Event.PocketTopSiteRemoved) + when (topSite.url) { + SupportUtils.POCKET_TRENDING_URL -> metrics.track(Event.PocketTopSiteRemoved) + SupportUtils.GOOGLE_URL -> metrics.track(Event.GoogleTopSiteRemoved) + SupportUtils.BAIDU_URL -> metrics.track(Event.BaiduTopSiteRemoved) } viewLifecycleScope.launch(Dispatchers.IO) { @@ -392,6 +389,10 @@ class DefaultSessionControlController( metrics.track(Event.TopSiteOpenGoogle) } + if (url == SupportUtils.BAIDU_URL) { + metrics.track(Event.TopSiteOpenBaidu) + } + if (url == SupportUtils.POCKET_TRENDING_URL) { metrics.track(Event.PocketTopSiteClicked) } @@ -455,17 +456,19 @@ class DefaultSessionControlController( hideOnboarding() } - override fun handleOpenSettingsClicked() { - val directions = HomeFragmentDirections.actionGlobalPrivateBrowsingFragment() + override fun handleCustomizeHomeTapped() { + val directions = HomeFragmentDirections.actionGlobalCustomizationFragment() navController.nav(R.id.homeFragment, directions) + metrics.track(Event.HomeScreenCustomizedHomeClicked) } - override fun handleWhatsNewGetAnswersClicked() { - activity.openToBrowserAndLoad( - searchTermOrURL = SupportUtils.getWhatsNewUrl(activity), - newTab = true, - from = BrowserDirection.FromHome - ) + override fun handleShowOnboardingDialog() { + if (FeatureFlags.showHomeOnboarding) { + navController.nav( + R.id.homeFragment, + HomeFragmentDirections.actionGlobalHomeOnboardingDialog() + ) + } } override fun handleReadPrivacyNoticeClicked() { @@ -577,4 +580,38 @@ class DefaultSessionControlController( metrics.track(Event.CloseExperimentCardClicked) fragmentStore.dispatch(HomeFragmentAction.RemoveSetDefaultBrowserCard) } + + override fun handlePrivateModeButtonClicked( + newMode: BrowsingMode, + userHasBeenOnboarded: Boolean + ) { + if (newMode == BrowsingMode.Private) { + activity.settings().incrementNumTimesPrivateModeOpened() + } + + if (userHasBeenOnboarded) { + fragmentStore.dispatch( + HomeFragmentAction.ModeChange(Mode.fromBrowsingMode(newMode)) + ) + + if (navController.currentDestination?.id == R.id.searchDialogFragment) { + navController.navigate( + BrowserFragmentDirections.actionGlobalSearchDialog( + sessionId = null + ) + ) + } + } + } + + override fun handleReportSessionMetrics(state: HomeFragmentState) { + with(metrics) { + track( + if (state.recentTabs.isEmpty()) Event.RecentTabsSectionIsNotVisible + else Event.RecentTabsSectionIsVisible + ) + + track(Event.RecentBookmarkCount(state.recentBookmarks.size)) + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt index c65f4b77a8..f32a5ad827 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt @@ -5,18 +5,23 @@ package org.mozilla.fenix.home.sessioncontrol import mozilla.components.concept.storage.BookmarkNode -import mozilla.components.concept.storage.HistoryMetadataKey import mozilla.components.feature.tab.collections.Tab import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.top.sites.TopSite +import mozilla.components.service.pocket.PocketRecommendedStory +import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.historymetadata.HistoryMetadataGroup import org.mozilla.fenix.historymetadata.controller.HistoryMetadataController import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor +import org.mozilla.fenix.home.HomeFragmentState import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor import org.mozilla.fenix.home.recenttabs.controller.RecentTabController import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesController +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesInteractor /** * Interface for tab related actions in the [SessionControlInteractor]. @@ -27,6 +32,18 @@ interface TabSessionInteractor { * "Common myths about private browsing" link in private mode. */ fun onPrivateBrowsingLearnMoreClicked() + + /** + * Called when a user clicks on the Private Mode button on the homescreen. + */ + fun onPrivateModeButtonClicked(newMode: BrowsingMode, userHasBeenOnboarded: Boolean) + + /** + * Called when there is an update to the session state and updated metrics need to be reported + * + * * @param state The state the homepage from which to report desired metrics. + */ + fun reportSessionMetrics(state: HomeFragmentState) } /** @@ -137,19 +154,15 @@ interface OnboardingInteractor { fun onStartBrowsingClicked() /** - * Hides the onboarding and navigates to Settings. Called when a user clicks on the "Open settings" button. + * Opens a custom tab to privacy notice url. Called when a user clicks on the "read our privacy notice" button. */ - fun onOpenSettingsClicked() + fun onReadPrivacyNoticeClicked() /** - * Opens a custom tab to what's new url. Called when a user clicks on the "Get answers here" link. + * Show the onboarding dialog to onboard users about recentTabs,recentBookmarks, + * historyMetadata and pocketArticles sections. */ - fun onWhatsNewGetAnswersClicked() - - /** - * Opens a custom tab to privacy notice url. Called when a user clicks on the "read our privacy notice" button. - */ - fun onReadPrivacyNoticeClicked() + fun showOnboardingDialog() } interface TipInteractor { @@ -159,6 +172,13 @@ interface TipInteractor { fun onCloseTip(tip: Tip) } +interface CustomizeHomeIteractor { + /** + * Opens the customize home settings page. + */ + fun openCustomizeHomePage() +} + /** * Interface for top site related actions in the [SessionControlInteractor]. */ @@ -214,14 +234,16 @@ interface ExperimentCardInteractor { /** * Interactor for the Home screen. Provides implementations for the CollectionInteractor, * OnboardingInteractor, TopSiteInteractor, TipInteractor, TabSessionInteractor, - * ToolbarInteractor, ExperimentCardInteractor, RecentTabInteractor, and RecentBookmarksInteractor. + * ToolbarInteractor, ExperimentCardInteractor, RecentTabInteractor, RecentBookmarksInteractor + * and others. */ @SuppressWarnings("TooManyFunctions") class SessionControlInteractor( private val controller: SessionControlController, private val recentTabController: RecentTabController, private val recentBookmarksController: RecentBookmarksController, - private val historyMetadataController: HistoryMetadataController + private val historyMetadataController: HistoryMetadataController, + private val pocketStoriesController: PocketStoriesController ) : CollectionInteractor, OnboardingInteractor, TopSiteInteractor, @@ -231,7 +253,9 @@ class SessionControlInteractor( ExperimentCardInteractor, RecentTabInteractor, RecentBookmarksInteractor, - HistoryMetadataInteractor { + HistoryMetadataInteractor, + CustomizeHomeIteractor, + PocketStoriesInteractor { override fun onCollectionAddTabTapped(collection: TabCollection) { controller.handleCollectionAddTabTapped(collection) @@ -281,18 +305,14 @@ class SessionControlInteractor( controller.handleStartBrowsingClicked() } - override fun onOpenSettingsClicked() { - controller.handleOpenSettingsClicked() - } - - override fun onWhatsNewGetAnswersClicked() { - controller.handleWhatsNewGetAnswersClicked() - } - override fun onReadPrivacyNoticeClicked() { controller.handleReadPrivacyNoticeClicked() } + override fun showOnboardingDialog() { + controller.handleShowOnboardingDialog() + } + override fun onToggleCollectionExpanded(collection: TabCollection, expand: Boolean) { controller.handleToggleCollectionExpanded(collection, expand) } @@ -309,6 +329,10 @@ class SessionControlInteractor( controller.handlePrivateBrowsingLearnMoreClicked() } + override fun onPrivateModeButtonClicked(newMode: BrowsingMode, userHasBeenOnboarded: Boolean) { + controller.handlePrivateModeButtonClicked(newMode, userHasBeenOnboarded) + } + override fun onPasteAndGo(clipboardText: String) { controller.handlePasteAndGo(clipboardText) } @@ -341,6 +365,10 @@ class SessionControlInteractor( recentTabController.handleRecentTabClicked(tabId) } + override fun onRecentSearchGroupClicked(tabId: String) { + recentTabController.handleRecentSearchGroupClicked(tabId) + } + override fun onRecentTabShowAllClicked() { recentTabController.handleRecentTabShowAllClicked() } @@ -353,17 +381,45 @@ class SessionControlInteractor( recentBookmarksController.handleShowAllBookmarksClicked() } - override fun onHistoryMetadataItemClicked(url: String, historyMetadata: HistoryMetadataKey) { - historyMetadataController.handleHistoryMetadataItemClicked(url, historyMetadata) - } - override fun onHistoryMetadataShowAllClicked() { historyMetadataController.handleHistoryShowAllClicked() } - override fun onToggleHistoryMetadataGroupExpanded(historyMetadataGroup: HistoryMetadataGroup) { - historyMetadataController.handleToggleHistoryMetadataGroupExpanded( + override fun onHistoryMetadataGroupClicked(historyMetadataGroup: HistoryMetadataGroup) { + historyMetadataController.handleHistoryMetadataGroupClicked( historyMetadataGroup ) } + + override fun onRemoveGroup(searchTerm: String) { + historyMetadataController.handleRemoveGroup(searchTerm) + } + + override fun openCustomizeHomePage() { + controller.handleCustomizeHomeTapped() + } + + override fun onStoriesShown(storiesShown: List) { + pocketStoriesController.handleStoriesShown(storiesShown) + } + + override fun onCategoryClicked(categoryClicked: PocketRecommendedStoriesCategory) { + pocketStoriesController.handleCategoryClick(categoryClicked) + } + + override fun onStoryClicked(storyClicked: PocketRecommendedStory, storyPosition: Pair) { + pocketStoriesController.handleStoryClicked(storyClicked, storyPosition) + } + + override fun onLearnMoreClicked(link: String) { + pocketStoriesController.handleLearnMoreClicked(link) + } + + override fun onDiscoverMoreClicked(link: String) { + pocketStoriesController.handleDiscoverMoreClicked(link) + } + + override fun reportSessionMetrics(state: HomeFragmentState) { + controller.handleReportSessionMetrics(state) + } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt index 28ffd20a8c..95a0d007d1 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt @@ -10,24 +10,28 @@ import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.extensions.LayoutContainer import mozilla.components.concept.storage.BookmarkNode -import mozilla.components.browser.state.state.TabSessionState import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.top.sites.TopSite +import mozilla.components.service.pocket.PocketRecommendedStory import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.historymetadata.HistoryMetadataGroup import org.mozilla.fenix.home.HomeFragmentState +import org.mozilla.fenix.home.HomeFragmentStore import org.mozilla.fenix.home.HomeScreenViewModel import org.mozilla.fenix.home.Mode import org.mozilla.fenix.home.OnboardingState -import org.mozilla.fenix.home.recenttabs.view.RecentTabsItemPosition +import org.mozilla.fenix.home.recenttabs.RecentTab +import org.mozilla.fenix.onboarding.JumpBackInCFRDialog +import org.mozilla.fenix.utils.Settings // This method got a little complex with the addition of the tab tray feature flag // When we remove the tabs from the home screen this will get much simpler again. @Suppress("ComplexMethod", "LongParameterList") -private fun normalModeAdapterItems( +@VisibleForTesting +internal fun normalModeAdapterItems( topSites: List, collections: List, expandedCollections: Set, @@ -35,10 +39,12 @@ private fun normalModeAdapterItems( recentBookmarks: List, showCollectionsPlaceholder: Boolean, showSetAsDefaultBrowserCard: Boolean, - recentTabs: List, - historyMetadata: List + recentTabs: List, + historyMetadata: List, + pocketStories: List ): List { val items = mutableListOf() + var shouldShowCustomizeHome = false tip?.let { items.add(AdapterItem.TipItem(it)) } @@ -51,15 +57,20 @@ private fun normalModeAdapterItems( } if (recentTabs.isNotEmpty()) { - showRecentTabs(recentTabs, items) + shouldShowCustomizeHome = true + items.add(AdapterItem.RecentTabsHeader) + items.add(AdapterItem.RecentTabItem) } if (recentBookmarks.isNotEmpty()) { + shouldShowCustomizeHome = true items.add(AdapterItem.RecentBookmarks(recentBookmarks)) } if (historyMetadata.isNotEmpty()) { - showHistoryMetadata(historyMetadata, items) + shouldShowCustomizeHome = true + items.add(AdapterItem.HistoryMetadataHeader) + items.add(AdapterItem.HistoryMetadataGroup) } if (collections.isEmpty()) { @@ -70,63 +81,16 @@ private fun normalModeAdapterItems( showCollections(collections, expandedCollections, items) } - return items -} - -/** - * Constructs the list of items to be shown in the recent tabs section. - * - * This section's structure is: - * - section header - * - one or more normal tabs - * - zero or one media tab (if there is a tab opened on which media started playing. - * This may be a duplicate of one of the normal tabs shown above). - */ -@VisibleForTesting -internal fun showRecentTabs( - recentTabs: List, - items: MutableList -) { - items.add(AdapterItem.RecentTabsHeader) - - recentTabs.forEachIndexed { index, recentTab -> - // If this is the first tab to be shown but more will follow. - if (index == 0 && recentTabs.size > 1) { - items.add(AdapterItem.RecentTabItem(recentTab, RecentTabsItemPosition.TOP)) - } - - // if this is the only tab to be shown. - else if (index == 0 && recentTabs.size == 1) { - items.add(AdapterItem.RecentTabItem(recentTab, RecentTabsItemPosition.SINGLE)) - } - - // If there are items above and below. - else if (index < recentTabs.size - 1) { - items.add(AdapterItem.RecentTabItem(recentTab, RecentTabsItemPosition.MIDDLE)) - } - - // If this is the last recent tab to be shown. - else if (index < recentTabs.size) { - items.add(AdapterItem.RecentTabItem(recentTab, RecentTabsItemPosition.BOTTOM)) - } + if (pocketStories.isNotEmpty()) { + shouldShowCustomizeHome = true + items.add(AdapterItem.PocketStoriesItem) } -} - -private fun showHistoryMetadata( - historyMetadata: List, - items: MutableList -) { - items.add(AdapterItem.HistoryMetadataHeader) - historyMetadata.forEach { container -> - items.add(AdapterItem.HistoryMetadataGroup(historyMetadataGroup = container)) - - if (container.expanded) { - container.historyMetadata.forEach { - items.add(AdapterItem.HistoryMetadataItem(it)) - } - } + if (shouldShowCustomizeHome) { + items.add(AdapterItem.CustomizeHomeButton) } + + return items } private fun showCollections( @@ -195,27 +159,37 @@ private fun HomeFragmentState.toAdapterList(): List = when (mode) { showCollectionPlaceholder, showSetAsDefaultBrowserCard, recentTabs, - historyMetadata + historyMetadata, + pocketStories ) is Mode.Private -> privateModeAdapterItems() is Mode.Onboarding -> onboardingAdapterItems(mode.state) } +@VisibleForTesting +internal fun HomeFragmentState.shouldShowHomeOnboardingDialog(settings: Settings): Boolean { + val isAnySectionsVisible = recentTabs.isNotEmpty() || recentBookmarks.isNotEmpty() || + historyMetadata.isNotEmpty() || pocketStories.isNotEmpty() + return isAnySectionsVisible && !settings.hasShownHomeOnboardingDialog +} + private fun collectionTabItems(collection: TabCollection) = collection.tabs.mapIndexed { index, tab -> AdapterItem.TabInCollectionItem(collection, tab, index == collection.tabs.lastIndex) } class SessionControlView( - override val containerView: View, + store: HomeFragmentStore, + val containerView: View, viewLifecycleOwner: LifecycleOwner, - interactor: SessionControlInteractor, + internal val interactor: SessionControlInteractor, private var homeScreenViewModel: HomeScreenViewModel -) : LayoutContainer { +) { val view: RecyclerView = containerView as RecyclerView private val sessionControlAdapter = SessionControlAdapter( + store, interactor, viewLifecycleOwner, containerView.context.components @@ -224,7 +198,13 @@ class SessionControlView( init { view.apply { adapter = sessionControlAdapter - layoutManager = LinearLayoutManager(containerView.context) + layoutManager = object : LinearLayoutManager(containerView.context) { + override fun onLayoutCompleted(state: RecyclerView.State?) { + super.onLayoutCompleted(state) + + JumpBackInCFRDialog(view).showIfNeeded() + } + } val itemTouchHelper = ItemTouchHelper( SwipeToDeleteCallback( @@ -235,7 +215,13 @@ class SessionControlView( } } - fun update(state: HomeFragmentState) { + fun update(state: HomeFragmentState, shouldReportMetrics: Boolean = false) { + if (state.shouldShowHomeOnboardingDialog(view.context.settings())) { + interactor.showOnboardingDialog() + } + + if (shouldReportMetrics) interactor.reportSessionMetrics(state) + val stateAdapterList = state.toAdapterList() if (homeScreenViewModel.shouldScrollToTopSites) { sessionControlAdapter.submitList(stateAdapterList) { diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/CustomizeHomeButtonViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/CustomizeHomeButtonViewHolder.kt new file mode 100644 index 0000000000..3b01a974e7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/CustomizeHomeButtonViewHolder.kt @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home.sessioncontrol.viewholders + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.CustomizeHomeListItemBinding +import org.mozilla.fenix.home.sessioncontrol.CustomizeHomeIteractor + +class CustomizeHomeButtonViewHolder( + view: View, + private val interactor: CustomizeHomeIteractor +) : RecyclerView.ViewHolder(view) { + + init { + val binding = CustomizeHomeListItemBinding.bind(view) + + binding.customizeHome.setOnClickListener { + interactor.openCustomizeHomePage() + } + } + + companion object { + const val LAYOUT_ID = R.layout.customize_home_list_item + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/onboarding/OnboardingPrivateBrowsingViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/onboarding/OnboardingPrivateBrowsingViewHolder.kt deleted file mode 100644 index b87f68330c..0000000000 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/onboarding/OnboardingPrivateBrowsingViewHolder.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.text.SpannableString -import android.text.Spanned -import android.text.style.ImageSpan -import android.view.View -import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import androidx.recyclerview.widget.RecyclerView -import mozilla.components.support.ktx.android.content.getColorFromAttr -import mozilla.components.support.ktx.android.content.getDrawableWithTint -import org.mozilla.fenix.R -import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.databinding.OnboardingPrivateBrowsingBinding -import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.setBounds -import org.mozilla.fenix.home.sessioncontrol.OnboardingInteractor - -class OnboardingPrivateBrowsingViewHolder( - view: View, - private val interactor: OnboardingInteractor -) : RecyclerView.ViewHolder(view) { - - init { - val binding = OnboardingPrivateBrowsingBinding.bind(view) - binding.headerText.setOnboardingIcon(R.drawable.ic_onboarding_private_browsing) - - // Display a private browsing icon as a character inside the description text. - val inlineIcon = PrivateBrowsingImageSpan( - view.context, - R.drawable.ic_private_browsing, - tint = view.context.getColorFromAttr(R.attr.primaryText), - size = binding.descriptionTextOnce.lineHeight - ) - - val text = SpannableString(view.context.getString(R.string.onboarding_private_browsing_description1)).apply { - val spanStartIndex = indexOf(IMAGE_PLACEHOLDER) - setSpan( - inlineIcon, - spanStartIndex, - spanStartIndex + IMAGE_PLACEHOLDER.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - - binding.descriptionTextOnce.text = text - binding.descriptionTextOnce.contentDescription = String.format(text.toString(), binding.headerText.text) - binding.openSettingsButton.setOnClickListener { - it.context.components.analytics.metrics.track(Event.OnboardingPrivateBrowsing) - interactor.onOpenSettingsClicked() - } - } - - class PrivateBrowsingImageSpan( - context: Context, - @DrawableRes drawableId: Int, - @ColorInt tint: Int, - size: Int - ) : ImageSpan( - context.getDrawableWithTint(drawableId, tint)!!.apply { setBounds(size) } - ) { - override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - canvas.save() - val fmPaint = paint.fontMetricsInt - val fontHeight = fmPaint.descent - fmPaint.ascent - val centerY = y + fmPaint.descent - fontHeight / 2 - val transY = (centerY - (drawable.bounds.bottom - drawable.bounds.top) / 2).toFloat() - canvas.translate(x, transY) - drawable.draw(canvas) - canvas.restore() - } - } - - companion object { - const val IMAGE_PLACEHOLDER = "%s" - const val LAYOUT_ID = R.layout.onboarding_private_browsing - } -} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/onboarding/OnboardingWhatsNewViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/onboarding/OnboardingWhatsNewViewHolder.kt deleted file mode 100644 index efa4dd4f22..0000000000 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/onboarding/OnboardingWhatsNewViewHolder.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import org.mozilla.fenix.R -import org.mozilla.fenix.databinding.OnboardingWhatsNewBinding -import org.mozilla.fenix.ext.addUnderline -import org.mozilla.fenix.home.sessioncontrol.OnboardingInteractor - -class OnboardingWhatsNewViewHolder( - view: View, - private val interactor: OnboardingInteractor -) : RecyclerView.ViewHolder(view) { - - init { - val binding = OnboardingWhatsNewBinding.bind(view) - binding.headerText.setOnboardingIcon(R.drawable.ic_whats_new) - - val appName = view.context.getString(R.string.app_name) - binding.descriptionText.text = view.context.getString(R.string.onboarding_whats_new_description, appName) - - binding.getAnswers.addUnderline() - binding.getAnswers.setOnClickListener { - interactor.onWhatsNewGetAnswersClicked() - } - } - - companion object { - const val LAYOUT_ID = R.layout.onboarding_whats_new - } -} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoriesCategory.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoriesCategory.kt new file mode 100644 index 0000000000..c7f99651cf --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoriesCategory.kt @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket + +import mozilla.components.service.pocket.PocketRecommendedStory + +/** + * Category name of the default category from which stories are to be shown + * if user hasn't explicitly selected others. + */ +const val POCKET_STORIES_DEFAULT_CATEGORY_NAME = "general" + +/** + * In memory cache of Pocket assigned topic of interest for recommended stories. + * Avoids multiple stories mappings for each time we are interested in their categories. + * + * One to many relationship with [PocketRecommendedStory]es. + * + * @property name The exact name of each category. Case sensitive. + * @property stories All [PocketRecommendedStory]s with this category. + */ +data class PocketRecommendedStoriesCategory( + val name: String, + val stories: List = emptyList() +) diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoriesSelectedCategory.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoriesSelectedCategory.kt new file mode 100644 index 0000000000..b016e43ad8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoriesSelectedCategory.kt @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket + +/** + * Details about a selected Pocket recommended stories category. + * + * @property name The exact name of a selected category. Case sensitive. + * @property selectionTimestamp The exact time at which a category was selected. Defaults to [System.currentTimeMillis]. + */ +data class PocketRecommendedStoriesSelectedCategory( + val name: String, + val selectionTimestamp: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt new file mode 100644 index 0000000000..b059f6c8be --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt @@ -0,0 +1,294 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@file:Suppress("MagicNumber") + +package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import mozilla.components.service.pocket.PocketRecommendedStory +import mozilla.components.ui.colors.PhotonColors +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.ClickableSubstringLink +import org.mozilla.fenix.compose.EagerFlingBehavior +import org.mozilla.fenix.compose.ListItemTabLarge +import org.mozilla.fenix.compose.ListItemTabLargePlaceholder +import org.mozilla.fenix.compose.SelectableChip +import org.mozilla.fenix.compose.StaggeredHorizontalGrid +import org.mozilla.fenix.compose.TabSubtitle +import org.mozilla.fenix.compose.TabSubtitleWithInterdot +import org.mozilla.fenix.compose.TabTitle +import org.mozilla.fenix.theme.FirefoxTheme +import kotlin.math.roundToInt +import kotlin.random.Random + +private const val URI_PARAM_UTM_KEY = "utm_source" +private const val POCKET_STORIES_UTM_VALUE = "pocket-newtab-android" +private const val POCKET_FEATURE_UTM_KEY_VALUE = "utm_source=ff_android" + +/** + * Placeholder [PocketRecommendedStory] allowing to combine other items in the same list that shows stories. + * It uses empty values for it's properties ensuring that no conflict is possible since real stories have + * mandatory values. + */ +private val placeholderStory = PocketRecommendedStory("", "", "", "", "", 0, 0) + +/** + * Displays a single [PocketRecommendedStory]. + * + * @param story The [PocketRecommendedStory] to be displayed. + * @param onStoryClick Callback for when the user taps on this story. + */ +@Composable +fun PocketStory( + @PreviewParameter(PocketStoryProvider::class) story: PocketRecommendedStory, + onStoryClick: (PocketRecommendedStory) -> Unit, +) { + val imageUrl = story.imageUrl.replace( + "{wh}", + with(LocalDensity.current) { "${116.dp.toPx().roundToInt()}x${84.dp.toPx().roundToInt()}" } + ) + val isValidPublisher = story.publisher.isNotBlank() + val isValidTimeToRead = story.timeToRead >= 0 + ListItemTabLarge( + imageUrl = imageUrl, + onClick = { onStoryClick(story) }, + title = { + TabTitle(text = story.title, maxLines = 2) + }, + subtitle = { + if (isValidPublisher && isValidTimeToRead) { + TabSubtitleWithInterdot(story.publisher, "${story.timeToRead} min") + } else if (isValidPublisher) { + TabSubtitle(story.publisher) + } else if (isValidTimeToRead) { + TabSubtitle("${story.timeToRead} min") + } + } + ) +} + +/** + * Displays a list of [PocketRecommendedStory]es on 3 by 3 grid. + * If there aren't enough stories to fill all columns placeholders containing an external link + * to go to Pocket for more recommendations are added. + * + * @param stories The list of [PocketRecommendedStory]ies to be displayed. Expect a list with 8 items. + * @param contentPadding Dimension for padding the content after it has been clipped. + * This space will be used for shadows and also content rendering when the list is scrolled. + * @param onStoryClicked Callback for when the user taps on a recommended story. + * @param onDiscoverMoreClicked Callback for when the user taps an element which contains an + */ +@Composable +fun PocketStories( + @PreviewParameter(PocketStoryProvider::class) stories: List, + contentPadding: Dp, + onStoryClicked: (PocketRecommendedStory, Pair) -> Unit, + onDiscoverMoreClicked: (String) -> Unit +) { + // Show stories in at most 3 rows but on any number of columns depending on the data received. + val maxRowsNo = 3 + val storiesToShow = (stories + placeholderStory).chunked(maxRowsNo) + + val listState = rememberLazyListState() + val flingBehavior = EagerFlingBehavior(lazyRowState = listState) + + LazyRow( + contentPadding = PaddingValues(horizontal = contentPadding), + state = listState, + flingBehavior = flingBehavior, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(storiesToShow) { columnIndex, columnItems -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + columnItems.forEachIndexed { rowIndex, story -> + if (story == placeholderStory) { + ListItemTabLargePlaceholder(stringResource(R.string.pocket_stories_placeholder_text)) { + onDiscoverMoreClicked("https://getpocket.com/explore?$POCKET_FEATURE_UTM_KEY_VALUE") + } + } else { + PocketStory(story) { + val uri = Uri.parse(story.url) + .buildUpon() + .appendQueryParameter(URI_PARAM_UTM_KEY, POCKET_STORIES_UTM_VALUE) + .build().toString() + onStoryClicked(it.copy(url = uri), rowIndex to columnIndex) + } + } + } + } + } + } +} + +/** + * Displays a list of [PocketRecommendedStoriesCategory]s. + * + * @param categories The categories needed to be displayed. + * @param selections List of categories currently selected. + * @param onCategoryClick Callback for when the user taps a category. + * @param modifier [Modifier] to be applied to the layout. + */ +@Composable +fun PocketStoriesCategories( + categories: List, + selections: List, + onCategoryClick: (PocketRecommendedStoriesCategory) -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + StaggeredHorizontalGrid( + horizontalItemsSpacing = 16.dp, + verticalItemsSpacing = 16.dp + ) { + categories.filter { it.name != POCKET_STORIES_DEFAULT_CATEGORY_NAME }.forEach { category -> + SelectableChip(category.name, selections.map { it.name }.contains(category.name)) { + onCategoryClick(category) + } + } + } + } +} + +/** + * Pocket feature section title. + * Shows a default text about Pocket and offers a external link to learn more. + * + * @param onLearnMoreClicked Callback invoked when the user clicks the "Learn more" link. + * Contains the full URL for where the user should be navigated to. + * @param modifier [Modifier] to be applied to the layout. + */ +@Composable +fun PoweredByPocketHeader( + onLearnMoreClicked: (String) -> Unit, + modifier: Modifier = Modifier +) { + val color = when (isSystemInDarkTheme()) { + true -> PhotonColors.LightGrey30 + false -> PhotonColors.DarkGrey90 + } + + val link = stringResource(R.string.pocket_stories_feature_learn_more) + val text = stringResource(R.string.pocket_stories_feature_caption, link) + val linkStartIndex = text.indexOf(link) + val linkEndIndex = linkStartIndex + link.length + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.pocket_vector), + contentDescription = null, + // Apply the red tint in code. Otherwise the image is black and white. + tint = Color(0xFFEF4056) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + Text( + text = stringResource(R.string.pocket_stories_feature_title), + color = color, + fontSize = 12.sp, + lineHeight = 16.sp + ) + + ClickableSubstringLink(text, color, linkStartIndex, linkEndIndex) { + onLearnMoreClicked("https://www.mozilla.org/en-US/firefox/pocket/?$POCKET_FEATURE_UTM_KEY_VALUE") + } + } + } + } +} + +@Composable +@Preview +private fun PocketStoriesComposablesPreview() { + FirefoxTheme { + Box(Modifier.background(FirefoxTheme.colors.surface)) { + Column { + PocketStories( + stories = getFakePocketStories(8), + contentPadding = 0.dp, + onStoryClicked = { _, _ -> }, + onDiscoverMoreClicked = { } + ) + Spacer(Modifier.height(10.dp)) + + PocketStoriesCategories( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor".split(" ").map { + PocketRecommendedStoriesCategory(it) + }, + emptyList(), + { } + ) + Spacer(Modifier.height(10.dp)) + + PoweredByPocketHeader({ }) + } + } + } +} + +private class PocketStoryProvider : PreviewParameterProvider { + override val values = getFakePocketStories(7).asSequence() + override val count = 8 +} + +private fun getFakePocketStories(limit: Int = 1): List { + return mutableListOf().apply { + for (index in 0 until limit) { + val randomNumber = Random.nextInt(0, 10) + + add( + PocketRecommendedStory( + title = "This is a ${"very ".repeat(randomNumber)} long title", + publisher = "Publisher", + url = "https://story$randomNumber.com", + imageUrl = "", + timeToRead = randomNumber, + category = "Category #$randomNumber", + timesShown = randomNumber.toLong() + ) + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt new file mode 100644 index 0000000000..708f3136bc --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt @@ -0,0 +1,142 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket + +import androidx.annotation.VisibleForTesting +import androidx.navigation.NavController +import org.mozilla.fenix.home.HomeFragmentAction +import org.mozilla.fenix.home.HomeFragmentStore +import mozilla.components.lib.state.Store +import mozilla.components.service.pocket.PocketRecommendedStory +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController + +/** + * Contract for how all user interactions with the Pocket recommended stories feature are to be handled. + */ +interface PocketStoriesController { + /** + * Callback to decide what should happen as an effect of a new list of stories being shown. + * + * @param storiesShown the new list of [PocketRecommendedStory]es shown to the user. + */ + fun handleStoriesShown(storiesShown: List) + + /** + * Callback allowing to handle a specific [PocketRecommendedStoriesCategory] being clicked by the user. + * + * @param categoryClicked the just clicked [PocketRecommendedStoriesCategory]. + */ + fun handleCategoryClick(categoryClicked: PocketRecommendedStoriesCategory): Unit + + /** + * Callback for when the user clicks on a specific story. + * + * @param storyClicked The just clicked [PocketRecommendedStory] URL. + * @param storyPosition `row x column` matrix representing the grid position of the clicked story. + */ + fun handleStoryClicked(storyClicked: PocketRecommendedStory, storyPosition: Pair) + + /** + * Callback for when the "Learn more" link is clicked. + * + * @param link URL clicked. + */ + fun handleLearnMoreClicked(link: String) + + /** + * Callback for when the "Discover more" link is clicked. + * + * @param link URL clicked. + */ + fun handleDiscoverMoreClicked(link: String) +} + +/** + * Default behavior for handling all user interactions with the Pocket recommended stories feature. + * + * @param homeActivity [HomeActivity] used to open URLs in a new tab. + * @param homeStore [Store] from which to read the current Pocket recommendations and dispatch new actions on. + * @param navController [NavController] used for navigation. + */ +internal class DefaultPocketStoriesController( + private val homeActivity: HomeActivity, + private val homeStore: HomeFragmentStore, + private val navController: NavController, + private val metrics: MetricController +) : PocketStoriesController { + override fun handleStoriesShown(storiesShown: List) { + homeStore.dispatch(HomeFragmentAction.PocketStoriesShown(storiesShown)) + metrics.track(Event.PocketHomeRecsShown) + } + + override fun handleCategoryClick(categoryClicked: PocketRecommendedStoriesCategory) { + val initialCategoriesSelections = homeStore.state.pocketStoriesCategoriesSelections + + // First check whether the category is clicked to be deselected. + if (initialCategoriesSelections.map { it.name }.contains(categoryClicked.name)) { + homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(categoryClicked.name)) + metrics.track( + Event.PocketHomeRecsCategoryClicked( + categoryClicked.name, + initialCategoriesSelections.size, + false + ) + ) + return + } + + // If a new category is clicked to be selected: + // Ensure the number of categories selected at a time is capped. + val oldestCategoryToDeselect = + if (initialCategoriesSelections.size == POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT) { + initialCategoriesSelections.minByOrNull { it.selectionTimestamp } + } else { + null + } + oldestCategoryToDeselect?.let { + homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(it.name)) + } + + // Finally update the selection. + homeStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(categoryClicked.name)) + + metrics.track( + Event.PocketHomeRecsCategoryClicked( + categoryClicked.name, + initialCategoriesSelections.size, + true + ) + ) + } + + override fun handleStoryClicked(storyClicked: PocketRecommendedStory, storyPosition: Pair) { + dismissSearchDialogIfDisplayed() + homeActivity.openToBrowserAndLoad(storyClicked.url, true, BrowserDirection.FromHome) + metrics.track(Event.PocketHomeRecsStoryClicked(storyClicked.timesShown.inc(), storyPosition)) + } + + override fun handleLearnMoreClicked(link: String) { + dismissSearchDialogIfDisplayed() + homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome) + metrics.track(Event.PocketHomeRecsLearnMoreClicked) + } + + override fun handleDiscoverMoreClicked(link: String) { + dismissSearchDialogIfDisplayed() + homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome) + metrics.track(Event.PocketHomeRecsDiscoverMoreClicked) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun dismissSearchDialogIfDisplayed() { + if (navController.currentDestination?.id == R.id.searchDialogFragment) { + navController.navigateUp() + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt new file mode 100644 index 0000000000..db814fc844 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket + +import mozilla.components.service.pocket.PocketRecommendedStory + +/** + * Contract for all possible user interactions with the Pocket recommended stories feature. + */ +interface PocketStoriesInteractor { + /** + * Callback for then new stories are shown to the user. + * + * @param storiesShown The new list of [PocketRecommendedStory]es shown to the user. + */ + fun onStoriesShown(storiesShown: List) + + /** + * Callback for when the user clicks a specific category. + * + * @param categoryClicked The just clicked [PocketRecommendedStoriesCategory]. + */ + fun onCategoryClicked(categoryClicked: PocketRecommendedStoriesCategory) + + /** + * Callback for when the user clicks on a specific story. + * + * @param storyClicked The just clicked [PocketRecommendedStory] URL. + * @param storyPosition `row x column` matrix representing the grid position of the clicked story. + */ + fun onStoryClicked(storyClicked: PocketRecommendedStory, storyPosition: Pair) + + /** + * Callback for when the user clicks the "Learn more" link. + * + * @param link URL clicked. + */ + fun onLearnMoreClicked(link: String) + + /** + * Callback for when the user clicks the "Discover more" link. + * + * @param link URL clicked. + */ + fun onDiscoverMoreClicked(link: String) +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt new file mode 100644 index 0000000000..145f283767 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket + +import android.view.View +import androidx.annotation.Dimension +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.lib.state.ext.observeAsComposableState +import mozilla.components.service.pocket.PocketRecommendedStory +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.HomeSectionHeader +import org.mozilla.fenix.home.HomeFragmentStore +import org.mozilla.fenix.theme.FirefoxTheme + +internal const val POCKET_STORIES_TO_SHOW_COUNT = 8 +internal const val POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT = 8 + +/** + * [RecyclerView.ViewHolder] that will display a list of [PocketRecommendedStory]es + * which is to be provided in the [bind] method. + * + * @param composeView [ComposeView] which will be populated with Jetpack Compose UI content. + * @param store [HomeFragmentStore] containing the list of Pocket stories to be displayed. + * @param interactor [PocketStoriesInteractor] callback for user interaction. + */ +class PocketStoriesViewHolder( + val composeView: ComposeView, + val store: HomeFragmentStore, + val interactor: PocketStoriesInteractor +) : RecyclerView.ViewHolder(composeView) { + + init { + composeView.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + composeView.setContent { + FirefoxTheme { + PocketStories( + store, + interactor::onStoriesShown, + interactor::onStoryClicked, + interactor::onCategoryClicked, + interactor::onDiscoverMoreClicked, + interactor::onLearnMoreClicked, + with(composeView.resources) { + getDimensionPixelSize(R.dimen.home_item_horizontal_margin) / displayMetrics.density + } + ) + } + } + } + + companion object { + val LAYOUT_ID = View.generateViewId() + } +} + +@Composable +@Suppress("LongParameterList") +fun PocketStories( + store: HomeFragmentStore, + onStoriesShown: (List) -> Unit, + onStoryClicked: (PocketRecommendedStory, Pair) -> Unit, + onCategoryClicked: (PocketRecommendedStoriesCategory) -> Unit, + onDiscoverMoreClicked: (String) -> Unit, + onLearnMoreClicked: (String) -> Unit, + @Dimension horizontalPadding: Float = 0f +) { + val stories = store + .observeAsComposableState { state -> state.pocketStories }.value + + val categories = store + .observeAsComposableState { state -> state.pocketStoriesCategories }.value + + val categoriesSelections = store + .observeAsComposableState { state -> state.pocketStoriesCategoriesSelections }.value + + LaunchedEffect(stories) { + // We should report back when a certain story is actually being displayed. + // Cannot do it reliably so for now we'll just mass report everything as being displayed. + stories?.let { + onStoriesShown(it) + } + } + + Column(modifier = Modifier.padding(vertical = 44.dp)) { + HomeSectionHeader( + text = stringResource(R.string.pocket_stories_header_1), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding.dp) + .wrapContentHeight(align = Alignment.Top) + ) + + Spacer(Modifier.height(17.dp)) + + PocketStories(stories ?: emptyList(), horizontalPadding.dp, onStoryClicked, onDiscoverMoreClicked) + + Spacer(Modifier.height(24.dp)) + + HomeSectionHeader( + text = stringResource(R.string.pocket_stories_categories_header), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding.dp) + .wrapContentHeight(align = Alignment.Top) + ) + + Spacer(Modifier.height(17.dp)) + + PocketStoriesCategories( + categories = categories ?: emptyList(), + selections = categoriesSelections ?: emptyList(), + onCategoryClick = onCategoryClicked, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding.dp) + ) + + Spacer(Modifier.height(24.dp)) + + PoweredByPocketHeader( + onLearnMoreClicked, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding.dp) + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/topsites/TopSiteItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/topsites/TopSiteItemViewHolder.kt index e337a2b4b6..d4ade4c27e 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/topsites/TopSiteItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/topsites/TopSiteItemViewHolder.kt @@ -10,7 +10,6 @@ import android.view.MotionEvent import android.view.View import android.widget.PopupWindow import androidx.appcompat.content.res.AppCompatResources.getDrawable -import kotlinx.android.synthetic.main.top_site_item.* import mozilla.components.browser.menu.BrowserMenuBuilder import mozilla.components.browser.menu.item.SimpleBrowserMenuItem import mozilla.components.feature.top.sites.TopSite @@ -19,6 +18,7 @@ import mozilla.components.feature.top.sites.TopSite.Type.FRECENT import mozilla.components.feature.top.sites.TopSite.Type.PINNED import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.TopSiteItemBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.home.sessioncontrol.TopSiteInteractor @@ -30,13 +30,14 @@ class TopSiteItemViewHolder( private val interactor: TopSiteInteractor ) : ViewHolder(view) { private lateinit var topSite: TopSite + private val binding = TopSiteItemBinding.bind(view) init { - top_site_item.setOnClickListener { + binding.topSiteItem.setOnClickListener { interactor.onSelectTopSite(topSite.url, topSite.type) } - top_site_item.setOnLongClickListener { + binding.topSiteItem.setOnLongClickListener { interactor.onTopSiteMenuOpened() it.context.components.analytics.metrics.track(Event.TopSiteLongPress(topSite.type)) @@ -62,30 +63,33 @@ class TopSiteItemViewHolder( } fun bind(topSite: TopSite) { - top_site_title.text = topSite.title + binding.topSiteTitle.text = topSite.title if (topSite.type == PINNED || topSite.type == DEFAULT) { val pinIndicator = getDrawable(itemView.context, R.drawable.ic_new_pin) - top_site_title.setCompoundDrawablesWithIntrinsicBounds(pinIndicator, null, null, null) + binding.topSiteTitle.setCompoundDrawablesWithIntrinsicBounds(pinIndicator, null, null, null) } else { - top_site_title.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null) + binding.topSiteTitle.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null) } when (topSite.url) { SupportUtils.POCKET_TRENDING_URL -> { - favicon_image.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_pocket)) + binding.faviconImage.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_pocket)) } SupportUtils.BAIDU_URL -> { - favicon_image.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_baidu)) + binding.faviconImage.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_baidu)) } SupportUtils.JD_URL -> { - favicon_image.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_jd)) + binding.faviconImage.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_jd)) } SupportUtils.PDD_URL -> { - favicon_image.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_pdd)) + binding.faviconImage.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_pdd)) + } + SupportUtils.TC_URL -> { + binding.faviconImage.setImageDrawable(getDrawable(itemView.context, R.drawable.ic_tc)) } else -> { - itemView.context.components.core.icons.loadIntoView(favicon_image, topSite.url) + itemView.context.components.core.icons.loadIntoView(binding.faviconImage, topSite.url) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/LibraryPageView.kt b/app/src/main/java/org/mozilla/fenix/library/LibraryPageView.kt index 183f62abfa..a9d8ece854 100644 --- a/app/src/main/java/org/mozilla/fenix/library/LibraryPageView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/LibraryPageView.kt @@ -8,15 +8,14 @@ import android.content.Context import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat -import kotlinx.android.extensions.LayoutContainer import mozilla.components.support.ktx.android.content.getColorFromAttr import org.mozilla.fenix.R import org.mozilla.fenix.ext.asActivity import org.mozilla.fenix.ext.setToolbarColors open class LibraryPageView( - override val containerView: ViewGroup -) : LayoutContainer { + val containerView: ViewGroup +) { protected val context: Context inline get() = containerView.context protected val activity = context.asActivity() diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/addfolder/AddBookmarkFolderFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/addfolder/AddBookmarkFolderFragment.kt index 59df17b23d..e1d8be4659 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/addfolder/AddBookmarkFolderFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/addfolder/AddBookmarkFolderFragment.kt @@ -14,7 +14,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.Navigation -import kotlinx.android.synthetic.main.fragment_edit_bookmark.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch @@ -24,6 +23,7 @@ import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.components.support.ktx.android.view.showKeyboard import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.FragmentEditBookmarkBinding import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar @@ -34,6 +34,8 @@ import org.mozilla.fenix.library.bookmarks.friendlyRootTitle * Menu to create a new bookmark folder. */ class AddBookmarkFolderFragment : Fragment(R.layout.fragment_edit_bookmark) { + private var _binding: FragmentEditBookmarkBinding? = null + private val binding get() = _binding!! private val sharedViewModel: BookmarksSharedViewModel by activityViewModels() @@ -46,10 +48,12 @@ class AddBookmarkFolderFragment : Fragment(R.layout.fragment_edit_bookmark) { * Hides fields for bookmark items present in the shared layout file. */ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - bookmarkUrlLabel.visibility = GONE - bookmarkUrlEdit.visibility = GONE - inputLayoutBookmarkUrl.visibility = GONE - bookmarkNameEdit.showKeyboard() + _binding = FragmentEditBookmarkBinding.bind(view) + + binding.bookmarkUrlLabel.visibility = GONE + binding.bookmarkUrlEdit.visibility = GONE + binding.inputLayoutBookmarkUrl.visibility = GONE + binding.bookmarkNameEdit.showKeyboard() } override fun onResume() { @@ -63,9 +67,9 @@ class AddBookmarkFolderFragment : Fragment(R.layout.fragment_edit_bookmark) { ?: requireComponents.core.bookmarksStorage.getBookmark(BookmarkRoot.Mobile.id) } - bookmarkParentFolderSelector.text = + binding.bookmarkParentFolderSelector.text = friendlyRootTitle(context, sharedViewModel.selectedFolder!!) - bookmarkParentFolderSelector.setOnClickListener { + binding.bookmarkParentFolderSelector.setOnClickListener { nav( R.id.bookmarkAddFolderFragment, AddBookmarkFolderFragmentDirections @@ -79,7 +83,7 @@ class AddBookmarkFolderFragment : Fragment(R.layout.fragment_edit_bookmark) { override fun onPause() { super.onPause() - bookmarkNameEdit.hideKeyboard() + binding.bookmarkNameEdit.hideKeyboard() } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -89,8 +93,8 @@ class AddBookmarkFolderFragment : Fragment(R.layout.fragment_edit_bookmark) { override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.confirm_add_folder_button -> { - if (bookmarkNameEdit.text.isNullOrBlank()) { - bookmarkNameEdit.error = + if (binding.bookmarkNameEdit.text.isNullOrBlank()) { + binding.bookmarkNameEdit.error = getString(R.string.bookmark_empty_title_error) return true } @@ -98,7 +102,7 @@ class AddBookmarkFolderFragment : Fragment(R.layout.fragment_edit_bookmark) { viewLifecycleOwner.lifecycleScope.launch(IO) { val newGuid = requireComponents.core.bookmarksStorage.addFolder( sharedViewModel.selectedFolder!!.guid, - bookmarkNameEdit.text.toString(), + binding.bookmarkNameEdit.text.toString(), null ) sharedViewModel.selectedFolder = @@ -114,4 +118,10 @@ class AddBookmarkFolderFragment : Fragment(R.layout.fragment_edit_bookmark) { else -> super.onOptionsItemSelected(item) } } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } } diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/edit/EditBookmarkFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/edit/EditBookmarkFragment.kt index 2df8e09ae7..99bc5869fb 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/edit/EditBookmarkFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/edit/EditBookmarkFragment.kt @@ -23,8 +23,6 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation.Navigation import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_edit_bookmark.* -import kotlinx.android.synthetic.main.fragment_edit_bookmark.view.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch @@ -40,6 +38,7 @@ import org.mozilla.fenix.NavHostActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.FragmentEditBookmarkBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getRootView import org.mozilla.fenix.ext.nav @@ -54,6 +53,8 @@ import org.mozilla.fenix.library.bookmarks.friendlyRootTitle * Menu to edit the name, URL, and location of a bookmark item. */ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { + private var _binding: FragmentEditBookmarkBinding? = null + private val binding get() = _binding!! private val args by navArgs() private val sharedViewModel: BookmarksSharedViewModel by activityViewModels() @@ -68,6 +69,9 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + _binding = FragmentEditBookmarkBinding.bind(view) + initToolbar() viewLifecycleOwner.lifecycleScope.launch(Main) { @@ -95,9 +99,9 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { when (bookmarkNode?.type) { BookmarkNodeType.FOLDER -> { activity?.title = getString(R.string.edit_bookmark_folder_fragment_title) - inputLayoutBookmarkUrl.visibility = View.GONE - bookmarkUrlEdit.visibility = View.GONE - bookmarkUrlLabel.visibility = View.GONE + binding.inputLayoutBookmarkUrl.visibility = View.GONE + binding.bookmarkUrlEdit.visibility = View.GONE + binding.bookmarkUrlLabel.visibility = View.GONE } BookmarkNodeType.ITEM -> { activity?.title = getString(R.string.edit_bookmark_fragment_title) @@ -107,15 +111,15 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { val currentBookmarkNode = bookmarkNode if (currentBookmarkNode != null && currentBookmarkNode != bookmarkNodeBeforeReload) { - bookmarkNameEdit.setText(currentBookmarkNode.title) - bookmarkUrlEdit.setText(currentBookmarkNode.url) + binding.bookmarkNameEdit.setText(currentBookmarkNode.title) + binding.bookmarkUrlEdit.setText(currentBookmarkNode.url) } bookmarkParent?.let { node -> - bookmarkParentFolderSelector.text = friendlyRootTitle(context, node) + binding.bookmarkParentFolderSelector.text = friendlyRootTitle(context, node) } - bookmarkParentFolderSelector.setOnClickListener { + binding.bookmarkParentFolderSelector.setOnClickListener { sharedViewModel.selectedFolder = null nav( R.id.bookmarkEditFragment, @@ -131,22 +135,22 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { ) } - view.bookmarkNameEdit.apply { + binding.bookmarkNameEdit.apply { requestFocus() placeCursorAtEnd() showKeyboard() } - view.bookmarkUrlEdit.addTextChangedListener(object : TextWatcher { + binding.bookmarkUrlEdit.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { // NOOP } override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - bookmarkUrlEdit.onTextChanged(s) + binding.bookmarkUrlEdit.onTextChanged(s) - inputLayoutBookmarkUrl.error = null - inputLayoutBookmarkUrl.errorIconDrawable = null + binding.inputLayoutBookmarkUrl.error = null + binding.inputLayoutBookmarkUrl.errorIconDrawable = null } override fun afterTextChanged(s: Editable?) { @@ -169,9 +173,9 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { override fun onPause() { super.onPause() - bookmarkNameEdit.hideKeyboard() - bookmarkUrlEdit.hideKeyboard() - progress_bar_bookmark.visibility = View.GONE + binding.bookmarkNameEdit.hideKeyboard() + binding.bookmarkUrlEdit.hideKeyboard() + binding.progressBarBookmark.visibility = View.GONE } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -234,9 +238,9 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { } private fun updateBookmarkFromTextChanges() { - progress_bar_bookmark.visibility = View.VISIBLE - val nameText = bookmarkNameEdit.text.toString() - val urlText = bookmarkUrlEdit.text.toString() + binding.progressBarBookmark.visibility = View.VISIBLE + val nameText = binding.bookmarkNameEdit.text.toString() + val urlText = binding.bookmarkUrlEdit.text.toString() updateBookmarkNode(nameText, urlText) } @@ -265,16 +269,16 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { ) } withContext(Main) { - inputLayoutBookmarkUrl.error = null - inputLayoutBookmarkUrl.errorIconDrawable = null + binding.inputLayoutBookmarkUrl.error = null + binding.inputLayoutBookmarkUrl.errorIconDrawable = null findNavController().popBackStack() } } catch (e: UrlParseFailed) { withContext(Main) { - inputLayoutBookmarkUrl.error = getString(R.string.bookmark_invalid_url_error) - inputLayoutBookmarkUrl.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) - inputLayoutBookmarkUrl.setErrorIconTintList( + binding.inputLayoutBookmarkUrl.error = getString(R.string.bookmark_invalid_url_error) + binding.inputLayoutBookmarkUrl.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + binding.inputLayoutBookmarkUrl.setErrorIconTintList( ColorStateList.valueOf( ContextCompat.getColor(requireContext(), R.color.design_error) ) @@ -282,6 +286,12 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { } } } - progress_bar_bookmark.visibility = View.INVISIBLE + binding.progressBarBookmark.visibility = View.INVISIBLE + } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null } } diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderAdapter.kt index 3fc46631da..527647b249 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderAdapter.kt @@ -12,7 +12,6 @@ import androidx.core.view.updatePaddingRelative import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.extensions.LayoutContainer import mozilla.components.concept.storage.BookmarkNode import org.mozilla.fenix.R import org.mozilla.fenix.library.LibrarySiteItemView @@ -61,9 +60,7 @@ class SelectBookmarkFolderAdapter(private val sharedViewModel: BookmarksSharedVi class BookmarkFolderViewHolder( val view: LibrarySiteItemView - ) : RecyclerView.ViewHolder(view), LayoutContainer { - - override val containerView get() = view + ) : RecyclerView.ViewHolder(view) { init { view.displayAs(LibrarySiteItemView.ItemType.FOLDER) @@ -74,12 +71,12 @@ class SelectBookmarkFolderAdapter(private val sharedViewModel: BookmarksSharedVi view.changeSelected(selected) view.iconView.setImageDrawable( AppCompatResources.getDrawable( - containerView.context, + view.context, R.drawable.ic_folder_icon )?.apply { setTint( ContextCompat.getColor( - containerView.context, + view.context, R.color.primary_text_light_theme ) ) diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderFragment.kt index dfc079dc86..3b52fa6ec4 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderFragment.kt @@ -15,7 +15,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_select_bookmark_folder.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch @@ -23,6 +22,7 @@ import kotlinx.coroutines.withContext import mozilla.appservices.places.BookmarkRoot import mozilla.components.concept.storage.BookmarkNode import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentSelectBookmarkFolderBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.showToolbar @@ -30,6 +30,8 @@ import org.mozilla.fenix.library.bookmarks.BookmarksSharedViewModel import org.mozilla.fenix.library.bookmarks.DesktopFolders class SelectBookmarkFolderFragment : Fragment() { + private var _binding: FragmentSelectBookmarkFolderBinding? = null + private val binding get() = _binding!! private val sharedViewModel: BookmarksSharedViewModel by activityViewModels() private var bookmarkNode: BookmarkNode? = null @@ -40,7 +42,15 @@ class SelectBookmarkFolderFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_select_bookmark_folder, container, false) + _binding = FragmentSelectBookmarkFolderBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null } override fun onResume() { @@ -57,7 +67,7 @@ class SelectBookmarkFolderFragment : Fragment() { ?.let { DesktopFolders(context, showMobileRoot = true).withOptionalDesktopFolders(it) } } val adapter = SelectBookmarkFolderAdapter(sharedViewModel) - recylerViewBookmarkFolders.adapter = adapter + binding.recylerViewBookmarkFolders.adapter = adapter adapter.updateData(bookmarkNode, args.hideFolderGuid) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadView.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadView.kt index c060b29f54..dcc0f88eb7 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadView.kt @@ -9,8 +9,6 @@ import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator -import kotlinx.android.synthetic.main.component_downloads.* -import kotlinx.android.synthetic.main.component_downloads.view.* import mozilla.components.support.base.feature.UserInteractionHandler import org.mozilla.fenix.R import org.mozilla.fenix.databinding.ComponentDownloadsBinding @@ -112,10 +110,10 @@ class DownloadView( } fun updateEmptyState(userHasDownloads: Boolean) { - download_list.isVisible = userHasDownloads - download_empty_view.isVisible = !userHasDownloads + binding.downloadList.isVisible = userHasDownloads + binding.downloadEmptyView.isVisible = !userHasDownloads if (!userHasDownloads) { - download_empty_view.announceForAccessibility(context.getString(R.string.download_empty_message_1)) + binding.downloadEmptyView.announceForAccessibility(context.getString(R.string.download_empty_message_1)) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt index 32b43b2210..5e535de4b0 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt @@ -28,9 +28,10 @@ enum class HistoryItemTimeGroup { } } -class HistoryAdapter(private val historyInteractor: HistoryInteractor) : - PagedListAdapter(historyDiffCallback), - SelectionHolder { +class HistoryAdapter( + private val historyInteractor: HistoryInteractor, +) : PagedListAdapter(historyDiffCallback), + SelectionHolder { private var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal override val selectedItems get() = mode.selectedItems @@ -41,6 +42,7 @@ class HistoryAdapter(private val historyInteractor: HistoryInteractor) : override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryListItemViewHolder { val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + return HistoryListItemViewHolder(view, historyInteractor, this) } @@ -100,7 +102,7 @@ class HistoryAdapter(private val historyInteractor: HistoryInteractor) : return calendar.time } - private fun timeGroupForHistoryItem(item: HistoryItem): HistoryItemTimeGroup { + private fun timeGroupForHistoryItem(item: History): HistoryItemTimeGroup { return when { DateUtils.isToday(item.visitedAt) -> HistoryItemTimeGroup.Today yesterdayRange.contains(item.visitedAt) -> HistoryItemTimeGroup.Yesterday @@ -110,16 +112,16 @@ class HistoryAdapter(private val historyInteractor: HistoryInteractor) : } } - private val historyDiffCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean { + private val historyDiffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: History, newItem: History): Boolean { return oldItem == newItem } - override fun areContentsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean { + override fun areContentsTheSame(oldItem: History, newItem: History): Boolean { return oldItem == newItem } - override fun getChangePayload(oldItem: HistoryItem, newItem: HistoryItem): Any? { + override fun getChangePayload(oldItem: History, newItem: History): Any? { return newItem } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt index 1f72c14eb9..5586a75042 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt @@ -4,32 +4,23 @@ package org.mozilla.fenix.library.history -import android.content.ClipData -import android.content.ClipboardManager -import android.content.res.Resources import androidx.navigation.NavController import androidx.navigation.NavOptions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import mozilla.components.concept.engine.prompt.ShareData import org.mozilla.fenix.R -import org.mozilla.fenix.browser.browsingmode.BrowsingMode -import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController @Suppress("TooManyFunctions") interface HistoryController { - fun handleOpen(item: HistoryItem) - fun handleOpenInNewTab(item: HistoryItem, mode: BrowsingMode) - fun handleSelect(item: HistoryItem) - fun handleDeselect(item: HistoryItem) + fun handleOpen(item: History) + fun handleSelect(item: History) + fun handleDeselect(item: History) fun handleBackPressed(): Boolean fun handleModeSwitched() fun handleDeleteAll() - fun handleDeleteSome(items: Set) - fun handleCopyUrl(item: HistoryItem) - fun handleShare(item: HistoryItem) + fun handleDeleteSome(items: Set) fun handleRequestSync() fun handleEnterRecentlyClosed() } @@ -38,34 +29,40 @@ interface HistoryController { class DefaultHistoryController( private val store: HistoryFragmentStore, private val navController: NavController, - private val resources: Resources, - private val snackbar: FenixSnackbar, - private val clipboardManager: ClipboardManager, private val scope: CoroutineScope, - private val openToBrowser: (item: HistoryItem) -> Unit, - private val openInNewTab: (item: HistoryItem, mode: BrowsingMode) -> Unit, + private val openToBrowser: (item: History.Regular) -> Unit, private val displayDeleteAll: () -> Unit, private val invalidateOptionsMenu: () -> Unit, - private val deleteHistoryItems: (Set) -> Unit, + private val deleteHistoryItems: (Set) -> Unit, private val syncHistory: suspend () -> Unit, private val metrics: MetricController ) : HistoryController { - override fun handleOpen(item: HistoryItem) { - openToBrowser(item) - } - override fun handleOpenInNewTab(item: HistoryItem, mode: BrowsingMode) { - openInNewTab(item, mode) + override fun handleOpen(item: History) { + when (item) { + is History.Regular -> openToBrowser(item) + is History.Group -> { + navController.navigate( + HistoryFragmentDirections.actionGlobalHistoryMetadataGroup( + title = item.title, + historyMetadataItems = item.items.toTypedArray() + ), + NavOptions.Builder().setPopUpTo(R.id.historyMetadataGroupFragment, true).build() + ) + } + else -> { /* noop */ } + } } - override fun handleSelect(item: HistoryItem) { + override fun handleSelect(item: History) { if (store.state.mode === HistoryFragmentState.Mode.Syncing) { return } + store.dispatch(HistoryFragmentAction.AddItemForRemoval(item)) } - override fun handleDeselect(item: HistoryItem) { + override fun handleDeselect(item: History) { store.dispatch(HistoryFragmentAction.RemoveItemForRemoval(item)) } @@ -86,27 +83,10 @@ class DefaultHistoryController( displayDeleteAll.invoke() } - override fun handleDeleteSome(items: Set) { + override fun handleDeleteSome(items: Set) { deleteHistoryItems.invoke(items) } - override fun handleCopyUrl(item: HistoryItem) { - val urlClipData = ClipData.newPlainText(item.url, item.url) - clipboardManager.setPrimaryClip(urlClipData) - with(snackbar) { - setText(resources.getString(R.string.url_copied)) - show() - } - } - - override fun handleShare(item: HistoryItem) { - navController.navigate( - HistoryFragmentDirections.actionGlobalShareFragment( - data = arrayOf(ShareData(url = item.url, title = item.title)) - ) - ) - } - override fun handleRequestSync() { scope.launch { store.dispatch(HistoryFragmentAction.StartSync) diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt index 2c5d52176e..a9b7c60089 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt @@ -5,49 +5,35 @@ package org.mozilla.fenix.library.history import androidx.paging.ItemKeyedDataSource -import mozilla.components.concept.storage.VisitInfo -import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl import org.mozilla.fenix.components.history.PagedHistoryProvider class HistoryDataSource( private val historyProvider: PagedHistoryProvider -) : ItemKeyedDataSource() { +) : ItemKeyedDataSource() { - // Because the pagination is not based off of they key + // Because the pagination is not based off of the key // we want to start at 1, not 0 to be able to send the correct offset // to the `historyProvider.getHistory` call. - override fun getKey(item: HistoryItem): Int = item.id + 1 + override fun getKey(item: History): Int = item.id + 1 override fun loadInitial( params: LoadInitialParams, - callback: LoadInitialCallback + callback: LoadInitialCallback ) { historyProvider.getHistory(INITIAL_OFFSET, params.requestedLoadSize.toLong()) { history -> - val items = history.mapIndexed(transformVisitInfoToHistoryItem(INITIAL_OFFSET.toInt())) - callback.onResult(items) + callback.onResult(history) } } - override fun loadAfter(params: LoadParams, callback: LoadCallback) { + override fun loadAfter(params: LoadParams, callback: LoadCallback) { historyProvider.getHistory(params.key.toLong(), params.requestedLoadSize.toLong()) { history -> - val items = history.mapIndexed(transformVisitInfoToHistoryItem(params.key)) - callback.onResult(items) + callback.onResult(history) } } - override fun loadBefore(params: LoadParams, callback: LoadCallback) { /* noop */ } + override fun loadBefore(params: LoadParams, callback: LoadCallback) { /* noop */ } companion object { private const val INITIAL_OFFSET = 0L - - fun transformVisitInfoToHistoryItem(offset: Int): (id: Int, visit: VisitInfo) -> HistoryItem { - return { id, visit -> - val title = visit.title - ?.takeIf(String::isNotEmpty) - ?: visit.url.tryGetHostFromUrl() - - HistoryItem(offset + id, title, visit.url, visit.visitTime) - } - } } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSourceFactory.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSourceFactory.kt index 78eb2fc5e8..64aeba2299 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSourceFactory.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSourceFactory.kt @@ -10,10 +10,10 @@ import org.mozilla.fenix.components.history.PagedHistoryProvider class HistoryDataSourceFactory( private val historyProvider: PagedHistoryProvider -) : DataSource.Factory() { +) : DataSource.Factory() { val datasource = MutableLiveData() - override fun create(): DataSource { + override fun create(): DataSource { val datasource = HistoryDataSource(historyProvider) this.datasource.postValue(datasource) return datasource diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt index 4181a14c85..4bc1754354 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -4,8 +4,6 @@ package org.mozilla.fenix.library.history -import android.content.ClipboardManager -import android.content.Context.CLIPBOARD_SERVICE import android.content.DialogInterface import android.os.Bundle import android.text.SpannableString @@ -20,7 +18,6 @@ import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController -import kotlinx.android.synthetic.main.fragment_history.view.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main @@ -39,10 +36,10 @@ import org.mozilla.fenix.NavHostActivity import org.mozilla.fenix.R import org.mozilla.fenix.addons.showSnackBar import org.mozilla.fenix.browser.browsingmode.BrowsingMode -import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.StoreProvider -import org.mozilla.fenix.components.history.createSynchronousPagedHistoryProvider +import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.FragmentHistoryBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents @@ -52,23 +49,28 @@ import org.mozilla.fenix.library.LibraryPageFragment import org.mozilla.fenix.utils.allowUndo @SuppressWarnings("TooManyFunctions", "LargeClass") -class HistoryFragment : LibraryPageFragment(), UserInteractionHandler { +class HistoryFragment : LibraryPageFragment(), UserInteractionHandler { private lateinit var historyStore: HistoryFragmentStore private lateinit var historyInteractor: HistoryInteractor private lateinit var viewModel: HistoryViewModel + private lateinit var historyProvider: DefaultPagedHistoryProvider + private var undoScope: CoroutineScope? = null private var pendingHistoryDeletionJob: (suspend () -> Unit)? = null private var _historyView: HistoryView? = null protected val historyView: HistoryView get() = _historyView!! + private var _binding: FragmentHistoryBinding? = null + private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_history, container, false) + savedInstanceState: Bundle?, + ): View { + _binding = FragmentHistoryBinding.inflate(inflater, container, false) + val view = binding.root historyStore = StoreProvider.get(this) { HistoryFragmentStore( HistoryFragmentState( @@ -82,27 +84,19 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl val historyController: HistoryController = DefaultHistoryController( store = historyStore, navController = findNavController(), - resources = resources, - snackbar = FenixSnackbar.make( - view = view, - duration = FenixSnackbar.LENGTH_LONG, - isDisplayedWithBrowserToolbar = false - ), - clipboardManager = activity?.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager, scope = lifecycleScope, openToBrowser = ::openItem, - openInNewTab = ::openItemAndShowTray, displayDeleteAll = ::displayDeleteAllDialog, invalidateOptionsMenu = ::invalidateOptionsMenu, deleteHistoryItems = ::deleteHistoryItems, syncHistory = ::syncHistory, metrics = requireComponents.analytics.metrics ) - historyInteractor = HistoryInteractor( + historyInteractor = DefaultHistoryInteractor( historyController ) _historyView = HistoryView( - view.historyLayout, + binding.historyLayout, historyInteractor ) @@ -118,9 +112,9 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel = HistoryViewModel( - requireComponents.core.historyStorage.createSynchronousPagedHistoryProvider() - ) + historyProvider = DefaultPagedHistoryProvider(requireComponents.core.historyStorage) + + viewModel = HistoryViewModel(historyProvider) viewModel.userHasHistory.observe( this, @@ -134,8 +128,7 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl setHasOptionsMenu(true) } - private fun deleteHistoryItems(items: Set) { - + private fun deleteHistoryItems(items: Set) { updatePendingHistoryToDelete(items) undoScope = CoroutineScope(IO) undoScope?.allowUndo( @@ -179,14 +172,36 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl SpannableString(getString(R.string.bookmark_menu_delete_button)).apply { setTextColor(requireContext(), R.attr.destructive) } + } else { + inflater.inflate(R.menu.history_menu, menu) } } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { R.id.share_history_multi_select -> { val selectedHistory = historyStore.state.mode.selectedItems - val shareTabs = selectedHistory.map { ShareData(url = it.url, title = it.title) } + val shareTabs = mutableListOf() + + for (history in selectedHistory) { + when (history) { + is History.Regular -> { + shareTabs.add(ShareData(url = history.url, title = history.title)) + } + is History.Group -> { + shareTabs.addAll( + history.items.map { metadata -> + ShareData(url = metadata.url, title = metadata.title) + } + ) + } + else -> { + // no-op, There is no [History.Metadata] in the HistoryFragment. + } + } + } + share(shareTabs) + true } R.id.delete_history_multi_select -> { @@ -196,6 +211,7 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl } R.id.open_history_in_new_tabs_multi_select -> { openItemsInNewTab { selectedItem -> + selectedItem as History.Regular requireComponents.analytics.metrics.track(Event.HistoryOpenedInNewTabs) selectedItem.url } @@ -205,6 +221,7 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl } R.id.open_history_in_private_tabs_multi_select -> { openItemsInNewTab(private = true) { selectedItem -> + selectedItem as History.Regular requireComponents.analytics.metrics.track(Event.HistoryOpenedInPrivateTabs) selectedItem.url } @@ -217,6 +234,10 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl showTabTray() true } + R.id.history_delete_all -> { + historyInteractor.onDeleteAll() + true + } else -> super.onOptionsItemSelected(item) } @@ -228,15 +249,19 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl ) } - private fun getMultiSelectSnackBarMessage(historyItems: Set): String { + private fun getMultiSelectSnackBarMessage(historyItems: Set): String { return if (historyItems.size > 1) { getString(R.string.history_delete_multiple_items_snackbar) } else { + val historyItem = historyItems.first() + String.format( - requireContext().getString( - R.string.history_delete_single_item_snackbar - ), - historyItems.first().url.toShortUrl(requireComponents.publicSuffixList) + requireContext().getString(R.string.history_delete_single_item_snackbar), + if (historyItem is History.Regular) { + historyItem.url.toShortUrl(requireComponents.publicSuffixList) + } else { + historyItem.title + } ) } } @@ -254,9 +279,10 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl override fun onDestroyView() { super.onDestroyView() _historyView = null + _binding = null } - private fun openItem(item: HistoryItem) { + private fun openItem(item: History.Regular) { requireComponents.analytics.metrics.track(Event.HistoryItemOpened) (activity as HomeActivity).openToBrowserAndLoad( @@ -266,21 +292,6 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl ) } - private fun openItemAndShowTray(item: HistoryItem, mode: BrowsingMode) { - when (mode.isPrivate) { - true -> requireComponents.analytics.metrics.track(Event.HistoryOpenedInPrivateTab) - false -> requireComponents.analytics.metrics.track(Event.HistoryOpenedInNewTab) - } - - val homeActivity = activity as HomeActivity - homeActivity.browsingModeManager.mode = mode - homeActivity.components.useCases.tabsUseCases.addTab.invoke( - item.url, private = (mode == BrowsingMode.Private) - ) - - showTabTray() - } - private fun displayDeleteAllDialog() { activity?.let { activity -> AlertDialog.Builder(activity).apply { @@ -333,14 +344,35 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl ) } - private fun getDeleteHistoryItemsOperation(items: Set): (suspend () -> Unit) { + private fun getDeleteHistoryItemsOperation(items: Set): (suspend () -> Unit) { return { CoroutineScope(IO).launch { historyStore.dispatch(HistoryFragmentAction.EnterDeletionMode) context?.components?.run { for (item in items) { analytics.metrics.track(Event.HistoryItemRemoved) - core.historyStorage.deleteVisit(item.url, item.visitedAt) + + if (item is History.Regular) { + core.historyStorage.deleteVisit( + url = item.url, + timestamp = item.visitedAt + ) + } else if (item is History.Group) { + for (historyMetadata in item.items) { + historyProvider.getMatchingHistory(historyMetadata)?.let { + core.historyStorage.deleteVisit( + url = it.url, + timestamp = it.visitTime + ) + } + } + + core.historyStorage.deleteHistoryMetadata( + searchTerm = item.title + ) + + historyProvider.clearHistoryGroups() + } } } historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode) @@ -349,13 +381,13 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl } } - private fun updatePendingHistoryToDelete(items: Set) { + private fun updatePendingHistoryToDelete(items: Set) { pendingHistoryDeletionJob = getDeleteHistoryItemsOperation(items) val ids = items.map { item -> item.visitedAt }.toSet() historyStore.dispatch(HistoryFragmentAction.AddPendingDeletionSet(ids)) } - private fun undoPendingDeletion(items: Set) { + private fun undoPendingDeletion(items: Set) { pendingHistoryDeletionJob = null val ids = items.map { item -> item.visitedAt }.toSet() historyStore.dispatch(HistoryFragmentAction.UndoPendingDeletionSet(ids)) diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt index 08d6f36820..587e292f80 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt @@ -4,18 +4,95 @@ package org.mozilla.fenix.library.history +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import mozilla.components.concept.storage.HistoryMetadata +import mozilla.components.concept.storage.HistoryMetadataKey import mozilla.components.lib.state.Action import mozilla.components.lib.state.State import mozilla.components.lib.state.Store +import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl /** - * Class representing a history entry - * @property id Unique id of the history item - * @property title Title of the history item - * @property url URL of the history item - * @property visitedAt Timestamp of when this history item was visited + * Class representing a history entry. */ -data class HistoryItem(val id: Int, val title: String, val url: String, val visitedAt: Long) +sealed class History : Parcelable { + abstract val id: Int + abstract val title: String + abstract val visitedAt: Long + abstract val selected: Boolean + + /** + * A regular history item. + * + * @property id Unique id of the history item. + * @property title Title of the history item. + * @property url URL of the history item. + * @property visitedAt Timestamp of when this history item was visited. + * @property selected Whether or not the history item is selected. + */ + @Parcelize data class Regular( + override val id: Int, + override val title: String, + val url: String, + override val visitedAt: Long, + override val selected: Boolean = false + ) : History() + + /** + * A history metadata item. + * + * @property id Unique id of the history metadata item. + * @property title Title of the history metadata item. + * @property url URL of the history metadata item. + * @property visitedAt Timestamp of when this history metadata item was visited. + * @property totalViewTime Total time the user viewed the page associated with this record. + * @property historyMetadataKey The [HistoryMetadataKey] of the new tab in case this tab + * was opened from history. + * @property selected Whether or not the history metadata item is selected. + */ + @Parcelize data class Metadata( + override val id: Int, + override val title: String, + val url: String, + override val visitedAt: Long, + val totalViewTime: Int, + val historyMetadataKey: HistoryMetadataKey, + override val selected: Boolean = false + ) : History() + + /** + * A history metadata group. + * + * @property id Unique id of the history metadata group. + * @property title Title of the history metadata group. + * @property visitedAt Timestamp of when this history metadata group was visited. + * @property items List of history metadata items associated with the group. + * @property selected Whether or not the history group is selected. + */ + @Parcelize data class Group( + override val id: Int, + override val title: String, + override val visitedAt: Long, + val items: List, + override val selected: Boolean = false + ) : History() +} + +/** + * Extension function for converting a [HistoryMetadata] into a [History.Metadata]. + */ +fun HistoryMetadata.toHistoryMetadata(): History.Metadata { + return History.Metadata( + id = createdAt.toInt(), + title = title?.takeIf(String::isNotEmpty) + ?: key.url.tryGetHostFromUrl(), + url = key.url, + visitedAt = createdAt, + totalViewTime = totalViewTime, + historyMetadataKey = key + ) +} /** * The [Store] for holding the [HistoryFragmentState] and applying [HistoryFragmentAction]s. @@ -28,8 +105,8 @@ class HistoryFragmentStore(initialState: HistoryFragmentState) : */ sealed class HistoryFragmentAction : Action { object ExitEditMode : HistoryFragmentAction() - data class AddItemForRemoval(val item: HistoryItem) : HistoryFragmentAction() - data class RemoveItemForRemoval(val item: HistoryItem) : HistoryFragmentAction() + data class AddItemForRemoval(val item: History) : HistoryFragmentAction() + data class RemoveItemForRemoval(val item: History) : HistoryFragmentAction() data class AddPendingDeletionSet(val itemIds: Set) : HistoryFragmentAction() data class UndoPendingDeletionSet(val itemIds: Set) : HistoryFragmentAction() object EnterDeletionMode : HistoryFragmentAction() @@ -40,21 +117,21 @@ sealed class HistoryFragmentAction : Action { /** * The state for the History Screen - * @property items List of HistoryItem to display + * @property items List of History to display * @property mode Current Mode of History */ data class HistoryFragmentState( - val items: List, + val items: List, val mode: Mode, val pendingDeletionIds: Set, val isDeletingItems: Boolean ) : State { sealed class Mode { - open val selectedItems = emptySet() + open val selectedItems = emptySet() object Normal : Mode() object Syncing : Mode() - data class Editing(override val selectedItems: Set) : Mode() + data class Editing(override val selectedItems: Set) : Mode() } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt index e7792e827f..21066f49af 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt @@ -4,25 +4,63 @@ package org.mozilla.fenix.library.history -import org.mozilla.fenix.browser.browsingmode.BrowsingMode +import org.mozilla.fenix.selection.SelectionInteractor + +/** + * Interface for the HistoryInteractor. This interface is implemented by objects that want + * to respond to user interaction on the HistoryView + */ +interface HistoryInteractor : SelectionInteractor { + + /** + * Called on backpressed to exit edit mode + */ + fun onBackPressed(): Boolean + + /** + * Called when the mode is switched so we can invalidate the menu + */ + fun onModeSwitched() + + /** + * Called when delete all is tapped + */ + fun onDeleteAll() + + /** + * Called when multiple history items are deleted + * @param items the history items to delete + */ + fun onDeleteSome(items: Set) + + /** + * Called when the user requests a sync of the history + */ + fun onRequestSync() + + /** + * Called when the user clicks on recently closed tab button. + */ + fun onRecentlyClosedClicked() +} /** * Interactor for the history screen - * Provides implementations for the HistoryViewInteractor + * Provides implementations for the HistoryInteractor */ @SuppressWarnings("TooManyFunctions") -class HistoryInteractor( +class DefaultHistoryInteractor( private val historyController: HistoryController -) : HistoryViewInteractor { - override fun open(item: HistoryItem) { +) : HistoryInteractor { + override fun open(item: History) { historyController.handleOpen(item) } - override fun select(item: HistoryItem) { + override fun select(item: History) { historyController.handleSelect(item) } - override fun deselect(item: HistoryItem) { + override fun deselect(item: History) { historyController.handleDeselect(item) } @@ -34,27 +72,11 @@ class HistoryInteractor( historyController.handleModeSwitched() } - override fun onCopyPressed(item: HistoryItem) { - historyController.handleCopyUrl(item) - } - - override fun onSharePressed(item: HistoryItem) { - historyController.handleShare(item) - } - - override fun onOpenInNormalTab(item: HistoryItem) { - historyController.handleOpenInNewTab(item, BrowsingMode.Normal) - } - - override fun onOpenInPrivateTab(item: HistoryItem) { - historyController.handleOpenInNewTab(item, BrowsingMode.Private) - } - override fun onDeleteAll() { historyController.handleDeleteAll() } - override fun onDeleteSome(items: Set) { + override fun onDeleteSome(items: Set) { historyController.handleDeleteSome(items) } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryItemMenu.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryItemMenu.kt deleted file mode 100644 index d4e99d5f99..0000000000 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryItemMenu.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.library.history - -import android.content.Context -import androidx.annotation.VisibleForTesting -import mozilla.components.browser.menu2.BrowserMenuController -import mozilla.components.concept.menu.MenuController -import mozilla.components.concept.menu.candidate.TextMenuCandidate -import mozilla.components.concept.menu.candidate.TextStyle -import mozilla.components.support.ktx.android.content.getColorFromAttr -import org.mozilla.fenix.R - -class HistoryItemMenu( - private val context: Context, - private val onItemTapped: (Item) -> Unit -) { - - enum class Item { - Copy, - Share, - OpenInNewTab, - OpenInPrivateTab, - Delete; - } - - val menuController: MenuController by lazy { - BrowserMenuController().apply { - submitList(menuItems()) - } - } - - @VisibleForTesting - internal fun menuItems(): List { - return listOf( - TextMenuCandidate( - text = context.getString(R.string.history_menu_copy_button) - ) { - onItemTapped.invoke(Item.Copy) - }, - TextMenuCandidate( - text = context.getString(R.string.history_menu_share_button) - ) { - onItemTapped.invoke(Item.Share) - }, - TextMenuCandidate( - text = context.getString(R.string.history_menu_open_in_new_tab_button) - ) { - onItemTapped.invoke(Item.OpenInNewTab) - }, - TextMenuCandidate( - text = context.getString(R.string.history_menu_open_in_private_tab_button) - ) { - onItemTapped.invoke(Item.OpenInPrivateTab) - }, - TextMenuCandidate( - text = context.getString(R.string.history_delete_item), - textStyle = TextStyle( - color = context.getColorFromAttr(R.attr.destructive) - ) - ) { - onItemTapped.invoke(Item.Delete) - } - ) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt index b13f4a8133..24f818367c 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt @@ -5,87 +5,17 @@ package org.mozilla.fenix.library.history import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator -import kotlinx.android.synthetic.main.component_history.* -import kotlinx.android.synthetic.main.component_history.view.* -import kotlinx.android.synthetic.main.recently_closed_nav_item.* import mozilla.components.support.base.feature.UserInteractionHandler import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ComponentHistoryBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.library.LibraryPageView -import org.mozilla.fenix.selection.SelectionInteractor import org.mozilla.fenix.theme.ThemeManager -/** - * Interface for the HistoryViewInteractor. This interface is implemented by objects that want - * to respond to user interaction on the HistoryView - */ -interface HistoryViewInteractor : SelectionInteractor { - - /** - * Called on backpressed to exit edit mode - */ - fun onBackPressed(): Boolean - - /** - * Called when the mode is switched so we can invalidate the menu - */ - fun onModeSwitched() - - /** - * Copies the URL of a history item to the copy-paste buffer. - * - * @param item the history item to copy the URL from - */ - fun onCopyPressed(item: HistoryItem) - - /** - * Opens the share sheet for a history item. - * - * @param item the history item to share - */ - fun onSharePressed(item: HistoryItem) - - /** - * Opens a history item in a new tab. - * - * @param item the history item to open in a new tab - */ - fun onOpenInNormalTab(item: HistoryItem) - - /** - * Opens a history item in a private tab. - * - * @param item the history item to open in a private tab - */ - fun onOpenInPrivateTab(item: HistoryItem) - - /** - * Called when delete all is tapped - */ - fun onDeleteAll() - - /** - * Called when multiple history items are deleted - * @param items the history items to delete - */ - fun onDeleteSome(items: Set) - - /** - * Called when the user requests a sync of the history - */ - fun onRequestSync() - - /** - * Called when the user clicks on recently closed tab button. - */ - fun onRecentlyClosedClicked() -} - /** * View that contains and configures the History List */ @@ -94,8 +24,9 @@ class HistoryView( val interactor: HistoryInteractor ) : LibraryPageView(container), UserInteractionHandler { - val view: View = LayoutInflater.from(container.context) - .inflate(R.layout.component_history, container, true) + val binding = ComponentHistoryBinding.inflate( + LayoutInflater.from(container.context), container, true + ) var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal private set @@ -104,7 +35,7 @@ class HistoryView( private val layoutManager = LinearLayoutManager(container.context) init { - view.history_list.apply { + binding.historyList.apply { layoutManager = this@HistoryView.layoutManager adapter = historyAdapter (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false @@ -112,19 +43,19 @@ class HistoryView( val primaryTextColor = ThemeManager.resolveAttribute(R.attr.primaryText, context) - view.swipe_refresh.setColorSchemeColors(primaryTextColor) - view.swipe_refresh.setOnRefreshListener { + binding.swipeRefresh.setColorSchemeColors(primaryTextColor) + binding.swipeRefresh.setOnRefreshListener { interactor.onRequestSync() - view.history_list.scrollToPosition(0) + binding.historyList.scrollToPosition(0) } } fun update(state: HistoryFragmentState) { val oldMode = mode - view.progress_bar.isVisible = state.isDeletingItems - view.swipe_refresh.isRefreshing = state.mode === HistoryFragmentState.Mode.Syncing - view.swipe_refresh.isEnabled = + binding.progressBar.isVisible = state.isDeletingItems + binding.swipeRefresh.isRefreshing = state.mode === HistoryFragmentState.Mode.Syncing + binding.swipeRefresh.isEnabled = state.mode === HistoryFragmentState.Mode.Normal || state.mode === HistoryFragmentState.Mode.Syncing mode = state.mode @@ -164,24 +95,24 @@ class HistoryView( } fun updateEmptyState(userHasHistory: Boolean) { - history_list.isVisible = userHasHistory - history_empty_view.isVisible = !userHasHistory - recently_closed_nav_empty.apply { - setOnClickListener { + binding.historyList.isVisible = userHasHistory + binding.historyEmptyView.isVisible = !userHasHistory + with(binding.recentlyClosedNavEmpty) { + recentlyClosedNav.setOnClickListener { interactor.onRecentlyClosedClicked() } - val numRecentTabs = view.context.components.core.store.state.closedTabs.size - recently_closed_tabs_description.text = String.format( - view.context.getString( + val numRecentTabs = recentlyClosedNav.context.components.core.store.state.closedTabs.size + recentlyClosedTabsDescription.text = String.format( + context.getString( if (numRecentTabs == 1) R.string.recently_closed_tab else R.string.recently_closed_tabs ), numRecentTabs ) - isVisible = !userHasHistory + recentlyClosedNav.isVisible = !userHasHistory } if (!userHasHistory) { - history_empty_view.announceForAccessibility(context.getString(R.string.history_empty_message)) + binding.historyEmptyView.announceForAccessibility(context.getString(R.string.history_empty_message)) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewModel.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewModel.kt index 8c758d7808..f4aa380e49 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewModel.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewModel.kt @@ -12,7 +12,7 @@ import androidx.paging.PagedList import org.mozilla.fenix.components.history.PagedHistoryProvider class HistoryViewModel(historyProvider: PagedHistoryProvider) : ViewModel() { - var history: LiveData> + var history: LiveData> var userHasHistory = MutableLiveData(true) private val datasource: LiveData @@ -21,7 +21,7 @@ class HistoryViewModel(historyProvider: PagedHistoryProvider) : ViewModel() { datasource = historyDataSourceFactory.datasource history = LivePagedListBuilder(historyDataSourceFactory, PAGE_SIZE) - .setBoundaryCallback(object : PagedList.BoundaryCallback() { + .setBoundaryCallback(object : PagedList.BoundaryCallback() { override fun onZeroItemsLoaded() { userHasHistory.value = false } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt index e7810ac1bb..8e4a177282 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt @@ -5,81 +5,90 @@ package org.mozilla.fenix.library.history.viewholders import android.view.View -import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.history_list_item.view.* -import kotlinx.android.synthetic.main.library_site_item.view.* -import kotlinx.android.synthetic.main.recently_closed_nav_item.view.* import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.HistoryListItemBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.hideAndDisable import org.mozilla.fenix.ext.showAndEnable -import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.library.history.History import org.mozilla.fenix.library.history.HistoryFragmentState import org.mozilla.fenix.library.history.HistoryInteractor -import org.mozilla.fenix.library.history.HistoryItem -import org.mozilla.fenix.library.history.HistoryItemMenu import org.mozilla.fenix.library.history.HistoryItemTimeGroup +import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.utils.Do class HistoryListItemViewHolder( view: View, private val historyInteractor: HistoryInteractor, - private val selectionHolder: SelectionHolder + private val selectionHolder: SelectionHolder, ) : RecyclerView.ViewHolder(view) { - private var item: HistoryItem? = null + private var item: History? = null + private val binding = HistoryListItemBinding.bind(view) init { - setupMenu() - - itemView.delete_button.setOnClickListener { - val selected = selectionHolder.selectedItems - if (selected.isEmpty()) { - historyInteractor.onDeleteAll() - } else { - historyInteractor.onDeleteSome(selected) - } + binding.recentlyClosedNavEmpty.recentlyClosedNav.setOnClickListener { + historyInteractor.onRecentlyClosedClicked() } - itemView.findViewById(R.id.recently_closed_nav).setOnClickListener { - historyInteractor.onRecentlyClosedClicked() + binding.historyLayout.overflowView.setImageResource(R.drawable.ic_close) + binding.historyLayout.overflowView.setOnClickListener { + val item = this.item ?: return@setOnClickListener + historyInteractor.onDeleteSome(setOf(item)) } } fun bind( - item: HistoryItem, + item: History, timeGroup: HistoryItemTimeGroup?, - showDeleteButton: Boolean, + showTopContent: Boolean, mode: HistoryFragmentState.Mode, - isPendingDeletion: Boolean = false + isPendingDeletion: Boolean = false, ) { if (isPendingDeletion) { - itemView.history_layout.visibility = View.GONE + binding.historyLayout.visibility = View.GONE } else { - itemView.history_layout.visibility = View.VISIBLE + binding.historyLayout.visibility = View.VISIBLE } - itemView.history_layout.titleView.text = item.title - itemView.history_layout.urlView.text = item.url + binding.historyLayout.titleView.text = item.title - toggleTopContent(showDeleteButton, mode === HistoryFragmentState.Mode.Normal) + binding.historyLayout.urlView.text = Do exhaustive when (item) { + is History.Regular -> item.url + is History.Metadata -> item.url + is History.Group -> { + val numChildren = item.items.size + val stringId = if (numChildren == 1) { + R.string.history_search_group_site + } else { + R.string.history_search_group_sites + } + String.format(itemView.context.getString(stringId), numChildren) + } + } + + toggleTopContent(showTopContent, mode === HistoryFragmentState.Mode.Normal) val headerText = timeGroup?.humanReadable(itemView.context) toggleHeader(headerText) - itemView.history_layout.setSelectionInteractor(item, selectionHolder, historyInteractor) - itemView.history_layout.changeSelected(item in selectionHolder.selectedItems) + binding.historyLayout.setSelectionInteractor(item, selectionHolder, historyInteractor) + binding.historyLayout.changeSelected(item in selectionHolder.selectedItems) - if (this.item?.url != item.url) { - itemView.history_layout.loadFavicon(item.url) + if (item is History.Regular && + (this.item as? History.Regular)?.url != item.url + ) { + binding.historyLayout.loadFavicon(item.url) + } else if (item is History.Group) { + binding.historyLayout.iconView.setImageResource(R.drawable.ic_multiple_tabs) } if (mode is HistoryFragmentState.Mode.Editing) { - itemView.overflow_menu.hideAndDisable() + binding.historyLayout.overflowView.hideAndDisable() } else { - itemView.overflow_menu.showAndEnable() + binding.historyLayout.overflowView.showAndEnable() } this.item = item @@ -87,67 +96,42 @@ class HistoryListItemViewHolder( private fun toggleHeader(headerText: String?) { if (headerText != null) { - itemView.header_title.visibility = View.VISIBLE - itemView.header_title.text = headerText + binding.headerTitle.visibility = View.VISIBLE + binding.headerTitle.text = headerText } else { - itemView.header_title.visibility = View.GONE + binding.headerTitle.visibility = View.GONE } } private fun toggleTopContent( showTopContent: Boolean, - isNormalMode: Boolean + isNormalMode: Boolean, ) { - itemView.delete_button.isVisible = showTopContent - itemView.findViewById(R.id.recently_closed_nav).isVisible = showTopContent + binding.recentlyClosedNavEmpty.recentlyClosedNav.isVisible = showTopContent if (showTopContent) { - itemView.delete_button.run { - if (isNormalMode) { - isEnabled = true - alpha = 1f - } else { - isEnabled = false - alpha = DELETE_BUTTON_DISABLED_ALPHA - } - } val numRecentTabs = itemView.context.components.core.store.state.closedTabs.size - itemView.recently_closed_tabs_description.text = String.format( + binding.recentlyClosedNavEmpty.recentlyClosedTabsDescription.text = String.format( itemView.context.getString( if (numRecentTabs == 1) R.string.recently_closed_tab else R.string.recently_closed_tabs ), numRecentTabs ) - itemView.findViewById(R.id.recently_closed_nav).run { + binding.recentlyClosedNavEmpty.recentlyClosedNav.run { if (isNormalMode) { isEnabled = true alpha = 1f } else { isEnabled = false - alpha = DELETE_BUTTON_DISABLED_ALPHA + alpha = DISABLED_BUTTON_ALPHA } } } } - private fun setupMenu() { - val historyMenu = HistoryItemMenu(itemView.context) { - val item = this.item ?: return@HistoryItemMenu - Do exhaustive when (it) { - HistoryItemMenu.Item.Copy -> historyInteractor.onCopyPressed(item) - HistoryItemMenu.Item.Share -> historyInteractor.onSharePressed(item) - HistoryItemMenu.Item.OpenInNewTab -> historyInteractor.onOpenInNormalTab(item) - HistoryItemMenu.Item.OpenInPrivateTab -> historyInteractor.onOpenInPrivateTab(item) - HistoryItemMenu.Item.Delete -> historyInteractor.onDeleteSome(setOf(item)) - } - } - - itemView.history_layout.attachMenu(historyMenu.menuController) - } - companion object { - const val DELETE_BUTTON_DISABLED_ALPHA = 0.7f + const val DISABLED_BUTTON_ALPHA = 0.7f const val LAYOUT_ID = R.layout.history_list_item } } diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt new file mode 100644 index 0000000000..fdf68108f4 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata + +import android.os.Bundle +import android.text.SpannableString +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.support.base.feature.UserInteractionHandler +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.R +import org.mozilla.fenix.browser.browsingmode.BrowsingMode +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.databinding.FragmentHistoryMetadataGroupBinding +import org.mozilla.fenix.ext.nav +import org.mozilla.fenix.ext.setTextColor +import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.library.LibraryPageFragment +import org.mozilla.fenix.library.history.History +import org.mozilla.fenix.library.historymetadata.controller.DefaultHistoryMetadataGroupController +import org.mozilla.fenix.library.historymetadata.interactor.DefaultHistoryMetadataGroupInteractor +import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor +import org.mozilla.fenix.library.historymetadata.view.HistoryMetadataGroupView + +/** + * Displays a list of history metadata items for a history metadata search group. + */ +class HistoryMetadataGroupFragment : + LibraryPageFragment(), UserInteractionHandler { + + private lateinit var historyMetadataGroupStore: HistoryMetadataGroupFragmentStore + private lateinit var interactor: HistoryMetadataGroupInteractor + + private var _historyMetadataGroupView: HistoryMetadataGroupView? = null + private val historyMetadataGroupView: HistoryMetadataGroupView + get() = _historyMetadataGroupView!! + private var _binding: FragmentHistoryMetadataGroupBinding? = null + private val binding get() = _binding!! + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentHistoryMetadataGroupBinding.inflate(inflater, container, false) + + historyMetadataGroupStore = StoreProvider.get(this) { + HistoryMetadataGroupFragmentStore( + HistoryMetadataGroupFragmentState( + items = args.historyMetadataItems.filterIsInstance() + ) + ) + } + + interactor = DefaultHistoryMetadataGroupInteractor( + controller = DefaultHistoryMetadataGroupController( + activity = activity as HomeActivity, + store = historyMetadataGroupStore, + navController = findNavController(), + scope = lifecycleScope, + searchTerm = args.title + ) + ) + + _historyMetadataGroupView = HistoryMetadataGroupView( + container = binding.historyMetadataGroupLayout, + interactor = interactor, + title = args.title + ) + + return binding.root + } + + @ExperimentalCoroutinesApi + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + consumeFrom(historyMetadataGroupStore) { state -> + historyMetadataGroupView.update(state) + activity?.invalidateOptionsMenu() + } + } + + override fun onResume() { + super.onResume() + showToolbar(args.title) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + if (selectedItems.isNotEmpty()) { + inflater.inflate(R.menu.history_select_multi, menu) + + menu.findItem(R.id.delete_history_multi_select)?.let { deleteItem -> + deleteItem.title = SpannableString(deleteItem.title).apply { + setTextColor(requireContext(), R.attr.destructive) + } + } + } else { + inflater.inflate(R.menu.history_menu, menu) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.share_history_multi_select -> { + interactor.onShareMenuItem(selectedItems) + true + } + R.id.delete_history_multi_select -> { + interactor.onDelete(selectedItems) + true + } + R.id.open_history_in_new_tabs_multi_select -> { + openItemsInNewTab { selectedItem -> + selectedItem.url + } + + showTabTray() + true + } + R.id.open_history_in_private_tabs_multi_select -> { + openItemsInNewTab(private = true) { selectedItem -> + selectedItem.url + } + + (activity as HomeActivity).apply { + browsingModeManager.mode = BrowsingMode.Private + supportActionBar?.hide() + } + + showTabTray() + true + } + R.id.history_delete_all -> { + interactor.onDeleteAllMenuItem() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _historyMetadataGroupView = null + _binding = null + } + + override val selectedItems: Set + get() = + historyMetadataGroupStore.state.items.filter { it.selected }.toSet() + + override fun onBackPressed(): Boolean = interactor.onBackPressed(selectedItems) + + private fun showTabTray() { + findNavController().nav( + R.id.historyMetadataGroupFragment, + HistoryMetadataGroupFragmentDirections.actionGlobalTabsTrayFragment() + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStore.kt new file mode 100644 index 0000000000..b267f1f707 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStore.kt @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import org.mozilla.fenix.library.history.History + +/** + * The [Store] for holding the [HistoryMetadataGroupFragmentState] and applying + * [HistoryMetadataGroupFragmentAction]s. + */ +class HistoryMetadataGroupFragmentStore(initialState: HistoryMetadataGroupFragmentState) : + Store( + initialState, + ::historyStateReducer + ) + +/** + * Actions to dispatch through the [HistoryMetadataGroupFragmentStore to modify the + * [HistoryMetadataGroupFragmentState] through the [historyStateReducer]. + */ +sealed class HistoryMetadataGroupFragmentAction : Action { + data class UpdateHistoryItems(val items: List) : + HistoryMetadataGroupFragmentAction() + data class Select(val item: History.Metadata) : HistoryMetadataGroupFragmentAction() + data class Deselect(val item: History.Metadata) : HistoryMetadataGroupFragmentAction() + object DeselectAll : HistoryMetadataGroupFragmentAction() + data class Delete(val item: History.Metadata) : HistoryMetadataGroupFragmentAction() + object DeleteAll : HistoryMetadataGroupFragmentAction() +} + +/** + * The state for [HistoryMetadataGroupFragment]. + * + * @property items The list of [History.Metadata] to display. + */ +data class HistoryMetadataGroupFragmentState( + val items: List = emptyList() +) : State + +/** + * Reduces the history metadata state from the current state with the provided [action] to be + * performed. + * + * @param state The current history metadata state. + * @param action The action to be performed on the state. + * @return the new [HistoryMetadataGroupFragmentState] with the [action] executed. + */ +private fun historyStateReducer( + state: HistoryMetadataGroupFragmentState, + action: HistoryMetadataGroupFragmentAction +): HistoryMetadataGroupFragmentState { + return when (action) { + is HistoryMetadataGroupFragmentAction.UpdateHistoryItems -> + state.copy(items = action.items) + is HistoryMetadataGroupFragmentAction.Select -> + state.copy( + items = state.items.toMutableList() + .map { + if (it == action.item) { + it.copy(selected = true) + } else { + it + } + } + ) + is HistoryMetadataGroupFragmentAction.Deselect -> + state.copy( + items = state.items.toMutableList() + .map { + if (it == action.item) { + it.copy(selected = false) + } else { + it + } + } + ) + is HistoryMetadataGroupFragmentAction.DeselectAll -> + state.copy( + items = state.items.toMutableList() + .map { it.copy(selected = false) } + ) + is HistoryMetadataGroupFragmentAction.Delete -> { + val items = state.items.toMutableList() + items.remove(action.item) + state.copy(items = items) + } + is HistoryMetadataGroupFragmentAction.DeleteAll -> + state.copy(items = emptyList()) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupController.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupController.kt new file mode 100644 index 0000000000..a42bd00ded --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupController.kt @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.controller + +import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import mozilla.components.concept.engine.prompt.ShareData +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.library.history.History +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore + +/** + * An interface that handles the view manipulation of the history metadata group in the History + * metadata group screen. + */ +interface HistoryMetadataGroupController { + + /** + * Opens the given history [item] in a new tab. + * + * @param item The [History] to open in a new tab. + */ + fun handleOpen(item: History.Metadata) + + /** + * Toggles the given history [item] to be selected in multi-select mode. + * + * @param item The [History] to select. + */ + fun handleSelect(item: History.Metadata) + + /** + * Toggles the given history [item] to be deselected in multi-select mode. + * + * @param item The [History] to deselect. + */ + fun handleDeselect(item: History.Metadata) + + /** + * Called on backpressed to deselect all the given [items]. + * + * @param items The set of [History]s to deselect. + */ + fun handleBackPressed(items: Set): Boolean + + /** + * Opens the share sheet for a set of history [items]. + * + * @param items The set of [History]s to share. + */ + fun handleShare(items: Set) + + /** + * Deletes the given history metadata [items] from storage. + */ + fun handleDelete(items: Set) + + /** + * Deletes all the history metadata items in this group. + */ + fun handleDeleteAll() +} + +/** + * The default implementation of [HistoryMetadataGroupController]. + */ +class DefaultHistoryMetadataGroupController( + private val activity: HomeActivity, + private val store: HistoryMetadataGroupFragmentStore, + private val navController: NavController, + private val scope: CoroutineScope, + private val searchTerm: String, +) : HistoryMetadataGroupController { + + override fun handleOpen(item: History.Metadata) { + activity.openToBrowserAndLoad( + searchTermOrURL = item.url, + newTab = true, + from = BrowserDirection.FromHistoryMetadataGroup, + historyMetadata = item.historyMetadataKey + ) + } + + override fun handleSelect(item: History.Metadata) { + store.dispatch(HistoryMetadataGroupFragmentAction.Select(item)) + } + + override fun handleDeselect(item: History.Metadata) { + store.dispatch(HistoryMetadataGroupFragmentAction.Deselect(item)) + } + + override fun handleBackPressed(items: Set): Boolean { + return if (items.isNotEmpty()) { + store.dispatch(HistoryMetadataGroupFragmentAction.DeselectAll) + true + } else { + false + } + } + + override fun handleShare(items: Set) { + navController.navigate( + HistoryMetadataGroupFragmentDirections.actionGlobalShareFragment( + data = items.map { ShareData(url = it.url, title = it.title) }.toTypedArray() + ) + ) + } + + override fun handleDelete(items: Set) { + scope.launch { + items.forEach { + store.dispatch(HistoryMetadataGroupFragmentAction.Delete(it)) + activity.components.core.historyStorage.deleteHistoryMetadata(it.historyMetadataKey) + } + } + } + + override fun handleDeleteAll() { + scope.launch { + store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll) + activity.components.core.historyStorage.deleteHistoryMetadata(searchTerm) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/interactor/HistoryMetadataGroupInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/interactor/HistoryMetadataGroupInteractor.kt new file mode 100644 index 0000000000..9e8953404f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/interactor/HistoryMetadataGroupInteractor.kt @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.interactor + +import org.mozilla.fenix.library.history.History +import org.mozilla.fenix.library.historymetadata.controller.HistoryMetadataGroupController +import org.mozilla.fenix.selection.SelectionInteractor + +/** + * Interface for history metadata group related actions in the History view. + */ +interface HistoryMetadataGroupInteractor : SelectionInteractor { + + /** + * Called on backpressed to deselect all the given [items]. + * + * @param items The set of [History]s to deselect. + */ + fun onBackPressed(items: Set): Boolean + + /** + * Deletes the given set of history metadata [items]. Called when a user clicks on the + * "Delete" menu item or the "x" button associated with a history metadata item. + * + * @param items The set of [History]s to delete. + */ + fun onDelete(items: Set) + + /** + * Deletes all the history items in the history metadata group. Called when a user clicks + * on the "Delete history" menu item. + */ + fun onDeleteAllMenuItem() + + /** + * Opens the share sheet for a set of history [items]. Called when a user clicks on the + * "Share" menu item. + * + * @param items The set of [History]s to share. + */ + fun onShareMenuItem(items: Set) +} + +/** + * The default implementation of [HistoryMetadataGroupInteractor]. + */ +class DefaultHistoryMetadataGroupInteractor( + private val controller: HistoryMetadataGroupController +) : HistoryMetadataGroupInteractor { + + override fun open(item: History.Metadata) { + controller.handleOpen(item) + } + + override fun select(item: History.Metadata) { + controller.handleSelect(item) + } + + override fun deselect(item: History.Metadata) { + controller.handleDeselect(item) + } + + override fun onBackPressed(items: Set): Boolean { + return controller.handleBackPressed(items) + } + + override fun onDelete(items: Set) { + controller.handleDelete(items) + } + + override fun onDeleteAllMenuItem() { + controller.handleDeleteAll() + } + + override fun onShareMenuItem(items: Set) { + controller.handleShare(items) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupAdapter.kt new file mode 100644 index 0000000000..e0a5d6bbbe --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupAdapter.kt @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.view + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.mozilla.fenix.library.history.History +import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor +import org.mozilla.fenix.selection.SelectionHolder + +/** + * Adapter for a list of history metadata items to be displayed. + */ +class HistoryMetadataGroupAdapter( + private val interactor: HistoryMetadataGroupInteractor +) : ListAdapter(DiffCallback), + SelectionHolder { + + private var selectedHistoryItems: Set = emptySet() + + override val selectedItems: Set + get() = selectedHistoryItems + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): HistoryMetadataGroupItemViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(HistoryMetadataGroupItemViewHolder.LAYOUT_ID, parent, false) + return HistoryMetadataGroupItemViewHolder(view, interactor, this) + } + + override fun onBindViewHolder(holder: HistoryMetadataGroupItemViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + fun updateData(items: List) { + this.selectedHistoryItems = items.filter { it.selected }.toSet() + notifyItemRangeChanged(0, items.size) + submitList(items) + } + + internal object DiffCallback : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: History.Metadata, newItem: History.Metadata): Boolean = + oldItem.id == newItem.id + + override fun areItemsTheSame(oldItem: History.Metadata, newItem: History.Metadata): Boolean = + oldItem == newItem + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolder.kt new file mode 100644 index 0000000000..1f5c9fc65d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolder.kt @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.view + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.HistoryMetadataGroupListItemBinding +import org.mozilla.fenix.ext.hideAndDisable +import org.mozilla.fenix.ext.showAndEnable +import org.mozilla.fenix.library.history.History +import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor +import org.mozilla.fenix.selection.SelectionHolder + +/** + * View holder for a history metadata list item. + */ +class HistoryMetadataGroupItemViewHolder( + view: View, + private val interactor: HistoryMetadataGroupInteractor, + private val selectionHolder: SelectionHolder +) : RecyclerView.ViewHolder(view) { + + private val binding = HistoryMetadataGroupListItemBinding.bind(view) + + private var item: History.Metadata? = null + + init { + binding.historyLayout.overflowView.setImageResource(R.drawable.ic_close) + binding.historyLayout.overflowView.setOnClickListener { + val item = this.item ?: return@setOnClickListener + interactor.onDelete(setOf(item)) + } + } + + fun bind(item: History.Metadata) { + binding.historyLayout.titleView.text = item.title + binding.historyLayout.urlView.text = item.url + + binding.historyLayout.setSelectionInteractor(item, selectionHolder, interactor) + binding.historyLayout.changeSelected(item in selectionHolder.selectedItems) + + if (this.item?.url != item.url) { + binding.historyLayout.loadFavicon(item.url) + } + + if (selectionHolder.selectedItems.isEmpty()) { + binding.historyLayout.overflowView.showAndEnable() + } else { + binding.historyLayout.overflowView.hideAndDisable() + } + + this.item = item + } + + companion object { + const val LAYOUT_ID = R.layout.history_metadata_group_list_item + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupView.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupView.kt new file mode 100644 index 0000000000..896b1ca306 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupView.kt @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.view + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ComponentHistoryMetadataGroupBinding +import org.mozilla.fenix.library.LibraryPageView +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentState +import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor + +/** + * Shows a list of history metadata items. + */ +class HistoryMetadataGroupView( + container: ViewGroup, + val interactor: HistoryMetadataGroupInteractor, + val title: String +) : LibraryPageView(container) { + + private val binding = ComponentHistoryMetadataGroupBinding.inflate( + LayoutInflater.from(container.context), container, true + ) + + private val historyMetadataGroupAdapter = HistoryMetadataGroupAdapter(interactor) + + init { + binding.historyMetadataGroupList.apply { + layoutManager = LinearLayoutManager(containerView.context) + adapter = historyMetadataGroupAdapter + } + } + + /** + * Updates the display of the history metadata items based on the given + * [HistoryMetadataGroupFragmentState]. + */ + fun update(state: HistoryMetadataGroupFragmentState) { + binding.historyMetadataGroupList.isVisible = state.items.isNotEmpty() + binding.historyMetadataGroupEmptyView.isVisible = state.items.isEmpty() + + historyMetadataGroupAdapter.updateData(state.items) + + val selectedItems = state.items.filter { it.selected } + + if (selectedItems.isEmpty()) { + setUiForNormalMode(title) + } else { + setUiForSelectingMode( + context.getString(R.string.history_multi_select_title, selectedItems.size) + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedController.kt b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedController.kt index 264bc89013..468350a8b8 100644 --- a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedController.kt +++ b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedController.kt @@ -4,9 +4,6 @@ package org.mozilla.fenix.library.recentlyclosed -import android.content.ClipData -import android.content.ClipboardManager -import android.content.res.Resources import androidx.navigation.NavController import androidx.navigation.NavOptions import mozilla.components.browser.state.action.RecentlyClosedAction @@ -18,7 +15,6 @@ import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode -import org.mozilla.fenix.components.FenixSnackbar @Suppress("TooManyFunctions") interface RecentlyClosedController { @@ -26,8 +22,6 @@ interface RecentlyClosedController { fun handleOpen(tabs: Set, mode: BrowsingMode? = null) fun handleDelete(tab: RecoverableTab) fun handleDelete(tabs: Set) - fun handleCopyUrl(item: RecoverableTab) - fun handleShare(tab: RecoverableTab) fun handleShare(tabs: Set) fun handleNavigateToHistory() fun handleRestore(item: RecoverableTab) @@ -42,9 +36,6 @@ class DefaultRecentlyClosedController( private val browserStore: BrowserStore, private val recentlyClosedStore: RecentlyClosedFragmentStore, private val tabsUseCases: TabsUseCases, - private val resources: Resources, - private val snackbar: FenixSnackbar, - private val clipboardManager: ClipboardManager, private val activity: HomeActivity, private val openToBrowser: (item: RecoverableTab, mode: BrowsingMode?) -> Unit ) : RecentlyClosedController { @@ -81,17 +72,6 @@ class DefaultRecentlyClosedController( ) } - override fun handleCopyUrl(item: RecoverableTab) { - val urlClipData = ClipData.newPlainText(item.url, item.url) - clipboardManager.setPrimaryClip(urlClipData) - with(snackbar) { - setText(resources.getString(R.string.url_copied)) - show() - } - } - - override fun handleShare(tab: RecoverableTab) = handleShare(setOf(tab)) - override fun handleShare(tabs: Set) { val shareData = tabs.map { ShareData(url = it.url, title = it.title) } navController.navigate( diff --git a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragment.kt b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragment.kt index 7505df7e95..e825e05bad 100644 --- a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragment.kt @@ -4,8 +4,6 @@ package org.mozilla.fenix.library.recentlyclosed -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.text.SpannableString import android.view.LayoutInflater @@ -15,7 +13,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController -import kotlinx.android.synthetic.main.fragment_recently_closed_tabs.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map @@ -28,9 +25,8 @@ import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode -import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.StoreProvider -import org.mozilla.fenix.ext.getRootView +import org.mozilla.fenix.databinding.FragmentRecentlyClosedTabsBinding import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.setTextColor import org.mozilla.fenix.ext.showToolbar @@ -100,8 +96,8 @@ class RecentlyClosedFragment : LibraryPageFragment(), UserIntera inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_recently_closed_tabs, container, false) + ): View { + val binding = FragmentRecentlyClosedTabsBinding.inflate(inflater, container, false) recentlyClosedFragmentStore = StoreProvider.get(this) { RecentlyClosedFragmentStore( RecentlyClosedFragmentState( @@ -116,20 +112,14 @@ class RecentlyClosedFragment : LibraryPageFragment(), UserIntera recentlyClosedStore = recentlyClosedFragmentStore, activity = activity as HomeActivity, tabsUseCases = requireComponents.useCases.tabsUseCases, - resources = requireContext().resources, - snackbar = FenixSnackbar.make( - view = requireActivity().getRootView()!!, - isDisplayedWithBrowserToolbar = true - ), - clipboardManager = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager, openToBrowser = ::openItem ) recentlyClosedInteractor = RecentlyClosedFragmentInteractor(recentlyClosedController) _recentlyClosedFragmentView = RecentlyClosedFragmentView( - view.recentlyClosedLayout, + binding.recentlyClosedLayout, recentlyClosedInteractor ) - return view + return binding.root } override fun onDestroyView() { diff --git a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentInteractor.kt index 8c1469adea..15e3edb860 100644 --- a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentInteractor.kt @@ -5,7 +5,6 @@ package org.mozilla.fenix.library.recentlyclosed import mozilla.components.browser.state.state.recover.RecoverableTab -import org.mozilla.fenix.browser.browsingmode.BrowsingMode /** * Interactor for the recently closed screen @@ -14,25 +13,6 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode class RecentlyClosedFragmentInteractor( private val recentlyClosedController: RecentlyClosedController ) : RecentlyClosedInteractor { - override fun restore(item: RecoverableTab) { - recentlyClosedController.handleRestore(item) - } - - override fun onCopyPressed(item: RecoverableTab) { - recentlyClosedController.handleCopyUrl(item) - } - - override fun onSharePressed(item: RecoverableTab) { - recentlyClosedController.handleShare(item) - } - - override fun onOpenInNormalTab(item: RecoverableTab) { - recentlyClosedController.handleOpen(item, BrowsingMode.Normal) - } - - override fun onOpenInPrivateTab(item: RecoverableTab) { - recentlyClosedController.handleOpen(item, BrowsingMode.Private) - } override fun onDelete(tab: RecoverableTab) { recentlyClosedController.handleDelete(tab) diff --git a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentView.kt b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentView.kt index 0b9054ee4c..c20b08a2a2 100644 --- a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentView.kt @@ -5,59 +5,23 @@ package org.mozilla.fenix.library.recentlyclosed import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.appcompat.content.res.AppCompatResources import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator -import kotlinx.android.synthetic.main.component_recently_closed.* import mozilla.components.browser.state.state.recover.RecoverableTab import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ComponentRecentlyClosedBinding import org.mozilla.fenix.library.LibraryPageView import org.mozilla.fenix.selection.SelectionInteractor interface RecentlyClosedInteractor : SelectionInteractor { - /** - * Called when an item is tapped to restore it. - * - * @param item the tapped item to restore. - */ - fun restore(item: RecoverableTab) - /** * Called when the view more history option is tapped. */ fun onNavigateToHistory() - /** - * Copies the URL of a recently closed tab item to the copy-paste buffer. - * - * @param item the recently closed tab item to copy the URL from - */ - fun onCopyPressed(item: RecoverableTab) - - /** - * Opens the share sheet for a recently closed tab item. - * - * @param item the recently closed tab item to share - */ - fun onSharePressed(item: RecoverableTab) - - /** - * Opens a recently closed tab item in a new tab. - * - * @param item the recently closed tab item to open in a new tab - */ - fun onOpenInNormalTab(item: RecoverableTab) - - /** - * Opens a recently closed tab item in a private tab. - * - * @param item the recently closed tab item to open in a private tab - */ - fun onOpenInPrivateTab(item: RecoverableTab) - /** * Called when recently closed tab is selected for deletion. * @@ -74,19 +38,20 @@ class RecentlyClosedFragmentView( private val interactor: RecentlyClosedFragmentInteractor ) : LibraryPageView(container) { - val view: View = LayoutInflater.from(container.context) - .inflate(R.layout.component_recently_closed, container, true) + private val binding = ComponentRecentlyClosedBinding.inflate( + LayoutInflater.from(container.context), container, true + ) private val recentlyClosedAdapter: RecentlyClosedAdapter = RecentlyClosedAdapter(interactor) init { - recently_closed_list.apply { + binding.recentlyClosedList.apply { layoutManager = LinearLayoutManager(containerView.context) adapter = recentlyClosedAdapter (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } - view_more_history.apply { + binding.viewMoreHistory.apply { titleView.text = containerView.context.getString(R.string.recently_closed_show_full_history) urlView.isVisible = false @@ -106,8 +71,8 @@ class RecentlyClosedFragmentView( fun update(state: RecentlyClosedFragmentState) { state.apply { - recently_closed_empty_view.isVisible = items.isEmpty() - recently_closed_list.isVisible = items.isNotEmpty() + binding.recentlyClosedEmptyView.isVisible = items.isEmpty() + binding.recentlyClosedList.isVisible = items.isNotEmpty() recentlyClosedAdapter.updateData(items, selectedTabs) diff --git a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedItemViewHolder.kt index 03373a94de..50dbb39472 100644 --- a/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedItemViewHolder.kt @@ -6,70 +6,56 @@ package org.mozilla.fenix.library.recentlyclosed import android.view.View import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.history_list_item.view.* -import kotlinx.android.synthetic.main.library_site_item.view.* import mozilla.components.browser.state.state.recover.RecoverableTab import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.HistoryListItemBinding import org.mozilla.fenix.ext.hideAndDisable import org.mozilla.fenix.ext.showAndEnable import org.mozilla.fenix.selection.SelectionHolder -import org.mozilla.fenix.library.history.HistoryItemMenu -import org.mozilla.fenix.utils.Do class RecentlyClosedItemViewHolder( view: View, private val recentlyClosedFragmentInteractor: RecentlyClosedFragmentInteractor, - private val selectionHolder: SelectionHolder + private val selectionHolder: SelectionHolder, ) : RecyclerView.ViewHolder(view) { + private val binding = HistoryListItemBinding.bind(view) + private var item: RecoverableTab? = null init { - setupMenu() + binding.historyLayout.overflowView.setImageResource(R.drawable.ic_close) + binding.historyLayout.overflowView.setOnClickListener { + val item = this.item ?: return@setOnClickListener + recentlyClosedFragmentInteractor.onDelete(item) + } } - fun bind( - item: RecoverableTab - ) { - itemView.history_layout.titleView.text = + fun bind(item: RecoverableTab) { + binding.historyLayout.titleView.text = if (item.title.isNotEmpty()) item.title else item.url - itemView.history_layout.urlView.text = item.url + binding.historyLayout.urlView.text = item.url - itemView.history_layout.setSelectionInteractor(item, selectionHolder, recentlyClosedFragmentInteractor) - itemView.history_layout.changeSelected(item in selectionHolder.selectedItems) + binding.historyLayout.setSelectionInteractor( + item, + selectionHolder, + recentlyClosedFragmentInteractor + ) + binding.historyLayout.changeSelected(item in selectionHolder.selectedItems) if (this.item?.url != item.url) { - itemView.history_layout.loadFavicon(item.url) + binding.historyLayout.loadFavicon(item.url) } if (selectionHolder.selectedItems.isEmpty()) { - itemView.overflow_menu.showAndEnable() + binding.historyLayout.overflowView.showAndEnable() } else { - itemView.overflow_menu.hideAndDisable() + binding.historyLayout.overflowView.hideAndDisable() } this.item = item } - private fun setupMenu() { - val historyMenu = HistoryItemMenu(itemView.context) { - val item = this.item ?: return@HistoryItemMenu - Do exhaustive when (it) { - HistoryItemMenu.Item.Copy -> recentlyClosedFragmentInteractor.onCopyPressed(item) - HistoryItemMenu.Item.Share -> recentlyClosedFragmentInteractor.onSharePressed(item) - HistoryItemMenu.Item.OpenInNewTab -> recentlyClosedFragmentInteractor.onOpenInNormalTab( - item - ) - HistoryItemMenu.Item.OpenInPrivateTab -> recentlyClosedFragmentInteractor.onOpenInPrivateTab( - item - ) - HistoryItemMenu.Item.Delete -> recentlyClosedFragmentInteractor.onDelete(item) - } - } - - itemView.history_layout.attachMenu(historyMenu.menuController) - } - companion object { const val LAYOUT_ID = R.layout.history_list_item } diff --git a/app/src/main/java/org/mozilla/fenix/migration/MigrationProgressActivity.kt b/app/src/main/java/org/mozilla/fenix/migration/MigrationProgressActivity.kt index 92be23ec6f..e2b832f713 100644 --- a/app/src/main/java/org/mozilla/fenix/migration/MigrationProgressActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/migration/MigrationProgressActivity.kt @@ -9,7 +9,6 @@ import android.os.Bundle import android.view.View import androidx.core.content.ContextCompat import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.android.synthetic.main.activity_migration.* import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.ktx.android.content.getColorFromAttr import mozilla.components.support.migration.AbstractMigrationProgressActivity @@ -21,6 +20,7 @@ import mozilla.components.support.migration.state.MigrationStore import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.IntentReceiverActivity import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ActivityMigrationBinding import org.mozilla.fenix.ext.components class MigrationProgressActivity : AbstractMigrationProgressActivity() { @@ -28,38 +28,43 @@ class MigrationProgressActivity : AbstractMigrationProgressActivity() { private val statusAdapter = MigrationStatusAdapter() override val store: MigrationStore by lazy { components.migrationStore } + private lateinit var binding: ActivityMigrationBinding + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_migration) + + binding = ActivityMigrationBinding.inflate(layoutInflater) + setContentView(binding.root) + init() } fun init() { window.navigationBarColor = getColorFromAttr(R.attr.foundation) - val appName = migration_description.context.getString(R.string.app_name) + val appName = binding.migrationDescription.context.getString(R.string.app_name) - migration_description.apply { + binding.migrationDescription.apply { text = context.getString(R.string.migration_description, appName) } - migration_status_list.apply { + binding.migrationStatusList.apply { val margin = resources.getDimensionPixelSize(R.dimen.migration_margin) addItemDecoration(MigrationStatusItemDecoration(margin)) layoutManager = LinearLayoutManager(this@MigrationProgressActivity) adapter = statusAdapter } - migration_welcome_title.apply { + binding.migrationWelcomeTitle.apply { text = context.getString(R.string.migration_title, appName) } - migration_button_text_view.text = getString(R.string.migration_updating_app_button_text, appName) + binding.migrationButtonTextView.text = getString(R.string.migration_updating_app_button_text, appName) } override fun onMigrationCompleted(results: MigrationResults) { // Enable clicking the finish button - migration_button.apply { + binding.migrationButton.apply { setOnClickListener { AbstractMigrationService.dismissNotification(context) @@ -79,12 +84,12 @@ class MigrationProgressActivity : AbstractMigrationProgressActivity() { } } } - migration_button_text_view.apply { + binding.migrationButtonTextView.apply { text = getString(R.string.migration_update_app_button, getString(R.string.app_name)) setTextColor(ContextCompat.getColor(context, R.color.white_color)) } - migration_button.setBackgroundResource(R.drawable.migration_button_background) - migration_button_progress_bar.visibility = View.INVISIBLE + binding.migrationButton.setBackgroundResource(R.drawable.migration_button_background) + binding.migrationButtonProgressBar.visibility = View.INVISIBLE // Keep the results list up-to-date. statusAdapter.updateData(results) } diff --git a/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt b/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt index 34f55e59cb..0145fa1148 100644 --- a/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt @@ -13,10 +13,10 @@ import androidx.core.view.isInvisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.migration_list_item.view.* import mozilla.components.support.migration.Migration import mozilla.components.support.migration.MigrationResults import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.MigrationListItemBinding internal data class MigrationItem( val migration: Migration, @@ -60,9 +60,11 @@ internal class MigrationStatusAdapter : } class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val context = view.context - private val title = view.migration_item_name - private val status = view.migration_status_image + private val binding = MigrationListItemBinding.bind(view) + private val title = binding.migrationItemName + private val status = binding.migrationStatusImage fun bind(item: MigrationItem) { // Get the resource ID for the item. diff --git a/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusBranchesView.kt b/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusBranchesView.kt index 5fdb59137f..9a1213b805 100644 --- a/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusBranchesView.kt +++ b/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusBranchesView.kt @@ -7,7 +7,6 @@ package org.mozilla.fenix.nimbus.view import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.extensions.LayoutContainer import mozilla.components.service.nimbus.ui.NimbusBranchAdapter import org.mozilla.fenix.nimbus.NimbusBranchesState import org.mozilla.fenix.nimbus.controller.NimbusBranchesController @@ -16,9 +15,9 @@ import org.mozilla.fenix.nimbus.controller.NimbusBranchesController * View used for managing a Nimbus experiment's branches. */ class NimbusBranchesView( - override val containerView: ViewGroup, + private val containerView: ViewGroup, val controller: NimbusBranchesController -) : LayoutContainer { +) { private val nimbusAdapter = NimbusBranchAdapter(controller) diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/HomeOnboardingDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/onboarding/HomeOnboardingDialogFragment.kt new file mode 100644 index 0000000000..ad078aadba --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/onboarding/HomeOnboardingDialogFragment.kt @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.onboarding + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentOnboardingHomeDialogBinding +import org.mozilla.fenix.ext.settings + +/** + * Dialog displayed once when one or multiples of these sections are shown in the home screen + * recentTabs,recentBookmarks,historyMetadata or pocketArticles. + */ +class HomeOnboardingDialogFragment : DialogFragment() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.HomeOnboardingDialogStyle) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_onboarding_home_dialog, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val binding = FragmentOnboardingHomeDialogBinding.bind(view) + + binding.finishButton.setOnClickListener { + context?.settings()?.let { settings -> + settings.hasShownHomeOnboardingDialog = true + } + dismiss() + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/JumpBackInCFRDialog.kt b/app/src/main/java/org/mozilla/fenix/onboarding/JumpBackInCFRDialog.kt new file mode 100644 index 0000000000..31a87ae3a9 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/onboarding/JumpBackInCFRDialog.kt @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.onboarding + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.mozilla.fenix.databinding.OnboardingJumpBackInCfrBinding +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.home.recenttabs.view.RecentTabsHeaderViewHolder + +/** + * Dialog displayed once when the jump back in section is shown in the home screen. + */ +class JumpBackInCFRDialog(val recyclerView: RecyclerView) { + + /** + * Try to show the crf dialog if it hasn't been shown before. + */ + fun showIfNeeded() { + val jumpBackInView = findJumpBackInView() + jumpBackInView?.let { + val crfDialog = createJumpCRF(anchor = jumpBackInView) + crfDialog?.let { + val context = jumpBackInView.context + context.settings().shouldShowJumpBackInCFR = false + it.show() + } + } + } + + private fun findJumpBackInView(): View? { + val count = recyclerView.adapter?.itemCount ?: return null + + for (index in 0..count) { + val viewHolder = recyclerView.findViewHolderForAdapterPosition(index) + if (viewHolder is RecentTabsHeaderViewHolder) { + return viewHolder.containerView + } + } + return null + } + + private fun createJumpCRF(anchor: View): Dialog? { + val context: Context = recyclerView.context + if (!context.settings().shouldShowJumpBackInCFR) { + return null + } + val anchorPosition = IntArray(2) + val popupBinding = OnboardingJumpBackInCfrBinding.inflate(LayoutInflater.from(context)) + val popup = Dialog(context) + + popup.apply { + setContentView(popupBinding.root) + setCancelable(false) + // removing title or setting it as an empty string does not prevent a11y services from assigning one + setTitle(" ") + } + popupBinding.closeInfoBanner.setOnClickListener { + popup.dismiss() + } + + anchor.getLocationOnScreen(anchorPosition) + val (x, y) = anchorPosition + + if (x == 0 && y == 0) { + return null + } + + popupBinding.root.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + + popup.window?.apply { + val attr = attributes + setGravity(Gravity.START or Gravity.TOP) + attr.x = x + attr.y = y - popupBinding.root.measuredHeight + attributes = attr + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + return popup + } +} diff --git a/app/src/main/java/org/mozilla/fenix/perf/ApplicationInitTimeContainer.kt b/app/src/main/java/org/mozilla/fenix/perf/ApplicationInitTimeContainer.kt new file mode 100644 index 0000000000..c0eec78c19 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/perf/ApplicationInitTimeContainer.kt @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.perf + +import android.os.SystemClock + +/** + * A class to store the application initialization time. + * Time is stores in elapsed real time nano seconds + */ +internal class ApplicationInitTimeContainer( + private val getElapsedRealtimeNanos: () -> Long = SystemClock::elapsedRealtimeNanos +) { + + var applicationInitNanos = -1L + private set + private var isApplicationInitCalled = false + + fun onApplicationInit() { + // This gets called from multiple processes: don't do anything expensive. See call site for details. + // + // In the main process, there are multiple Application impl so we ensure it's only set by + // the first one. + if (!isApplicationInitCalled) { + isApplicationInitCalled = true + applicationInitNanos = getElapsedRealtimeNanos() + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/perf/LazyMonitored.kt b/app/src/main/java/org/mozilla/fenix/perf/LazyMonitored.kt index 1cc593c6f0..e72f6c7ce9 100644 --- a/app/src/main/java/org/mozilla/fenix/perf/LazyMonitored.kt +++ b/app/src/main/java/org/mozilla/fenix/perf/LazyMonitored.kt @@ -4,42 +4,12 @@ package org.mozilla.fenix.perf -import mozilla.components.support.base.log.logger.Logger -import java.util.concurrent.atomic.AtomicInteger - -private val logger = Logger("LazyMonitored") - -/** - * A container for the number of components initialized. - */ -object ComponentInitCount { - val count = AtomicInteger(0) -} - /** - * A convenience function for setting the [LazyMonitored] property delegate, which wraps - * [lazy] to add performance monitoring. + * A function which wraps [lazy]. + * + * This functionality was previously used to add performance monitoring. This + * wrapper could be useful in the future to add more monitoring. Even though + * this method is unused, we keep the code because re-adding this wrapper to + * every component is non-trivial. */ -fun lazyMonitored(initializer: () -> T): Lazy = LazyMonitored(initializer) - -/** - * A wrapper around the [lazy] property delegate to monitor for performance related issues. - * For example, we can count the number of components initialized to see how the number of - * components initialized on start up impacts start up time. - */ -private class LazyMonitored(initializer: () -> T) : Lazy { - // Lazy is thread safe. - private val lazyValue = lazy { - // We're unlikely to have 4 billion components so we don't handle overflow. - val componentInitCount = ComponentInitCount.count.incrementAndGet() - - initializer().also { - @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") // the compiler fails with !! but warns with !!. - val className = if (it == null) "null" else it!!::class.java.canonicalName - logger.debug("Init component #$componentInitCount: $className") - } - } - - override val value: T get() = lazyValue.value - override fun isInitialized(): Boolean = lazyValue.isInitialized() -} +fun lazyMonitored(initializer: () -> T): Lazy = lazy(initializer) diff --git a/app/src/main/java/org/mozilla/fenix/perf/MarkersLifecycleCallbacks.kt b/app/src/main/java/org/mozilla/fenix/perf/MarkersLifecycleCallbacks.kt new file mode 100644 index 0000000000..c606888530 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/perf/MarkersLifecycleCallbacks.kt @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.perf + +import android.app.Activity +import android.os.Bundle +import mozilla.components.concept.engine.Engine +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.IntentReceiverActivity +import org.mozilla.fenix.android.DefaultActivityLifecycleCallbacks + +/** + * Adds a profiler marker for each activity lifecycle callbacks. The callbacks are called by the + * super method (e.g. [Activity.onCreate] so the markers occur sometime during the execution of + * our implementation (e.g. [org.mozilla.fenix.HomeActivity.onCreate]) rather than at the beginning + * or end of that method. + */ +class MarkersLifecycleCallbacks( + private val engine: Engine, +) : DefaultActivityLifecycleCallbacks { + + private fun shouldSkip(): Boolean { + return engine.profiler?.isProfilerActive() != true + } + + override fun onActivityCreated(activity: Activity, bundle: Bundle?) { + if (shouldSkip() || + // These methods are manually instrumented with duration. + activity is HomeActivity || + activity is IntentReceiverActivity + ) { + return + } + engine.profiler?.addMarker(MARKER_NAME, "${activity::class.simpleName}.onCreate (via callbacks)") + } + + override fun onActivityStarted(activity: Activity) { + if (shouldSkip() || + // These methods are manually instrumented with duration. + activity is HomeActivity + ) { + return + } + engine.profiler?.addMarker(MARKER_NAME, "${activity::class.simpleName}.onStart (via callbacks)") + } + + override fun onActivityResumed(activity: Activity) { + if (shouldSkip()) { return } + engine.profiler?.addMarker(MARKER_NAME, "${activity::class.simpleName}.onResume (via callbacks)") + } + + override fun onActivityPaused(activity: Activity) { + if (shouldSkip()) { return } + engine.profiler?.addMarker(MARKER_NAME, "${activity::class.simpleName}.onPause (via callbacks)") + } + + override fun onActivityStopped(activity: Activity) { + if (shouldSkip()) { return } + engine.profiler?.addMarker(MARKER_NAME, "${activity::class.simpleName}.onStop (via callbacks)") + } + + override fun onActivityDestroyed(activity: Activity) { + if (shouldSkip()) { return } + engine.profiler?.addMarker(MARKER_NAME, "${activity::class.simpleName}.onDestroy (via callbacks)") + } + + companion object { + const val MARKER_NAME = "Activity Lifecycle" + } +} diff --git a/app/src/main/java/org/mozilla/fenix/perf/Performance.kt b/app/src/main/java/org/mozilla/fenix/perf/Performance.kt index d990442dec..9878290f89 100644 --- a/app/src/main/java/org/mozilla/fenix/perf/Performance.kt +++ b/app/src/main/java/org/mozilla/fenix/perf/Performance.kt @@ -37,19 +37,24 @@ object Performance { } /** - * The checks for the USB connections and ADB debugging are checks in case another application + * The checks for the charging state and ADB debugging are checks in case another application * tries to leverage this intent to trigger a code path for Firefox that shouldn't be used unless * it is for testing visual metrics. These checks aren't full proof but most of our users won't have - * ADB on and USB connected at the same time when running Firefox. + * ADB on and charging at the same time when running Firefox. */ private fun isPerformanceTest(intent: Intent, context: Context): Boolean { if (!intent.getBooleanExtra(EXTRA_IS_PERFORMANCE_TEST, false)) { return false } + val batteryStatus = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) batteryStatus?.let { - val isPhonePlugged = it.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) == - BatteryManager.BATTERY_PLUGGED_USB + // We only run perf tests when the device is connected to USB. However, AC may be reported + // instead if the device is connected through a USB hub so we check both states. + val extraPlugged = it.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) + val isPhonePlugged = extraPlugged == BatteryManager.BATTERY_PLUGGED_USB || + extraPlugged == BatteryManager.BATTERY_PLUGGED_AC + val isAdbEnabled = AndroidSettings.Global.getInt( context.contentResolver, AndroidSettings.Global.ADB_ENABLED, 0 diff --git a/app/src/main/java/org/mozilla/fenix/perf/ProfilerMarkers.kt b/app/src/main/java/org/mozilla/fenix/perf/ProfilerMarkers.kt index 783992303f..6b2f4ec1e3 100644 --- a/app/src/main/java/org/mozilla/fenix/perf/ProfilerMarkers.kt +++ b/app/src/main/java/org/mozilla/fenix/perf/ProfilerMarkers.kt @@ -4,9 +4,13 @@ package org.mozilla.fenix.perf +import android.app.Activity +import android.view.MotionEvent import android.view.View +import android.view.ViewTreeObserver import androidx.core.view.doOnPreDraw import mozilla.components.concept.base.profiler.Profiler +import mozilla.components.concept.engine.Engine /** * A container for functions for when adding a profiler marker is less readable @@ -14,9 +18,41 @@ import mozilla.components.concept.base.profiler.Profiler */ object ProfilerMarkers { + fun addListenerForOnGlobalLayout(engine: Engine, activity: Activity, rootView: View) { + // We define the listener in a non-anonymous class to avoid memory leaks with the activity. + val listener = MarkerGlobalLayoutListener(engine, activity::class.simpleName ?: "null") + rootView.viewTreeObserver.addOnGlobalLayoutListener(listener) + } + fun homeActivityOnStart(rootContainer: View, profiler: Profiler?) { rootContainer.doOnPreDraw { profiler?.addMarker("onPreDraw", "expected first frame via HomeActivity.onStart") } } + + fun addForDispatchTouchEvent(profiler: Profiler?, ev: MotionEvent?) { + // We only run this if the profiler is active to minimize any possible delay on touch events. + if (profiler?.isProfilerActive() == true) { + // We only do this subset because 1) other actions like MOVE may be spammy and 2) doing + // a generic ev?.action::class.simpleName may be too expensive for dispatchTouchEvent. + val detailText = when (ev?.action) { + MotionEvent.ACTION_DOWN -> "ACTION_DOWN" + MotionEvent.ACTION_UP -> "ACTION_UP" + else -> return + } + profiler.addMarker("dispatchTouchEvent", detailText) + } + } +} + +/** + * A global layout listener that adds a profiler marker on global layout. + */ +class MarkerGlobalLayoutListener( + private val engine: Engine, + private val activityName: String, +) : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + engine.profiler?.addMarker("onGlobalLayout", activityName) + } } diff --git a/app/src/main/java/org/mozilla/fenix/perf/StartupFrameworkStartMeasurement.kt b/app/src/main/java/org/mozilla/fenix/perf/StartupFrameworkStartMeasurement.kt deleted file mode 100644 index 359ff44f6b..0000000000 --- a/app/src/main/java/org/mozilla/fenix/perf/StartupFrameworkStartMeasurement.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.perf - -import android.os.Process -import android.os.SystemClock -import java.io.FileNotFoundException -import kotlin.math.roundToLong -import org.mozilla.fenix.GleanMetrics.StartupTimeline as Telemetry - -/** - * A class to measure the time the Android framework executes before letting us execute our own code - * for the first time in Application's initializer: this value is captured in the - * [Telemetry.frameworkStart] probe. - * - * Since we cannot execute code at process start, this measurement does not fit within the Glean - * Timespan metric start/stop programming model so it lives in its own class. - */ -internal class StartupFrameworkStartMeasurement( - private val stat: Stat = Stat(), - private val telemetry: Telemetry = Telemetry, - private val getElapsedRealtimeNanos: () -> Long = SystemClock::elapsedRealtimeNanos -) { - - private var isMetricSet = false - - var applicationInitNanos = -1L - private set - private var isApplicationInitCalled = false - - fun onApplicationInit() { - // This gets called from multiple processes: don't do anything expensive. See call site for details. - // - // In the main process, there are multiple Application impl so we ensure it's only set by - // the first one. - if (!isApplicationInitCalled) { - isApplicationInitCalled = true - applicationInitNanos = getElapsedRealtimeNanos() - } - } - - /** - * Sets the values for metrics to record in glean. - * - * We defer these metrics, rather than setting them as soon as the values are available, - * because they are slow to fetch and we don't want to impact startup. - */ - fun setExpensiveMetric() { - // The application is only init once per process lifetime so we only set this value once. - if (isMetricSet) return - isMetricSet = true - - if (applicationInitNanos < 0) { - telemetry.frameworkStartError.set(true) - } else { - val clockTicksPerSecond = stat.clockTicksPerSecond.also { - // framework* is derived from the number of clock ticks per second. To ensure this - // value does not throw off our result, we capture it too. - telemetry.clockTicksPerSecondV2.set(it) - } - - // In our brief analysis, clock ticks per second was overwhelmingly equal to 100. To make - // analysis easier in GLAM, we split the results into two separate metrics. See the - // metric descriptions for more details. - @Suppress("MagicNumber") // it's more confusing to separate the comment above from the value declaration. - val durationMetric = - if (clockTicksPerSecond == 100L) telemetry.frameworkPrimary else telemetry.frameworkSecondary - - try { - durationMetric.setRawNanos(getFrameworkStartNanos()) - } catch (e: FileNotFoundException) { - // Privacy managers can add hooks that block access to reading system /proc files. - // We want to catch these exception and report an error on accessing the file - // rather than an implementation error. - telemetry.frameworkStartReadError.set(true) - } - } - } - - /** - * @throws [java.io.FileNotFoundException] - */ - private fun getFrameworkStartNanos(): Long { - // Get our timestamps in ticks: we expect ticks to be less granular than nanoseconds so, - // to ensure our measurement uses the correct number of significant figures, we convert - // everything to ticks before getting the result. - // - // Similarly, we round app init to a whole integer tick value because process start only - // comes in integer ticks values. - val processStartTicks = stat.getProcessStartTimeTicks(Process.myPid()) - val applicationInitTicks = applicationInitNanos.nanosToTicks().roundToLong() - - val frameworkStartTicks = applicationInitTicks - processStartTicks - - // Glean only takes whole unit nanoseconds so we round to that. I'm not sure but it may be - // possible that capturing nanos in a double will produce a rounding error that chops off - // significant values. However, since we expect to be using a much smaller portion of the - // nano field - if ticks are actually less granular than nanoseconds - I don't expect for - // this to be a problem. - return frameworkStartTicks.ticksToNanos().roundToLong() - } - - private fun Long.nanosToTicks(): Double = stat.convertNanosToTicks(this) - private fun Long.ticksToNanos(): Double = stat.convertTicksToNanos(this) -} diff --git a/app/src/main/java/org/mozilla/fenix/perf/StartupTimeline.kt b/app/src/main/java/org/mozilla/fenix/perf/StartupTimeline.kt index 7a9d03fa8b..3d2233023f 100644 --- a/app/src/main/java/org/mozilla/fenix/perf/StartupTimeline.kt +++ b/app/src/main/java/org/mozilla/fenix/perf/StartupTimeline.kt @@ -5,16 +5,6 @@ package org.mozilla.fenix.perf import androidx.annotation.UiThread -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.OnLifecycleEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import mozilla.components.service.glean.private.NoReasonCodes -import mozilla.components.service.glean.private.PingType -import org.mozilla.fenix.GleanMetrics.Pings import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.TopSiteItemViewHolder import org.mozilla.fenix.perf.StartupTimeline.onApplicationInit @@ -41,10 +31,7 @@ object StartupTimeline { private var state: StartupState = StartupState.Cold(StartupDestination.UNKNOWN) private val reportFullyDrawn by lazy { StartupReportFullyDrawn() } - internal val frameworkStartMeasurement by lazy { StartupFrameworkStartMeasurement() } - internal val homeActivityLifecycleObserver by lazy { - StartupHomeActivityLifecycleObserver(frameworkStartMeasurement) - } + internal val frameworkStartMeasurement by lazy { ApplicationInitTimeContainer() } fun onApplicationInit() { // This gets called from multiple processes: don't do anything expensive. See call site for details. @@ -71,31 +58,3 @@ object StartupTimeline { state = StartupTimelineStateMachine.getNextState(state, startingActivity) } } - -/** - * A [LifecycleObserver] for [HomeActivity] focused on startup performance measurement. - */ -@OptIn(DelicateCoroutinesApi::class) // GlobalScope usage -internal class StartupHomeActivityLifecycleObserver( - private val frameworkStartMeasurement: StartupFrameworkStartMeasurement, - private val startupTimeline: PingType = Pings.startupTimeline, - private val scope: CoroutineScope = GlobalScope -) : LifecycleObserver { - - @OnLifecycleEvent(Lifecycle.Event.ON_STOP) - fun onStop() { - scope.launch { // use background thread due to expensive metrics. - // Ensure any last metrics are set before submission. - frameworkStartMeasurement.setExpensiveMetric() - - // Startup metrics placed in the Activity should be re-recorded each time the Activity - // is started so we need to clear the ping lifetime by submitting once per each startup. - // It's less complex to add it here rather than the visual completeness task manager. - // - // N.B.: this submission location may need to be changed if we add metrics outside of the - // HomeActivity startup path (e.g. if the user goes directly to a separate activity and - // closes the app, they will never hit this) to appropriately adjust for the ping lifetimes. - startupTimeline.submit() - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/perf/Stat.kt b/app/src/main/java/org/mozilla/fenix/perf/Stat.kt deleted file mode 100644 index 4205813e7f..0000000000 --- a/app/src/main/java/org/mozilla/fenix/perf/Stat.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.perf - -import android.os.SystemClock -import android.system.Os -import android.system.OsConstants -import androidx.annotation.VisibleForTesting -import androidx.annotation.VisibleForTesting.PRIVATE -import java.io.File -import java.util.concurrent.TimeUnit - -private const val FIELD_POS_STARTTIME = 21 // starttime naming matches field in man page. - -/** - * Functionality from stat on the proc pseudo-filesystem common to unix systems. /proc contains - * information related to active processes. /proc/$pid/stat contains information about the status of - * the process by the given process id (pid). - * - * See the man page - `man 5 proc` - on linux for more information: - * http://man7.org/linux/man-pages/man5/proc.5.html - */ -open class Stat { - - /** - * @throws [java.io.FileNotFoundException] - */ - @VisibleForTesting(otherwise = PRIVATE) - open fun getStatText(pid: Int): String = File("/proc/$pid/stat").readText() - - // See `man 3 sysconf` for details on Os.sysconf and OsConstants: - // http://man7.org/linux/man-pages/man3/sysconf.3.html - open val clockTicksPerSecond: Long get() = Os.sysconf(OsConstants._SC_CLK_TCK) - private val nanosPerClockTick = TimeUnit.SECONDS.toNanos(1).let { nanosPerSecond -> - // We use nanos per clock tick, rather than clock ticks per nanos, to mitigate float/double - // rounding errors: this way we can use integer values and divide the larger value by the smaller one. - nanosPerSecond / clockTicksPerSecond.toDouble() - } - - /** - * Gets the process start time since system boot in ticks, including time spent in suspension/deep sleep. - * This value can be compared against [SystemClock.elapsedRealtimeNanos]: you can convert between - * measurements using [convertTicksToNanos] and [convertNanosToTicks]. - * - * Ticks are "an arbitrary unit for measuring internal system time": https://superuser.com/a/101202 - * They are not aligned with CPU frequency and do not change at runtime but can theoretically - * change between devices. On the Pixel 2, one tick is equivalent to one centisecond. - * - * We confirmed that this measurement and elapsedRealtimeNanos both include suspension time, and - * are thus comparable, by* looking at their source: - * - /proc/pid/stat starttime is set using boottime: - * https://github.com/torvalds/linux/blob/79e178a57dae819ae724065b47c25720494cc9f2/fs/proc/array.c#L536 - * - elapsedRealtimeNanos is set using boottime: - * https://cs.android.com/android/platform/superproject/+/master:system/core/libutils/SystemClock.cpp;l=60-68;drc=bab16584ce0525742b5370682c9132b2002ee110 - * - * Perf note: this call reads from the pseudo-filesystem using the java File APIs, which isn't - * likely to be a very optimized call path. - * - * Implementation inspired by https://stackoverflow.com/a/42195623. - */ - fun getProcessStartTimeTicks(pid: Int): Long { - return getStatText(pid).split(' ')[FIELD_POS_STARTTIME].toLong() - } - - fun getProcessStartTimeStampNano(pid: Int): Long { - return convertTicksToNanos(getProcessStartTimeTicks(pid)).toLong() - } - - fun convertTicksToNanos(ticks: Long): Double = ticks * nanosPerClockTick - fun convertNanosToTicks(nanos: Long): Double = nanos / nanosPerClockTick -} diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt index 616fd0652e..c11c49078a 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt @@ -35,18 +35,17 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_search_dialog.* -import kotlinx.android.synthetic.main.fragment_search_dialog.view.* -import kotlinx.android.synthetic.main.search_suggestions_hint.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.storage.HistoryStorage import mozilla.components.feature.qr.QrFeature import mozilla.components.lib.state.ext.consumeFlow import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.support.base.coroutines.Dispatchers import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.content.getColorFromAttr @@ -62,6 +61,8 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.toolbar.ToolbarPosition +import org.mozilla.fenix.databinding.FragmentSearchDialogBinding +import org.mozilla.fenix.databinding.SearchSuggestionsHintBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings @@ -74,6 +75,9 @@ typealias SearchDialogFragmentStore = SearchFragmentStore @SuppressWarnings("LargeClass", "TooManyFunctions") class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { + private var _binding: FragmentSearchDialogBinding? = null + private val binding get() = _binding!! + private var voiceSearchButtonAlreadyAdded: Boolean = false private lateinit var interactor: SearchDialogInteractor private lateinit var store: SearchDialogFragmentStore @@ -134,9 +138,9 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { val args by navArgs() - val view = inflater.inflate(R.layout.fragment_search_dialog, container, false) + _binding = FragmentSearchDialogBinding.inflate(inflater, container, false) val activity = requireActivity() as HomeActivity val isPrivate = activity.browsingModeManager.mode.isPrivate @@ -187,13 +191,12 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { interactor, historyStorageProvider(), isPrivate, - view.toolbar, + binding.toolbar, requireComponents.core.engine, fromHomeFragment ) - val awesomeBar = view.awesome_bar - awesomeBar.customizeForBottomToolbar = requireContext().settings().shouldUseBottomToolbar + val awesomeBar = binding.awesomeBar awesomeBarView = AwesomeBarView( activity, @@ -202,8 +205,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { fromHomeFragment ) - view.awesome_bar.setOnTouchListener { _, _ -> - view.hideKeyboard() + binding.awesomeBar.setOnTouchListener { _, _ -> + binding.root.hideKeyboard() false } @@ -217,14 +220,14 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { if (fromHomeFragment) { // When displayed above home, dispatches the touch events to scrim area to the HomeFragment - view.search_wrapper.background = ColorDrawable(Color.TRANSPARENT) + binding.searchWrapper.background = ColorDrawable(Color.TRANSPARENT) dialog?.window?.decorView?.setOnTouchListener { _, event -> requireActivity().dispatchTouchEvent(event) false } } - return view + return binding.root } @ExperimentalCoroutinesApi @@ -244,13 +247,13 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { // When displayed above browser, dismisses dialog on clicking scrim area if (findNavController().previousBackStackEntry?.destination?.id == R.id.browserFragment) { - search_wrapper.setOnClickListener { + binding.searchWrapper.setOnClickListener { it.hideKeyboard() dismissAllowingStateLoss() } } - view.search_engines_shortcut_button.setOnClickListener { + binding.searchEnginesShortcutButton.setOnClickListener { interactor.onSearchShortcutsButtonClicked() } @@ -260,18 +263,18 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { view = view ) - qr_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE + binding.qrScanButton.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE - qr_scan_button.setOnClickListener { + binding.qrScanButton.setOnClickListener { if (!requireContext().hasCamera()) { return@setOnClickListener } view.hideKeyboard() toolbarView.view.clearFocus() if (requireContext().settings().shouldShowCameraPermissionPrompt) { - qrFeature.get()?.scan(R.id.search_wrapper) + qrFeature.get()?.scan(binding.searchWrapper.id) } else { if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) { - qrFeature.get()?.scan(R.id.search_wrapper) + qrFeature.get()?.scan(binding.searchWrapper.id) } else { interactor.onCameraPermissionsNeeded() resetFocus() @@ -282,7 +285,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { requireContext().settings().setCameraPermissionNeededState = false } - fill_link_from_clipboard.setOnClickListener { + binding.fillLinkFromClipboard.setOnClickListener { requireComponents.analytics.metrics.track(Event.ClipboardSuggestionClicked) view.hideKeyboard() toolbarView.view.clearFocus() @@ -295,7 +298,9 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { } val stubListener = ViewStub.OnInflateListener { _, inflated -> - inflated.learn_more.setOnClickListener { + val searchSuggestionHintBinding = SearchSuggestionsHintBinding.bind(inflated) + + searchSuggestionHintBinding.learnMore.setOnClickListener { (activity as HomeActivity) .openToBrowserAndLoad( searchTermOrURL = SupportUtils.getGenericSumoURLForTopic( @@ -306,7 +311,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { ) } - inflated.allow.setOnClickListener { + searchSuggestionHintBinding.allow.setOnClickListener { inflated.visibility = View.GONE requireContext().settings().also { it.shouldShowSearchSuggestionsInPrivate = true @@ -316,7 +321,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { store.dispatch(SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt(false)) } - inflated.dismiss.setOnClickListener { + searchSuggestionHintBinding.dismiss.setOnClickListener { inflated.visibility = View.GONE requireContext().settings().also { it.shouldShowSearchSuggestionsInPrivate = false @@ -324,14 +329,14 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { } } - inflated.text.text = + searchSuggestionHintBinding.text.text = getString(R.string.search_suggestions_onboarding_text, getString(R.string.app_name)) - inflated.title.text = + searchSuggestionHintBinding.title.text = getString(R.string.search_suggestions_onboarding_title) } - view.search_suggestions_hint.setOnInflateListener((stubListener)) + binding.searchSuggestionsHint.setOnInflateListener((stubListener)) if (view.context.settings().accessibilityServicesEnabled) { updateAccessibilityTraversalOrder() } @@ -343,9 +348,9 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { * query as consumeFrom may run several times on fragment start due to state updates. * */ if (it.url != it.query) firstUpdate = false - awesome_bar?.visibility = if (shouldShowAwesomebar(it)) View.VISIBLE else View.INVISIBLE + binding.awesomeBar.visibility = if (shouldShowAwesomebar(it)) View.VISIBLE else View.INVISIBLE updateSearchSuggestionsHintVisibility(it) - updateClipboardSuggestion(it, requireContext().components.clipboardHandler.url) + updateClipboardSuggestion(it) updateToolbarContentDescription(it) updateSearchShortcutsIcon(it) toolbarView.update(it) @@ -358,14 +363,28 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { !firstUpdate && searchFragmentState.query.isNotBlank() || searchFragmentState.showSearchShortcuts private fun updateAccessibilityTraversalOrder() { - val searchWrapperId = search_wrapper.id + val searchWrapperId = binding.searchWrapper.id if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - qr_scan_button.accessibilityTraversalAfter = searchWrapperId - search_engines_shortcut_button.accessibilityTraversalAfter = searchWrapperId - fill_link_from_clipboard.accessibilityTraversalAfter = searchWrapperId + binding.qrScanButton.accessibilityTraversalAfter = searchWrapperId + binding.searchEnginesShortcutButton.accessibilityTraversalAfter = searchWrapperId + binding.fillLinkFromClipboard.accessibilityTraversalAfter = searchWrapperId } else { viewLifecycleOwner.lifecycleScope.launch { - search_wrapper.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) + binding.searchWrapper.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) + } + } + } + + override fun onResume() { + super.onResume() + + view?.post { + // We delay querying the clipboard by posting this code to the main thread message queue, + // because ClipboardManager will return null if the does app not have input focus yet. + lifecycleScope.launch(Dispatchers.Cached) { + context?.components?.clipboardHandler?.url?.let { clipboardUrl -> + store.dispatch(SearchFragmentAction.UpdateClipboardUrl(clipboardUrl)) + } } } } @@ -375,6 +394,12 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { view?.hideKeyboard() } + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } + /* * This way of dismissing the keyboard is needed to smoothly dismiss the keyboard while the dialog * is also dismissing. For example, when clicking a top site on home while this dialog is showing. @@ -410,7 +435,10 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { true } else -> { - if (FeatureFlags.showHomeBehindSearch) { + // In case we're displaying search results, we wouldn't have navigated to home, and + // so we don't need to navigate "back to" browser fragment. + // See mirror of this logic in BrowserToolbarController#handleToolbarClick. + if (FeatureFlags.showHomeBehindSearch && store.state.searchTerms.isBlank()) { val args by navArgs() args.sessionId?.let { findNavController().navigate( @@ -441,7 +469,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { requestPermissions(permissions, REQUEST_CODE_CAMERA_PERMISSIONS) }, onScanResult = { result -> - qr_scan_button.isChecked = false + binding.qrScanButton.isChecked = false activity?.let { AlertDialog.Builder(it).apply { val spannable = resources.getSpanned( @@ -457,7 +485,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { (activity as? HomeActivity)?.openToBrowserAndLoad( searchTermOrURL = result, newTab = store.state.tabId == null, - from = BrowserDirection.FromSearchDialog + from = BrowserDirection.FromSearchDialog, + flags = EngineSession.LoadUrlFlags.external() ) dialog.dismiss() } @@ -486,7 +515,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { } private fun resetFocus() { - qr_scan_button.isChecked = false + binding.qrScanButton.isChecked = false toolbarView.view.edit.focus() toolbarView.view.requestFocus() } @@ -494,31 +523,31 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { private fun setupConstraints(view: View) { if (view.context.settings().toolbarPosition == ToolbarPosition.BOTTOM) { ConstraintSet().apply { - clone(search_wrapper) + clone(binding.searchWrapper) - clear(toolbar.id, TOP) - connect(toolbar.id, BOTTOM, PARENT_ID, BOTTOM) + clear(binding.toolbar.id, TOP) + connect(binding.toolbar.id, BOTTOM, PARENT_ID, BOTTOM) - clear(pill_wrapper.id, BOTTOM) - connect(pill_wrapper.id, BOTTOM, toolbar.id, TOP) + clear(binding.pillWrapper.id, BOTTOM) + connect(binding.pillWrapper.id, BOTTOM, binding.toolbar.id, TOP) - clear(awesome_bar.id, TOP) - clear(awesome_bar.id, BOTTOM) - connect(awesome_bar.id, TOP, search_suggestions_hint.id, BOTTOM) - connect(awesome_bar.id, BOTTOM, pill_wrapper.id, TOP) + clear(binding.awesomeBar.id, TOP) + clear(binding.awesomeBar.id, BOTTOM) + connect(binding.awesomeBar.id, TOP, binding.searchSuggestionsHint.id, BOTTOM) + connect(binding.awesomeBar.id, BOTTOM, binding.pillWrapper.id, TOP) - clear(search_suggestions_hint.id, TOP) - clear(search_suggestions_hint.id, BOTTOM) - connect(search_suggestions_hint.id, TOP, PARENT_ID, TOP) - connect(search_suggestions_hint.id, BOTTOM, search_hint_bottom_barrier.id, TOP) + clear(binding.searchSuggestionsHint.id, TOP) + clear(binding.searchSuggestionsHint.id, BOTTOM) + connect(binding.searchSuggestionsHint.id, TOP, PARENT_ID, TOP) + connect(binding.searchSuggestionsHint.id, BOTTOM, binding.searchHintBottomBarrier.id, TOP) - clear(fill_link_from_clipboard.id, TOP) - connect(fill_link_from_clipboard.id, BOTTOM, pill_wrapper.id, TOP) + clear(binding.fillLinkFromClipboard.id, TOP) + connect(binding.fillLinkFromClipboard.id, BOTTOM, binding.pillWrapper.id, TOP) - clear(fill_link_divider.id, TOP) - connect(fill_link_divider.id, BOTTOM, fill_link_from_clipboard.id, TOP) + clear(binding.fillLinkDivider.id, TOP) + connect(binding.fillLinkDivider.id, BOTTOM, binding.fillLinkFromClipboard.id, TOP) - applyTo(search_wrapper) + applyTo(binding.searchWrapper) } } } @@ -529,8 +558,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { !state.showSearchShortcuts && state.url != state.query - findViewById(R.id.search_suggestions_hint)?.isVisible = showHint - search_suggestions_hint_divider?.isVisible = showHint + binding.searchSuggestionsHint.isVisible = showHint + binding.searchSuggestionsHintDivider.isVisible = showHint } } @@ -575,25 +604,26 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { private fun isSpeechAvailable(): Boolean = speechIntent.resolveActivity(requireContext().packageManager) != null - private fun updateClipboardSuggestion(searchState: SearchFragmentState, clipboardUrl: String?) { + private fun updateClipboardSuggestion(searchState: SearchFragmentState) { val shouldShowView = searchState.showClipboardSuggestions && searchState.query.isEmpty() && - !clipboardUrl.isNullOrEmpty() && !searchState.showSearchShortcuts + !searchState.clipboardUrl.isNullOrEmpty() && !searchState.showSearchShortcuts - fill_link_from_clipboard.isVisible = shouldShowView - fill_link_divider.isVisible = shouldShowView - pill_wrapper_divider.isVisible = + binding.fillLinkFromClipboard.isVisible = shouldShowView + binding.fillLinkDivider.isVisible = shouldShowView + binding.pillWrapperDivider.isVisible = !(shouldShowView && requireComponents.settings.shouldUseBottomToolbar) - clipboard_url.isVisible = shouldShowView - clipboard_title.isVisible = shouldShowView - link_icon.isVisible = shouldShowView + binding.clipboardUrl.isVisible = shouldShowView + binding.clipboardTitle.isVisible = shouldShowView + binding.linkIcon.isVisible = shouldShowView - clipboard_url.text = clipboardUrl + binding.clipboardUrl.text = searchState.clipboardUrl - fill_link_from_clipboard.contentDescription = "${clipboard_title.text}, ${clipboard_url.text}." + binding.fillLinkFromClipboard.contentDescription = + "${binding.clipboardTitle.text}, ${binding.clipboardUrl.text}." - if (clipboardUrl != null && !((activity as HomeActivity).browsingModeManager.mode.isPrivate)) { - requireComponents.core.engine.speculativeConnect(clipboardUrl) + if (searchState.clipboardUrl != null && !((activity as HomeActivity).browsingModeManager.mode.isPrivate)) { + requireComponents.core.engine.speculativeConnect(searchState.clipboardUrl) } } @@ -610,13 +640,13 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { private fun updateSearchShortcutsIcon(searchState: SearchFragmentState) { view?.apply { - search_engines_shortcut_button.isVisible = searchState.areShortcutsAvailable + binding.searchEnginesShortcutButton.isVisible = searchState.areShortcutsAvailable val showShortcuts = searchState.showSearchShortcuts - search_engines_shortcut_button.isChecked = showShortcuts + binding.searchEnginesShortcutButton.isChecked = showShortcuts val color = if (showShortcuts) R.attr.contrastText else R.attr.primaryText - search_engines_shortcut_button.compoundDrawables[0]?.setTint( + binding.searchEnginesShortcutButton.compoundDrawables[0]?.setTint( requireContext().getColorFromAttr(color) ) } diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt index 1c6570fe91..0db9b845fa 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt @@ -61,6 +61,7 @@ sealed class SearchEngineSource { * @property showHistorySuggestions Whether or not to show history suggestions in the AwesomeBar * @property showBookmarkSuggestions Whether or not to show the bookmark suggestion in the AwesomeBar * @property pastedText The text pasted from the long press toolbar menu + * @property clipboardUrl The URL in the clipboard of the user - if there's any; otherwise `null`. */ data class SearchFragmentState( val query: String, @@ -79,7 +80,8 @@ data class SearchFragmentState( val showSyncedTabsSuggestions: Boolean, val tabId: String?, val pastedText: String? = null, - val searchAccessPoint: Event.PerformedSearch.SearchAccessPoint? + val searchAccessPoint: Event.PerformedSearch.SearchAccessPoint?, + val clipboardUrl: String? = null ) : State fun createInitialSearchFragmentState( @@ -129,6 +131,7 @@ sealed class SearchFragmentAction : Action { data class ShowSearchShortcutEnginePicker(val show: Boolean) : SearchFragmentAction() data class AllowSearchSuggestionsInPrivateModePrompt(val show: Boolean) : SearchFragmentAction() data class UpdateQuery(val query: String) : SearchFragmentAction() + data class UpdateClipboardUrl(val url: String?) : SearchFragmentAction() /** * Updates the local `SearchFragmentState` from the global `SearchState` in `BrowserStore`. @@ -169,5 +172,10 @@ private fun searchStateReducer(state: SearchFragmentState, action: SearchFragmen } ) } + is SearchFragmentAction.UpdateClipboardUrl -> { + state.copy( + clipboardUrl = action.url + ) + } } } diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt index 212bfdeb0d..959953f565 100644 --- a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt +++ b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarView.kt @@ -8,7 +8,6 @@ import androidx.appcompat.content.res.AppCompatResources.getDrawable import androidx.core.graphics.BlendModeColorFilterCompat.createBlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat.SRC_IN import androidx.core.graphics.drawable.toBitmap -import mozilla.components.browser.awesomebar.BrowserAwesomeBar import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.state.searchEngines import mozilla.components.concept.awesomebar.AwesomeBar @@ -41,7 +40,7 @@ import org.mozilla.fenix.search.SearchFragmentState class AwesomeBarView( private val activity: HomeActivity, val interactor: AwesomeBarInteractor, - val view: BrowserAwesomeBar, + val view: AwesomeBarWrapper, private val fromHomeFragment: Boolean ) { private val sessionProvider: SessionSuggestionProvider @@ -93,8 +92,6 @@ class AwesomeBarView( } init { - view.itemAnimator = null - val components = activity.components val primaryTextColor = activity.getColorFromAttr(R.attr.primaryText) @@ -251,7 +248,7 @@ class AwesomeBarView( val providersToAdd = mutableSetOf() if (state.showHistorySuggestions) { - if (activity.settings().historyMetadataFeature) { + if (activity.settings().historyMetadataUIFeature) { providersToAdd.add(combinedHistoryProvider) } else { providersToAdd.add(historyStorageProvider) @@ -285,7 +282,7 @@ class AwesomeBarView( providersToRemove.add(shortcutsEnginePickerProvider) if (!state.showHistorySuggestions) { - if (activity.settings().historyMetadataFeature) { + if (activity.settings().historyMetadataUIFeature) { providersToRemove.add(combinedHistoryProvider) } else { providersToRemove.add(historyStorageProvider) diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarWrapper.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarWrapper.kt new file mode 100644 index 0000000000..a78f57e3a6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarWrapper.kt @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.search.awesomebar + +import android.content.Context +import android.util.AttributeSet +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.AbstractComposeView +import mozilla.components.compose.browser.awesomebar.AwesomeBar +import mozilla.components.compose.browser.awesomebar.AwesomeBarDefaults +import mozilla.components.compose.browser.awesomebar.AwesomeBarOrientation +import mozilla.components.concept.awesomebar.AwesomeBar +import mozilla.components.support.ktx.android.view.hideKeyboard +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.fenix.theme.ThemeManager + +/** + * This wrapper wraps the `AwesomeBar()` composable and exposes it as a `View` and `concept-awesomebar` + * implementation to be integrated in the view hierarchy of `SearchDialogFragment` until more parts + * of that screen have been refactored to use Jetpack Compose. + */ +class AwesomeBarWrapper @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AbstractComposeView(context, attrs, defStyleAttr), AwesomeBar { + private val providers = mutableStateOf(emptyList()) + private val text = mutableStateOf("") + private var onEditSuggestionListener: ((String) -> Unit)? = null + private var onStopListener: (() -> Unit)? = null + + @Composable + override fun Content() { + if (providers.value.isEmpty()) { + return + } + + val orientation = if (context.settings().shouldUseBottomToolbar) { + AwesomeBarOrientation.BOTTOM + } else { + AwesomeBarOrientation.TOP + } + + FirefoxTheme { + AwesomeBar( + text = text.value, + providers = providers.value, + orientation = orientation, + colors = AwesomeBarDefaults.colors( + background = Color.Transparent, + title = ThemeManager.resolveAttributeColor(R.attr.primaryText), + description = ThemeManager.resolveAttributeColor(R.attr.secondaryText), + autocompleteIcon = ThemeManager.resolveAttributeColor(R.attr.secondaryText) + ), + onSuggestionClicked = { suggestion -> + suggestion.onSuggestionClicked?.invoke() + onStopListener?.invoke() + }, + onAutoComplete = { suggestion -> + onEditSuggestionListener?.invoke(suggestion.editSuggestion!!) + }, + onScroll = { hideKeyboard() } + ) + } + } + + override fun addProviders(vararg providers: AwesomeBar.SuggestionProvider) { + val newProviders = this.providers.value.toMutableList() + newProviders.addAll(providers) + this.providers.value = newProviders + } + + override fun containsProvider(provider: AwesomeBar.SuggestionProvider): Boolean { + return providers.value.any { current -> current.id == provider.id } + } + + override fun onInputChanged(text: String) { + this.text.value = text + } + + override fun removeAllProviders() { + providers.value = emptyList() + } + + override fun removeProviders(vararg providers: AwesomeBar.SuggestionProvider) { + val newProviders = this.providers.value.toMutableList() + newProviders.removeAll(providers) + this.providers.value = newProviders + } + + override fun setOnEditSuggestionListener(listener: (String) -> Unit) { + onEditSuggestionListener = listener + } + + override fun setOnStopListener(listener: () -> Unit) { + onStopListener = listener + } +} diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProvider.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProvider.kt index e611373cbe..2225407e0a 100644 --- a/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProvider.kt +++ b/app/src/main/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProvider.kt @@ -6,11 +6,14 @@ package org.mozilla.fenix.search.awesomebar import android.content.Context import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.BlendModeColorFilterCompat.createBlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat.SRC_IN import androidx.core.graphics.drawable.toBitmap import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.state.searchEngines import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.awesomebar.AwesomeBar +import mozilla.components.support.ktx.android.content.getColorFromAttr import org.mozilla.fenix.R import java.util.UUID @@ -26,7 +29,12 @@ class ShortcutsSuggestionProvider( override val id: String = UUID.randomUUID().toString() private val settingsIcon by lazy { - AppCompatResources.getDrawable(context, R.drawable.ic_settings)?.toBitmap() + AppCompatResources.getDrawable(context, R.drawable.mozac_ic_settings)?.apply { + colorFilter = createBlendModeColorFilterCompat( + context.getColorFromAttr(R.attr.primaryText), + SRC_IN + ) + }?.toBitmap() } override suspend fun onInputChanged(text: String): List { diff --git a/app/src/main/java/org/mozilla/fenix/settings/CustomizationFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/CustomizationFragment.kt index 47aaa63a52..329da893b7 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/CustomizationFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/CustomizationFragment.kt @@ -10,6 +10,7 @@ import android.os.Build.VERSION.SDK_INT import android.os.Bundle import androidx.appcompat.app.AppCompatDelegate import androidx.preference.EditTextPreference +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreference import org.mozilla.fenix.FeatureFlags @@ -151,7 +152,31 @@ class CustomizationFragment : PreferenceFragmentCompat() { private fun setupHomeCategory() { requirePreference(R.string.pref_key_enable_top_frecent_sites).apply { isChecked = context.settings().showTopFrecentSites - onPreferenceChangeListener = SharedPreferenceUpdater() + onPreferenceChangeListener = CustomizeHomeMetricsUpdater() + } + + requirePreference(R.string.pref_key_recent_tabs).apply { + isVisible = FeatureFlags.showRecentTabsFeature + isChecked = context.settings().showRecentTabsFeature + onPreferenceChangeListener = CustomizeHomeMetricsUpdater() + } + + requirePreference(R.string.pref_key_recent_bookmarks).apply { + isVisible = FeatureFlags.recentBookmarksFeature + isChecked = context.settings().showRecentBookmarksFeature + onPreferenceChangeListener = CustomizeHomeMetricsUpdater() + } + + requirePreference(R.string.pref_key_pocket_homescreen_recommendations).apply { + isVisible = FeatureFlags.isPocketRecommendationsFeatureEnabled(context) + isChecked = context.settings().showPocketRecommendationsFeature + onPreferenceChangeListener = CustomizeHomeMetricsUpdater() + } + + requirePreference(R.string.pref_key_history_metadata_feature).apply { + isVisible = FeatureFlags.historyMetadataUIFeature + isChecked = context.settings().historyMetadataUIFeature + onPreferenceChangeListener = CustomizeHomeMetricsUpdater() } } @@ -189,4 +214,22 @@ class CustomizationFragment : PreferenceFragmentCompat() { onPreferenceChangeListener = SharedPreferenceUpdater() } } + + class CustomizeHomeMetricsUpdater : SharedPreferenceUpdater() { + override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { + try { + val context = preference.context + context.components.analytics.metrics.track( + Event.CustomizeHomePreferenceToggled( + preference.key, + newValue as Boolean, + context + ) + ) + } catch (e: IllegalArgumentException) { + // The event is not tracked + } + return super.onPreferenceChange(preference, newValue) + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt index bfbcb15012..6aa832f475 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt @@ -5,17 +5,17 @@ package org.mozilla.fenix.settings import android.os.Bundle -import androidx.appcompat.app.AlertDialog -import androidx.core.content.edit +import androidx.navigation.findNavController +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreference import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.MetricServiceType import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getPreferenceKey +import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar -import kotlin.system.exitProcess /** * Lets the user toggle telemetry on/off. @@ -50,6 +50,7 @@ class DataChoicesFragment : PreferenceFragmentCompat() { override fun onResume() { super.onResume() showToolbar(getString(R.string.preferences_data_collection)) + updateStudiesSection() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -68,52 +69,22 @@ class DataChoicesFragment : PreferenceFragmentCompat() { isChecked = context.settings().isMarketingTelemetryEnabled onPreferenceChangeListener = SharedPreferenceUpdater() } - - requirePreference(R.string.pref_key_experimentation).apply { - isChecked = context.settings().isExperimentationEnabled - - setOnPreferenceChangeListener { preference, enabled -> - val builder = AlertDialog.Builder(context) - .setPositiveButton( - R.string.top_sites_rename_dialog_ok - ) { dialog, _ -> - context.settings().preferences.edit { - putBoolean(preference.key, enabled).commit() - } - context.components.analytics.experiments.globalUserParticipation = enabled - dialog.dismiss() - exitProcess(0) - } - .setNegativeButton( - R.string.top_sites_rename_dialog_cancel - ) { dialog, _ -> - dialog.dismiss() - } - .setTitle(R.string.preference_experiments_2) - .setMessage(getQuittingAppString()) - .setCancelable(false) - val alertDialog: AlertDialog = builder.create() - alertDialog.show() - false - } - } } - @Suppress("TooGenericExceptionCaught") - private fun getQuittingAppString(): String { - // Fix for #20919. As we are not able to get new strings on Beta and Release, - // We are using a string that it's already translated and taking some parts of it. - // To be specific "Firefox Account/Sync server modified. Quitting the application to apply changes…" - // We are interested on the phrase after the dot, that is generic and we can use for this case. - val rawString = getString(R.string.toast_override_fxa_sync_server_done) - return try { - rawString.split(".")[1] - } catch (e: ArrayIndexOutOfBoundsException) { - rawString + private fun updateStudiesSection() { + val studiesPreference = requirePreference(R.string.pref_key_studies_section) + val settings = requireContext().settings() + val stringId = if (settings.isExperimentationEnabled) { + R.string.studies_on + } else { + R.string.studies_off } - } + studiesPreference.summary = getString(stringId) - companion object { - private const val OVERRIDE_EXIT_DELAY = 3000L + studiesPreference.setOnPreferenceClickListener { + val action = DataChoicesFragmentDirections.actionDataChoicesFragmentToStudiesFragment() + view?.findNavController()?.nav(R.id.dataChoicesFragment, action) + true + } } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/PhoneFeature.kt b/app/src/main/java/org/mozilla/fenix/settings/PhoneFeature.kt index d3724a3747..c9b56ade3d 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/PhoneFeature.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/PhoneFeature.kt @@ -10,7 +10,7 @@ import android.Manifest.permission.RECORD_AUDIO import android.content.Context import android.os.Parcelable import androidx.annotation.StringRes -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize import mozilla.components.concept.engine.permission.SitePermissions import mozilla.components.feature.sitepermissions.SitePermissionsRules import mozilla.components.support.ktx.android.content.isPermissionGranted diff --git a/app/src/main/java/org/mozilla/fenix/settings/SecretDebugSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SecretDebugSettingsFragment.kt index f9ea3d1a32..07eae25609 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SecretDebugSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SecretDebugSettingsFragment.kt @@ -21,6 +21,7 @@ import androidx.fragment.app.Fragment import org.mozilla.fenix.R import org.mozilla.fenix.components.components import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.theme.FirefoxTheme class SecretDebugSettingsFragment : Fragment() { @@ -36,7 +37,11 @@ class SecretDebugSettingsFragment : Fragment() { savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { - setContent { DebugInfo() } + setContent { + FirefoxTheme { + DebugInfo() + } + } } } } @@ -46,24 +51,29 @@ private fun DebugInfo() { val store = components.core.store Column( - modifier = Modifier.padding(8.dp) + modifier = Modifier + .padding(8.dp) ) { Text( text = stringResource(R.string.debug_info_region_home), style = MaterialTheme.typography.h6, - modifier = Modifier.padding(4.dp) + color = MaterialTheme.colors.onBackground, + modifier = Modifier.padding(4.dp), ) Text( text = store.state.search.region?.home ?: "Unknown", + color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(4.dp) ) Text( text = stringResource(R.string.debug_info_region_current), style = MaterialTheme.typography.h6, + color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(4.dp) ) Text( text = store.state.search.region?.current ?: "Unknown", + color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(4.dp) ) } diff --git a/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt index 5a989d71a9..2ace1c605b 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt @@ -36,7 +36,7 @@ class SecretSettingsFragment : PreferenceFragmentCompat() { requirePreference(R.string.pref_key_history_metadata_feature).apply { isVisible = true - isChecked = context.settings().historyMetadataFeature + isChecked = context.settings().historyMetadataUIFeature onPreferenceChangeListener = object : SharedPreferenceUpdater() { override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { val result = super.onPreferenceChange(preference, newValue) diff --git a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt index cc8f6a5916..659549a15d 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -36,7 +36,6 @@ import mozilla.components.concept.sync.Profile import mozilla.components.support.ktx.android.view.showKeyboard import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.Config -import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event @@ -451,9 +450,6 @@ class SettingsFragment : PreferenceFragmentCompat() { preferenceSyncOverride?.onPreferenceChangeListener = syncFxAOverrideUpdater with(requireContext().settings()) { - findPreference( - getPreferenceKey(R.string.pref_key_credit_cards) - )?.isVisible = FeatureFlags.creditCardsFeature findPreference( getPreferenceKey(R.string.pref_key_nimbus_experiments) )?.isVisible = showSecretDebugMenuThisSession diff --git a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt index 39422abff7..1f9d39f1f5 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt @@ -35,6 +35,7 @@ object SupportUtils { "AEwdRGF0cMhIAVB5ZFAETBVAaXRwyFQdcKydLSUpaCEtYFAIXN2UrWCUyIgdVK1slXVZaCCtZFAMWDg%3D%3D" const val PDD_URL = "https://mobile.yangkeduo.com/duo_cms_mall.html?pid=13289095_194240604&" + "cpsSign=CM_210309_13289095_194240604_8bcfd56d5db3c43d983014d2658ec26e&duoduo_type=2" + const val TC_URL = "https://jumpluna.58.com/i/29HU" const val GOOGLE_US_URL = "https://www.google.com/webhp?client=firefox-b-1-m&channel=ts" const val GOOGLE_XX_URL = "https://www.google.com/webhp?client=firefox-b-m&channel=ts" @@ -45,12 +46,14 @@ object SupportUtils { YOUR_RIGHTS("your-rights"), TRACKING_PROTECTION("tracking-protection-firefox-android"), WHATS_NEW("whats-new-firefox-preview"), + OPT_OUT_STUDIES("how-opt-out-studies-firefox-android"), SEND_TABS("send-tab-preview"), SET_AS_DEFAULT_BROWSER("set-firefox-preview-default"), SEARCH_SUGGESTION("how-search-firefox-preview"), CUSTOM_SEARCH_ENGINES("custom-search-engines"), SYNC_SETUP("how-set-firefox-sync-firefox-android"), - QR_CAMERA_ACCESS("qr-camera-access") + QR_CAMERA_ACCESS("qr-camera-access"), + SMARTBLOCK("smartblock-enhanced-tracking-protection") } enum class MozillaPage(internal val path: String) { diff --git a/app/src/main/java/org/mozilla/fenix/settings/TabsSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/TabsSettingsFragment.kt index 0bd913116b..0e184f18b3 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/TabsSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/TabsSettingsFragment.kt @@ -4,22 +4,32 @@ package org.mozilla.fenix.settings +import android.content.res.Configuration import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import android.widget.RadioButton import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreference import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event.TabViewSettingChanged import org.mozilla.fenix.components.metrics.Event.TabViewSettingChanged.Type +import org.mozilla.fenix.databinding.SurveyInactiveTabsDisableBinding import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.metrics +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.utils.view.addToRadioGroup +import java.util.Locale /** * Lets the user customize auto closing tabs. */ +@Suppress("TooManyFunctions") class TabsSettingsFragment : PreferenceFragmentCompat() { private lateinit var listRadioButton: RadioButtonPreference private lateinit var gridRadioButton: RadioButtonPreference @@ -30,6 +40,12 @@ class TabsSettingsFragment : PreferenceFragmentCompat() { private lateinit var startOnHomeRadioFourHours: RadioButtonPreference private lateinit var startOnHomeRadioAlways: RadioButtonPreference private lateinit var startOnHomeRadioNever: RadioButtonPreference + private lateinit var inactiveTabsCategory: PreferenceCategory + private lateinit var inactiveTabs: SwitchPreference + private lateinit var searchTermTabGroups: SwitchPreference + private val shouldShowInactiveTabsTurnOffSurvey + get() = requireContext().settings().isTelemetryEnabled && + requireContext().settings().shouldShowInactiveTabsTurnOffSurvey override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.tabs_preferences, rootKey) @@ -43,6 +59,7 @@ class TabsSettingsFragment : PreferenceFragmentCompat() { override fun onResume() { super.onResume() showToolbar(getString(R.string.preferences_tabs)) + setupPreferences() } @@ -54,13 +71,17 @@ class TabsSettingsFragment : PreferenceFragmentCompat() { // pref_key_tab_view_grid and look into using the native RadioGroup in the future. listRadioButton = requirePreference(R.string.pref_key_tab_view_list_do_not_use) gridRadioButton = requirePreference(R.string.pref_key_tab_view_grid) + searchTermTabGroups = requirePreference(R.string.pref_key_search_term_tab_groups).also { + it.isVisible = FeatureFlags.tabGroupFeature + it.isChecked = it.context.settings().searchTermTabGroupsAreEnabled + it.onPreferenceChangeListener = SharedPreferenceUpdater() + } radioManual = requirePreference(R.string.pref_key_close_tabs_manually) - radioOneDay = requirePreference(R.string.pref_key_close_tabs_after_one_day) - radioOneWeek = requirePreference(R.string.pref_key_close_tabs_after_one_week) radioOneMonth = requirePreference(R.string.pref_key_close_tabs_after_one_month) + radioOneWeek = requirePreference(R.string.pref_key_close_tabs_after_one_week) + radioOneDay = requirePreference(R.string.pref_key_close_tabs_after_one_day) - startOnHomeRadioFourHours = requirePreference(R.string.pref_key_start_on_home_after_four_hours) startOnHomeRadioFourHours = requirePreference(R.string.pref_key_start_on_home_after_four_hours) startOnHomeRadioAlways = requirePreference(R.string.pref_key_start_on_home_always) startOnHomeRadioNever = requirePreference(R.string.pref_key_start_on_home_never) @@ -68,12 +89,104 @@ class TabsSettingsFragment : PreferenceFragmentCompat() { requirePreference(R.string.pref_key_start_on_home_category).isVisible = FeatureFlags.showStartOnHomeSettings + inactiveTabs = requirePreference(R.string.pref_key_inactive_tabs).also { + it.isChecked = requireContext().settings().inactiveTabsAreEnabled + it.setOnPreferenceChangeListener { preference, newValue -> + if (shouldShowInactiveTabsTurnOffSurvey && newValue == false) { + // The first time the user tries to disable the feature show a little survey for her motives. + val inactiveTabsSurveyBinding = SurveyInactiveTabsDisableBinding.inflate( + LayoutInflater.from(context), + view as ViewGroup, + true + ) + setupSurvey(inactiveTabsSurveyBinding) + requireContext().metrics.track(Event.InactiveTabsSurveyOpened) + + // Don't update the preference as a direct action of user tapping the switch. + // Only disable the feature after the user selects an option in the survey or expressly closes it. + false + } else { + SharedPreferenceUpdater().onPreferenceChange(preference, newValue) + } + } + } + + inactiveTabsCategory = requirePreference(R.string.pref_key_inactive_tabs_category).also { + it.isVisible = FeatureFlags.inactiveTabs + it.isEnabled = !(it.context.settings().closeTabsAfterOneDay || it.context.settings().closeTabsAfterOneWeek) + } + listRadioButton.onClickListener(::sendTabViewTelemetry) gridRadioButton.onClickListener(::sendTabViewTelemetry) + radioManual.onClickListener(::enableInactiveTabsSetting) + radioOneDay.onClickListener(::disableInactiveTabsSetting) + radioOneWeek.onClickListener(::disableInactiveTabsSetting) + radioOneMonth.onClickListener(::enableInactiveTabsSetting) + setupRadioGroups() } + private fun setupSurvey(inactiveTabsSurveyBinding: SurveyInactiveTabsDisableBinding) { + inactiveTabsSurveyBinding.closeSurvey.setOnClickListener { + finishInactiveTabsSurvey(inactiveTabsSurveyBinding) + + // Register that user closed this survey without picking any option. + requireContext().metrics.track( + Event.InactiveTabsOffSurvey("none") + ) + } + + // A map is needed to help retrieve the correct string on SEND. + // These values are also sent to Glean which will truncate anything over 100 UTF8 characters. + val radioButtonsMap: Map = mapOf( + R.id.rb_do_not_understand to R.string.inactive_tabs_survey_do_not_understand, + R.id.rb_do_it_myself to R.string.inactive_tabs_survey_do_it_myself, + R.id.rb_time_too_long to R.string.inactive_tabs_survey_time_too_long_option, + R.id.rb_time_too_short to R.string.inactive_tabs_survey_time_too_short_option, + ) + + // Sets the Radio buttons' text + radioButtonsMap.forEach { + inactiveTabsSurveyBinding.surveyGroup.findViewById(it.key)?.text = + requireContext().getText(it.value) + } + + inactiveTabsSurveyBinding.sendButton.setOnClickListener { + val checkedRadioButtonId = inactiveTabsSurveyBinding.surveyGroup.checkedRadioButtonId + // If no option has been selected the button does not need to do anything. + if (checkedRadioButtonId != -1) { + finishInactiveTabsSurvey(inactiveTabsSurveyBinding) + + // Using the stringId of the selected option an event is sent using English. + radioButtonsMap[checkedRadioButtonId]?.let { stringId -> + requireContext().metrics.track( + Event.InactiveTabsOffSurvey(getDefaultString(stringId)) + ) + } + } + } + } + + /** + * Set the inactive tabs survey completed and the feature disabled. + */ + private fun finishInactiveTabsSurvey(inactiveTabsSurveyBinding: SurveyInactiveTabsDisableBinding) { + inactiveTabsSurveyBinding.surveyContainer.visibility = View.GONE + requireContext().settings().shouldShowInactiveTabsTurnOffSurvey = false + requireContext().settings().inactiveTabsAreEnabled = false + requirePreference(R.string.pref_key_inactive_tabs).isChecked = false + } + + /** + * Get the "en-US" string value for the indicated [resourceId]. + */ + private fun getDefaultString(resourceId: Int): String { + val config = Configuration(requireContext().resources.configuration) + config.setLocale(Locale.ENGLISH) + return requireContext().createConfigurationContext(config).getText(resourceId).toString() + } + private fun setupRadioGroups() { addToRadioGroup( listRadioButton, @@ -103,4 +216,18 @@ class TabsSettingsFragment : PreferenceFragmentCompat() { metrics.track(TabViewSettingChanged(Type.GRID)) } } + + private fun enableInactiveTabsSetting() { + inactiveTabsCategory.apply { + isEnabled = true + } + } + + private fun disableInactiveTabsSetting() { + inactiveTabsCategory.apply { + isEnabled = false + inactiveTabs.isChecked = false + context.settings().inactiveTabsAreEnabled = false + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt index 6c36b9ff8c..3af277d333 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt @@ -300,7 +300,6 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { isChecked = syncEnginesStatus.getOrElse(SyncEngine.Bookmarks) { true } } requirePreference(R.string.pref_key_sync_credit_cards).apply { - isVisible = FeatureFlags.creditCardsFeature isEnabled = syncEnginesStatus.containsKey(SyncEngine.CreditCards) isChecked = syncEnginesStatus.getOrElse(SyncEngine.CreditCards) { true } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/account/TurnOnSyncFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/account/TurnOnSyncFragment.kt index 7162b11dfc..ff3447a686 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/account/TurnOnSyncFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/account/TurnOnSyncFragment.kt @@ -87,7 +87,6 @@ class TurnOnSyncFragment : Fragment(), AccountObserver { override fun onDestroy() { super.onDestroy() requireComponents.analytics.metrics.track(Event.SyncAuthClosed) - _binding = null } override fun onResume() { @@ -139,6 +138,12 @@ class TurnOnSyncFragment : Fragment(), AccountObserver { return binding.root } + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { // If we're in a `shouldLoginJustWithEmail = true` state, we won't have a view available, // and can't display a snackbar. diff --git a/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt index 37e342fd2b..6e951e4768 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt @@ -104,9 +104,14 @@ class LocaleSettingsFragment : Fragment() { } } + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } + override fun onDestroy() { super.onDestroy() requireComponents.analytics.metrics.track(Event.SyncAuthClosed) - _binding = null } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorFragment.kt index 79cd517b27..1cc7c58436 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorFragment.kt @@ -58,7 +58,8 @@ class CreditCardEditorFragment : SecureFragment(R.layout.fragment_credit_card_ed controller = DefaultCreditCardEditorController( storage = storage, lifecycleScope = lifecycleScope, - navController = findNavController() + navController = findNavController(), + requireContext().components.analytics.metrics ) ) diff --git a/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementFragment.kt index 183e0671d0..fdf4cf9a2a 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementFragment.kt @@ -49,7 +49,8 @@ class CreditCardsManagementFragment : SecureFragment() { interactor = DefaultCreditCardsManagementInteractor( controller = DefaultCreditCardsManagementController( navController = findNavController() - ) + ), + requireContext().components.analytics.metrics ) val binding = ComponentCreditCardsBinding.bind(view) diff --git a/app/src/main/java/org/mozilla/fenix/settings/creditcards/controller/CreditCardEditorController.kt b/app/src/main/java/org/mozilla/fenix/settings/creditcards/controller/CreditCardEditorController.kt index 18fae576e0..806fe51126 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/creditcards/controller/CreditCardEditorController.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/creditcards/controller/CreditCardEditorController.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.launch import mozilla.components.concept.storage.NewCreditCardFields import mozilla.components.concept.storage.UpdatableCreditCardFields import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor @@ -55,6 +57,7 @@ class DefaultCreditCardEditorController( private val storage: AutofillCreditCardsAddressesStorage, private val lifecycleScope: CoroutineScope, private val navController: NavController, + private val metrics: MetricController, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO ) : CreditCardEditorController { @@ -69,6 +72,7 @@ class DefaultCreditCardEditorController( lifecycleScope.launch(Dispatchers.Main) { navController.popBackStack() } + metrics.track(Event.CreditCardDeleted) } } @@ -79,6 +83,7 @@ class DefaultCreditCardEditorController( lifecycleScope.launch(Dispatchers.Main) { navController.popBackStack() } + metrics.track(Event.CreditCardSaved) } } @@ -89,6 +94,7 @@ class DefaultCreditCardEditorController( lifecycleScope.launch(Dispatchers.Main) { navController.popBackStack() } + metrics.track(Event.CreditCardModified) } } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/creditcards/interactor/CreditCardsManagementInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/creditcards/interactor/CreditCardsManagementInteractor.kt index 63286d2192..1a886f123e 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/creditcards/interactor/CreditCardsManagementInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/creditcards/interactor/CreditCardsManagementInteractor.kt @@ -5,6 +5,8 @@ package org.mozilla.fenix.settings.creditcards.interactor import mozilla.components.concept.storage.CreditCard +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.settings.creditcards.controller.CreditCardsManagementController /** @@ -34,14 +36,17 @@ interface CreditCardsManagementInteractor { * all user interactions. */ class DefaultCreditCardsManagementInteractor( - private val controller: CreditCardsManagementController + private val controller: CreditCardsManagementController, + private val metrics: MetricController ) : CreditCardsManagementInteractor { override fun onSelectCreditCard(creditCard: CreditCard) { controller.handleCreditCardClicked(creditCard) + metrics.track(Event.CreditCardManagementCardTapped) } override fun onAddCreditCardClick() { controller.handleAddCreditCardClicked() + metrics.track(Event.CreditCardManagementAddTapped) } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt index 9ab138c305..3a1d743fab 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt @@ -5,7 +5,7 @@ package org.mozilla.fenix.settings.logins import android.os.Parcelable -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize import mozilla.components.concept.storage.Login import mozilla.components.lib.state.Action import mozilla.components.lib.state.State diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt index 8d4c5629fd..5645321044 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt @@ -45,6 +45,12 @@ class LoginsListController( ) } + fun handleAddLoginClicked() { + navController.navigate( + SavedLoginsFragmentDirections.actionSavedLoginsFragmentToAddLoginFragment() + ) + } + fun handleLearnMoreClicked() { browserNavigator.invoke( SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP), diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt index e8e52e216a..69efa7eede 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt @@ -23,11 +23,13 @@ import org.mozilla.fenix.R import org.mozilla.fenix.settings.logins.LoginsAction import org.mozilla.fenix.settings.logins.LoginsFragmentStore import org.mozilla.fenix.settings.logins.fragment.EditLoginFragmentDirections +import org.mozilla.fenix.settings.logins.fragment.AddLoginFragmentDirections import org.mozilla.fenix.settings.logins.mapToSavedLogin /** * Controller for all saved logins interactions with the password storage component */ +@Suppress("TooManyFunctions", "LargeClass") open class SavedLoginsStorageController( private val passwordsStorage: SyncableLoginsStorage, private val lifecycleScope: CoroutineScope, @@ -56,6 +58,50 @@ open class SavedLoginsStorageController( } } + fun add(hostnameText: String, usernameText: String, passwordText: String) { + var saveLoginJob: Deferred? = null + lifecycleScope.launch(ioDispatcher) { + saveLoginJob = async { + val loginToSave = Login( + guid = null, + origin = hostnameText, + username = usernameText, + password = passwordText, + httpRealm = hostnameText + ) + val newLoginId = add(loginToSave) + if (newLoginId.isNotEmpty()) { + val newLogin = passwordsStorage.get(newLoginId) + syncAndUpdateList(newLogin!!) + } + } + saveLoginJob?.await() + withContext(Dispatchers.Main) { + val directions = + AddLoginFragmentDirections.actionAddLoginFragmentToSavedLoginsFragment() + navController.navigate(directions) + } + } + saveLoginJob?.invokeOnCompletion { + if (it is CancellationException) { + saveLoginJob?.cancel() + } + } + } + + private suspend fun add(loginToSave: Login): String { + var newLoginId = "" + try { + newLoginId = passwordsStorage.add(loginToSave) + } catch (loginException: LoginsStorageException) { + Log.e( + "Add new login", + "Failed to add new login.", loginException + ) + } + return newLoginId + } + fun save(loginId: String, usernameText: String, passwordText: String) { var saveLoginJob: Deferred? = null lifecycleScope.launch(ioDispatcher) { @@ -148,6 +194,38 @@ open class SavedLoginsStorageController( } } + fun findPotentialDuplicates(hostnameText: String, usernameText: String, passwordText: String) { + var deferredLogin: Deferred>? = null + val fetchLoginJob = lifecycleScope.launch(ioDispatcher) { + deferredLogin = async { + val login = Login( + guid = null, + origin = hostnameText, + username = usernameText, + password = passwordText, + httpRealm = hostnameText + ) + passwordsStorage.getPotentialDupesIgnoringUsername(login) + } + val fetchedDuplicatesList = deferredLogin?.await() + fetchedDuplicatesList?.let { list -> + withContext(Dispatchers.Main) { + val savedLoginList = list.map { it.mapToSavedLogin() } + loginsFragmentStore.dispatch( + LoginsAction.ListOfDupes( + savedLoginList + ) + ) + } + } + } + fetchLoginJob.invokeOnCompletion { + if (it is CancellationException) { + deferredLogin?.cancel() + } + } + } + fun fetchLoginDetails(loginId: String) { var deferredLogin: Deferred>? = null val fetchLoginJob = lifecycleScope.launch(ioDispatcher) { diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt new file mode 100644 index 0000000000..510f7c8ea2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt @@ -0,0 +1,354 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.fragment + +import android.content.Context +import android.content.res.ColorStateList +import android.os.Bundle +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.webkit.URLUtil +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.support.ktx.android.view.hideKeyboard +import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.databinding.FragmentAddLoginBinding +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.redirectToReAuth +import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.ext.toEditable +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController +import org.mozilla.fenix.settings.logins.interactor.AddLoginInteractor +import org.mozilla.fenix.settings.logins.LoginsFragmentStore +import org.mozilla.fenix.settings.logins.SavedLogin +import org.mozilla.fenix.settings.logins.createInitialLoginsListState + +/** + * Displays the editable new login information for a single website + */ +@ExperimentalCoroutinesApi +@Suppress("TooManyFunctions", "NestedBlockDepth", "ForbiddenComment") +class AddLoginFragment : Fragment(R.layout.fragment_add_login) { + + private lateinit var loginsFragmentStore: LoginsFragmentStore + private lateinit var interactor: AddLoginInteractor + + private var listOfPossibleDupes: List? = null + + private var validPassword = true + private var validUsername = true + private var validHostname = false + + private var _binding: FragmentAddLoginBinding? = null + private val binding get() = _binding!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setHasOptionsMenu(true) + + _binding = FragmentAddLoginBinding.bind(view) + + loginsFragmentStore = StoreProvider.get(this) { + LoginsFragmentStore( + createInitialLoginsListState(requireContext().settings()) + ) + } + + interactor = AddLoginInteractor( + SavedLoginsStorageController( + passwordsStorage = requireContext().components.core.passwordsStorage, + lifecycleScope = lifecycleScope, + navController = findNavController(), + loginsFragmentStore = loginsFragmentStore + ) + ) + + initEditableValues() + + setUpClickListeners() + setUpTextListeners() + + consumeFrom(loginsFragmentStore) { + listOfPossibleDupes = loginsFragmentStore.state.duplicateLogins + } + } + + private fun initEditableValues() { + binding.hostnameText.text = "".toEditable() + binding.usernameText.text = "".toEditable() + binding.passwordText.text = "".toEditable() + + binding.hostnameText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + binding.usernameText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + + // TODO: extend PasswordTransformationMethod() to change bullets to asterisks + binding.passwordText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + + binding.passwordText.compoundDrawablePadding = + requireContext().resources + .getDimensionPixelOffset(R.dimen.saved_logins_end_icon_drawable_padding) + } + + private fun setUpClickListeners() { + binding.hostnameText.requestFocus() + val imm = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0) + + binding.clearHostnameTextButton.setOnClickListener { + binding.hostnameText.text?.clear() + binding.hostnameText.isCursorVisible = true + binding.hostnameText.hasFocus() + binding.inputLayoutHostname.hasFocus() + it.isEnabled = false + } + + binding.clearUsernameTextButton.setOnClickListener { + binding.usernameText.text?.clear() + binding.usernameText.isCursorVisible = true + binding.usernameText.hasFocus() + binding.inputLayoutUsername.hasFocus() + it.isEnabled = false + } + + binding.clearPasswordTextButton.setOnClickListener { + binding.passwordText.text?.clear() + binding.passwordText.isCursorVisible = true + binding.passwordText.hasFocus() + binding.inputLayoutPassword.hasFocus() + it.isEnabled = false + } + } + + private fun setUpTextListeners() { + val frag = view?.findViewById(R.id.addLoginFragment) + + frag?.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + view?.hideKeyboard() + } + } + + binding.addLoginLayout.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + view?.hideKeyboard() + } + } + + binding.hostnameText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(h: Editable?) { + val hostnameText = h.toString() + + when { + hostnameText.isEmpty() -> { + setHostnameError() + binding.clearHostnameTextButton.isEnabled = false + } + !URLUtil.isHttpUrl(hostnameText) && !URLUtil.isHttpsUrl(hostnameText) -> { + setHostnameError() + binding.clearHostnameTextButton.isEnabled = true + } + else -> { + validHostname = true + + binding.clearHostnameTextButton.isEnabled = true + binding.inputLayoutHostname.error = null + binding.inputLayoutHostname.errorIconDrawable = null + + interactor.findPotentialDuplicates( + hostnameText = h.toString(), + binding.usernameText.text.toString(), + binding.passwordText.text.toString() + ) + } + } + setSaveButtonState() + } + + override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) { + // NOOP + } + + override fun onTextChanged(u: CharSequence?, start: Int, before: Int, count: Int) { + // NOOP + } + }) + + binding.usernameText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(u: Editable?) { + when { + u.toString().isEmpty() -> { + binding.clearUsernameTextButton.isVisible = false + setUsernameError() + } + else -> { + setDupeError() + binding.inputLayoutUsername.error = null + binding.inputLayoutUsername.errorIconDrawable = null + } + } + binding.clearUsernameTextButton.isEnabled = u.toString().isNotEmpty() + setSaveButtonState() + } + + override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) { + // NOOP + } + + override fun onTextChanged(u: CharSequence?, start: Int, before: Int, count: Int) { + // NOOP + } + }) + + binding.passwordText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(p: Editable?) { + when { + p.toString().isEmpty() -> { + binding.clearPasswordTextButton.isVisible = false + setPasswordError() + } + else -> { + validPassword = true + binding.inputLayoutPassword.error = null + binding.inputLayoutPassword.errorIconDrawable = null + binding.clearPasswordTextButton.isVisible = true + } + } + setSaveButtonState() + } + + override fun beforeTextChanged(p: CharSequence?, start: Int, count: Int, after: Int) { + // NOOP + } + + override fun onTextChanged(p: CharSequence?, start: Int, before: Int, count: Int) { + // NOOP + } + }) + } + + private fun isDupe(username: String): Boolean = + loginsFragmentStore.state.duplicateLogins.filter { it.username == username }.any() + + private fun setDupeError() { + if (isDupe(binding.usernameText.text.toString())) { + binding.inputLayoutUsername.let { + validUsername = false + it.error = context?.getString(R.string.saved_login_duplicate) + it.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + it.setErrorIconTintList( + ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), R.color.design_error) + ) + ) + binding.clearUsernameTextButton.isVisible = false + } + } else { + validUsername = true + binding.inputLayoutUsername.error = null + binding.inputLayoutUsername.errorIconDrawable = null + binding.clearUsernameTextButton.isVisible = true + } + } + + private fun setPasswordError() { + binding.inputLayoutPassword.let { layout -> + validPassword = false + layout.error = context?.getString(R.string.saved_login_password_required) + layout.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + layout.setErrorIconTintList( + ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), R.color.design_error) + ) + ) + } + } + + private fun setUsernameError() { + binding.inputLayoutUsername.let { layout -> + validUsername = false + layout.error = context?.getString(R.string.saved_login_username_required) + layout.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + layout.setErrorIconTintList( + ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), R.color.design_error) + ) + ) + } + } + + private fun setHostnameError() { + binding.inputLayoutHostname.let { layout -> + validHostname = false + layout.error = context?.getString(R.string.add_login_hostname_invalid_text_2) + layout.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + layout.setErrorIconTintList( + ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), R.color.design_error) + ) + ) + } + } + + private fun setSaveButtonState() { + activity?.invalidateOptionsMenu() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.login_save, menu) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + val saveButton = menu.findItem(R.id.save_login_button) + val changesMadeWithNoErrors = validHostname && validUsername && validPassword + saveButton.isEnabled = changesMadeWithNoErrors + } + + override fun onPause() { + redirectToReAuth( + listOf(R.id.loginDetailFragment, R.id.savedLoginsFragment), + findNavController().currentDestination?.id, + R.id.editLoginFragment + ) + super.onPause() + } + + override fun onResume() { + super.onResume() + showToolbar(getString(R.string.add_login)) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.save_login_button -> { + view?.hideKeyboard() + interactor.onAddLogin( + binding.hostnameText.text.toString(), + binding.usernameText.text.toString(), + binding.passwordText.text.toString() + ) + true + } + else -> false + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt index 38bd7a66fb..cadb1f17f0 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt @@ -271,7 +271,7 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { override fun onPause() { redirectToReAuth( - listOf(R.id.loginDetailFragment), + listOf(R.id.loginDetailFragment, R.id.savedLoginsFragment), findNavController().currentDestination?.id, R.id.editLoginFragment ) diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt index 17953c7912..b9b7d5592c 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt @@ -44,7 +44,7 @@ import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController import org.mozilla.fenix.settings.logins.createInitialLoginsListState import org.mozilla.fenix.settings.logins.interactor.LoginDetailInteractor import org.mozilla.fenix.settings.logins.togglePasswordReveal -import org.mozilla.fenix.settings.logins.view.LoginDetailView +import org.mozilla.fenix.settings.logins.view.LoginDetailsBindingDelegate /** * Displays saved login information for a single website. @@ -56,7 +56,7 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { private val args by navArgs() private var login: SavedLogin? = null private lateinit var savedLoginsStore: LoginsFragmentStore - private lateinit var loginDetailView: LoginDetailView + private lateinit var loginDetailsBindingDelegate: LoginDetailsBindingDelegate private lateinit var interactor: LoginDetailInteractor private lateinit var menu: Menu private var deleteDialog: AlertDialog? = null @@ -76,9 +76,7 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { createInitialLoginsListState(requireContext().settings()) ) } - loginDetailView = LoginDetailView( - view.findViewById(R.id.loginDetailLayout) - ) + loginDetailsBindingDelegate = LoginDetailsBindingDelegate(binding) return view } @@ -99,7 +97,7 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { interactor.onFetchLoginList(args.savedLoginId) consumeFrom(savedLoginsStore) { - loginDetailView.update(it) + loginDetailsBindingDelegate.update(it) login = savedLoginsStore.state.currentItem setUpCopyButtons() showToolbar( diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt index b139a53ece..339ad11cf4 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.launch import mozilla.components.feature.autofill.preference.AutofillPreference import mozilla.components.service.fxa.SyncEngine import mozilla.components.support.base.feature.ViewBoundFeatureWrapper -import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components @@ -102,10 +101,6 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat() { requirePreference(R.string.pref_key_android_autofill).apply { update() - - if (!FeatureFlags.androidAutofill) { - isVisible = false - } } requirePreference(R.string.pref_key_login_exceptions).apply { diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt index a7a4cac1f7..ab47c93fa6 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt @@ -151,7 +151,7 @@ class SavedLoginsFragment : Fragment() { setHasOptionsMenu(false) redirectToReAuth( - listOf(R.id.loginDetailFragment), + listOf(R.id.loginDetailFragment, R.id.addLoginFragment), findNavController().currentDestination?.id, R.id.savedLoginsFragment ) diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/AddLoginInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/AddLoginInteractor.kt new file mode 100644 index 0000000000..8b1421d6f7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/AddLoginInteractor.kt @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.interactor + +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController + +/** + * Interactor for the add login screen + * + * @property savedLoginsController controller for the saved logins storage + */ +class AddLoginInteractor( + private val savedLoginsController: SavedLoginsStorageController +) { + fun findPotentialDuplicates(hostnameText: String, usernameText: String, passwordText: String) { + savedLoginsController.findPotentialDuplicates(hostnameText, usernameText, passwordText) + } + + fun onAddLogin(hostnameText: String, usernameText: String, passwordText: String) { + savedLoginsController.add(hostnameText, usernameText, passwordText) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt index 5bf69a99b8..662c3a7ffb 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt @@ -36,4 +36,8 @@ class SavedLoginsInteractor( fun loadAndMapLogins() { savedLoginsStorageController.handleLoadAndMapLogins() } + + fun onAddLoginClick() { + loginsListController.handleAddLoginClicked() + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailsBindingDelegate.kt similarity index 52% rename from app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailsBindingDelegate.kt index 123e8f121b..7e1cf788e6 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailsBindingDelegate.kt @@ -4,18 +4,18 @@ package org.mozilla.fenix.settings.logins.view -import android.view.ViewGroup -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.fragment_login_detail.* +import org.mozilla.fenix.databinding.FragmentLoginDetailBinding import org.mozilla.fenix.settings.logins.LoginsListState /** * View that contains and configures the Login Details */ -class LoginDetailView(override val containerView: ViewGroup) : LayoutContainer { +class LoginDetailsBindingDelegate( + private val binding: FragmentLoginDetailBinding +) { fun update(login: LoginsListState) { - webAddressText.text = login.currentItem?.origin - usernameText.text = login.currentItem?.username - passwordText.text = login.currentItem?.password + binding.webAddressText.text = login.currentItem?.origin + binding.usernameText.text = login.currentItem?.username + binding.passwordText.text = login.currentItem?.password } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt index 005e20bd58..69f73265f5 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt @@ -7,44 +7,41 @@ package org.mozilla.fenix.settings.logins.view import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.ViewGroup -import android.widget.FrameLayout import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.component_saved_logins.view.* import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ComponentSavedLoginsBinding +import org.mozilla.fenix.ext.addUnderline import org.mozilla.fenix.settings.logins.LoginsListState import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor -import org.mozilla.fenix.ext.addUnderline /** * View that contains and configures the Saved Logins List */ class SavedLoginsListView( - override val containerView: ViewGroup, + private val containerView: ViewGroup, val interactor: SavedLoginsInteractor -) : LayoutContainer { - - val view: FrameLayout = LayoutInflater.from(containerView.context) - .inflate(R.layout.component_saved_logins, containerView, true) - .findViewById(R.id.saved_logins_wrapper) +) { + private val binding = ComponentSavedLoginsBinding.inflate( + LayoutInflater.from(containerView.context), containerView, true + ) private val loginsAdapter = LoginsAdapter(interactor) init { - view.saved_logins_list.apply { + binding.savedLoginsList.apply { adapter = loginsAdapter layoutManager = LinearLayoutManager(containerView.context) itemAnimator = null } - with(view.saved_passwords_empty_learn_more) { + with(binding.savedPasswordsEmptyLearnMore) { movementMethod = LinkMovementMethod.getInstance() addUnderline() setOnClickListener { interactor.onLearnMoreClicked() } } - with(view.saved_passwords_empty_message) { + with(binding.savedPasswordsEmptyMessage) { val appName = context.getString(R.string.app_name) text = String.format( context.getString( @@ -53,15 +50,17 @@ class SavedLoginsListView( appName ) } + + binding.addLoginButton.addLoginLayout.setOnClickListener { interactor.onAddLoginClick() } } fun update(state: LoginsListState) { if (state.isLoading) { - view.progress_bar.isVisible = true + binding.progressBar.isVisible = true } else { - view.progress_bar.isVisible = false - view.saved_logins_list.isVisible = state.loginList.isNotEmpty() - view.saved_passwords_empty_view.isVisible = state.loginList.isEmpty() + binding.progressBar.isVisible = false + binding.savedLoginsList.isVisible = state.loginList.isNotEmpty() + binding.savedPasswordsEmptyView.isVisible = state.loginList.isEmpty() } loginsAdapter.submitList(state.filteredItems) } diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsController.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsController.kt new file mode 100644 index 0000000000..4d3b690aac --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsController.kt @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.quicksettings + +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.navigation.NavController +import mozilla.components.browser.state.state.SessionState +import mozilla.components.concept.engine.permission.SitePermissions +import org.mozilla.fenix.browser.BrowserFragmentDirections +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.runIfFragmentIsAttached + +/** + * [ConnectionDetailsController] controller. + * + * Delegated by View Interactors, handles container business logic and operates changes on it, + * complex Android interactions or communication with other features. + */ +interface ConnectionDetailsController { + /** + * @see [WebSiteInfoInteractor.onBackPressed] + */ + fun handleBackPressed() +} + +/** + * Default behavior of [ConnectionDetailsController]. + */ +class DefaultConnectionDetailsController( + private val context: Context, + private val fragment: Fragment, + private val navController: () -> NavController, + internal var sitePermissions: SitePermissions?, + private val gravity: Int, + private val getCurrentTab: () -> SessionState? +) : ConnectionDetailsController { + + override fun handleBackPressed() { + getCurrentTab()?.let { tab -> + context.components.useCases.trackingProtectionUseCases.containsException(tab.id) { contains -> + fragment.runIfFragmentIsAttached { + navController().popBackStack() + val isTrackingProtectionEnabled = tab.trackingProtection.enabled && !contains + val directions = + BrowserFragmentDirections.actionGlobalQuickSettingsSheetDialogFragment( + sessionId = tab.id, + url = tab.content.url, + title = tab.content.title, + isSecured = tab.content.securityInfo.secure, + sitePermissions = sitePermissions, + gravity = gravity, + certificateName = tab.content.securityInfo.issuer, + permissionHighlights = tab.content.permissionHighlights, + isTrackingProtectionEnabled = isTrackingProtectionEnabled + ) + navController().navigate(directions) + } + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsInteractor.kt new file mode 100644 index 0000000000..f8f3d07649 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsInteractor.kt @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.quicksettings + +/** + * [ConnectionPanelDialogFragment] interactor. + * + * Implements callbacks for each of [ConnectionPanelDialogFragment]'s Views declared possible user interactions, + * delegates all such user events to the [ConnectionDetailsController]. + * + * @param controller [ConnectionDetailsController] which will be delegated for all users interactions, + * it expected to contain all business logic for how to act in response. + */ +class ConnectionDetailsInteractor( + private val controller: ConnectionDetailsController +) : WebSiteInfoInteractor { + + override fun onBackPressed() { + controller.handleBackPressed() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsView.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsView.kt new file mode 100644 index 0000000000..b7f8c7d548 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsView.kt @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.quicksettings + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.core.view.isVisible +import mozilla.components.browser.icons.BrowserIcons +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ConnectionDetailsWebsiteInfoBinding +import org.mozilla.fenix.ext.loadIntoView + +/** + * MVI View that knows to display a whether the current website connection details. + * + * Currently it does not support any user interaction. + * + * @param container [ViewGroup] in which this View will inflate itself. + * @param icons Icons component for loading, caching and processing website icons. + * @param interactor [WebSiteInfoInteractor] which will have delegated to all user interactions. + */ +class ConnectionDetailsView( + container: ViewGroup, + private val icons: BrowserIcons, + val interactor: WebSiteInfoInteractor, +) { + val binding = ConnectionDetailsWebsiteInfoBinding.inflate( + LayoutInflater.from(container.context), container, true + ) + + /** + * Allows changing what this View displays. + * + * @param state [WebsiteInfoState] to be rendered. + */ + fun update(state: WebsiteInfoState) { + icons.loadIntoView(binding.faviconImage, state.websiteUrl) + bindUrl(state.websiteUrl) + bindSecurityInfo(state.websiteSecurityUiValues) + bindCertificateName(state.certificateName) + bindTitle(state.websiteTitle) + bindBackButtonListener() + } + + private fun bindUrl(websiteUrl: String) { + binding.url.text = websiteUrl + } + + private fun bindSecurityInfo(uiValues: WebsiteSecurityUiValues) { + binding.securityInfo.setText(uiValues.securityInfoRes) + binding.securityInfoIcon.setImageResource(uiValues.iconRes) + } + + @VisibleForTesting + internal fun provideContext(): Context = binding.root.context + + @VisibleForTesting + internal fun bindBackButtonListener() { + binding.detailsBack.isVisible = true + binding.detailsBack.setOnClickListener { + interactor.onBackPressed() + } + } + + @VisibleForTesting + internal fun bindTitle(websiteTitle: String) { + binding.title.text = websiteTitle + binding.titleContainer.isVisible = websiteTitle.isNotEmpty() + } + + @VisibleForTesting + internal fun bindCertificateName(cert: String) { + val certificateLabel = + provideContext().getString(R.string.certificate_info_verified_by, cert) + binding.certificateInfo.text = certificateLabel + binding.certificateInfo.isVisible = cert.isNotEmpty() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionPanelDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionPanelDialogFragment.kt new file mode 100644 index 0000000000..d47bdb945f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/ConnectionPanelDialogFragment.kt @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.quicksettings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.browser.state.selector.findTabOrCustomTab +import mozilla.components.browser.state.state.SessionState +import org.mozilla.fenix.R +import org.mozilla.fenix.android.FenixDialogFragment +import org.mozilla.fenix.databinding.FragmentConnectionDetailsDialogBinding +import org.mozilla.fenix.ext.requireComponents + +@ExperimentalCoroutinesApi +class ConnectionPanelDialogFragment : FenixDialogFragment() { + @VisibleForTesting + private lateinit var connectionView: ConnectionDetailsView + private val args by navArgs() + private var _binding: FragmentConnectionDetailsDialogBinding? = null + + override val gravity: Int get() = args.gravity + override val layoutId: Int = R.layout.fragment_connection_details_dialog + // This property is only valid between onCreateView and onDestroyView. + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val rootView = inflateRootView(container) + + val controller = DefaultConnectionDetailsController( + context = requireContext(), + fragment = this, + navController = { findNavController() }, + sitePermissions = args.sitePermissions, + gravity = args.gravity, + getCurrentTab = ::getCurrentTab + ) + + val interactor = ConnectionDetailsInteractor(controller) + _binding = FragmentConnectionDetailsDialogBinding.bind(rootView) + + connectionView = ConnectionDetailsView( + binding.connectionDetailsInfoLayout, + icons = requireComponents.core.icons, + interactor = interactor + ) + + return rootView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + connectionView.update( + WebsiteInfoState.createWebsiteInfoState( + args.url, + args.title, + args.isSecured, + args.certificateName + ) + ) + } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } + + private fun getCurrentTab(): SessionState? { + return requireComponents.core.store.state.findTabOrCustomTab(args.sessionId) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsController.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsController.kt index 48ea3d23c9..089e0df788 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsController.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsController.kt @@ -11,12 +11,16 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.store.BrowserStore -import mozilla.components.feature.session.SessionUseCases.ReloadUrlUseCase import mozilla.components.concept.engine.permission.SitePermissions +import mozilla.components.feature.session.SessionUseCases.ReloadUrlUseCase import mozilla.components.feature.tabs.TabsUseCases.AddNewTabUseCase import mozilla.components.support.base.feature.OnNeedToRequestPermissions import mozilla.components.support.ktx.kotlin.getOrigin +import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.components.PermissionStorage +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.settings.PhoneFeature import org.mozilla.fenix.settings.quicksettings.ext.shouldBeEnabled import org.mozilla.fenix.settings.toggle @@ -54,6 +58,22 @@ interface QuickSettingsController { * feature [PhoneFeature] which the user granted Android permission(s) for. */ fun handleAndroidPermissionGranted(feature: PhoneFeature) + + /** + * @see [TrackingProtectionInteractor.onTrackingProtectionToggled] + */ + fun handleTrackingProtectionToggled(isEnabled: Boolean) + + /** + * @see [TrackingProtectionInteractor.onDetailsClicked] + */ + fun handleDetailsClicked() + + /** + * Navigates to the connection details. Called when a user clicks on the + * "Secured or Insecure Connection" section. + */ + fun handleConnectionDetailsClicked() } /** @@ -80,7 +100,7 @@ class DefaultQuickSettingsController( private val quickSettingsStore: QuickSettingsFragmentStore, private val browserStore: BrowserStore, private val ioScope: CoroutineScope, - private val navController: NavController, + private val navController: () -> NavController, @VisibleForTesting internal val sessionId: String, @VisibleForTesting @@ -155,6 +175,59 @@ class DefaultQuickSettingsController( ) } + override fun handleTrackingProtectionToggled(isEnabled: Boolean) { + val components = context.components + val sessionState = components.core.store.state.findTabOrCustomTab(sessionId) + + sessionState?.let { session -> + val trackingProtectionUseCases = components.useCases.trackingProtectionUseCases + val sessionUseCases = components.useCases.sessionUseCases + + if (isEnabled) { + trackingProtectionUseCases.removeException(session.id) + } else { + context.metrics.track(Event.TrackingProtectionException) + trackingProtectionUseCases.addException(session.id) + } + + sessionUseCases.reload.invoke(session.id) + } + + quickSettingsStore.dispatch(TrackingProtectionAction.ToggleTrackingProtectionEnabled(isEnabled)) + } + + override fun handleDetailsClicked() { + navController().popBackStack() + + val state = quickSettingsStore.state.trackingProtectionState + val directions = NavGraphDirections + .actionGlobalTrackingProtectionPanelDialogFragment( + sessionId = sessionId, + url = state.url, + trackingProtectionEnabled = state.isTrackingProtectionEnabled, + gravity = context.components.settings.toolbarPosition.androidGravity, + sitePermissions = sitePermissions + ) + navController().navigate(directions) + } + + override fun handleConnectionDetailsClicked() { + navController().popBackStack() + + val state = quickSettingsStore.state.webInfoState + val directions = ConnectionPanelDialogFragmentDirections + .actionGlobalConnectionDetailsDialogFragment( + sessionId = sessionId, + title = state.websiteTitle, + url = state.websiteUrl, + isSecured = state.websiteSecurityUiValues == WebsiteSecurityUiValues.SECURE, + certificateName = state.certificateName, + gravity = context.components.settings.toolbarPosition.androidGravity, + sitePermissions = sitePermissions + ) + navController().navigate(directions) + } + /** * Request a certain set of runtime Android permissions. * @@ -197,6 +270,6 @@ class DefaultQuickSettingsController( private fun navigateToManagePhoneFeature(phoneFeature: PhoneFeature) { val directions = QuickSettingsSheetDialogFragmentDirections .actionGlobalSitePermissionsManagePhoneFeature(phoneFeature) - navController.navigate(directions) + navController().navigate(directions) } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentAction.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentAction.kt index 48d785ca28..8d24dd7c75 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentAction.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentAction.kt @@ -6,6 +6,7 @@ package org.mozilla.fenix.settings.quicksettings import mozilla.components.lib.state.Action import org.mozilla.fenix.settings.PhoneFeature +import org.mozilla.fenix.trackingprotection.TrackingProtectionState /** * Parent [Action] for all the [QuickSettingsFragmentState] changes. @@ -46,3 +47,16 @@ sealed class WebsitePermissionAction(open val updatedFeature: PhoneFeature) : Qu val autoplayValue: AutoplayValue ) : WebsitePermissionAction(PhoneFeature.AUTOPLAY) } + +/** + * All possible [TrackingProtectionState] changes as a result oof user / system interactions. + */ +sealed class TrackingProtectionAction : QuickSettingsFragmentAction() { + /** + * Toggles the enabled state of tracking protection. + * + * @param isTrackingProtectionEnabled Whether or not tracking protection is enabled. + */ + data class ToggleTrackingProtectionEnabled(val isTrackingProtectionEnabled: Boolean) : + TrackingProtectionAction() +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentReducer.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentReducer.kt index e26c7f3f0f..0e3654389d 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentReducer.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentReducer.kt @@ -4,10 +4,12 @@ package org.mozilla.fenix.settings.quicksettings +import org.mozilla.fenix.trackingprotection.TrackingProtectionState + /** * Parent Reducer for all [QuickSettingsFragmentState]s of all Views shown in this Fragment. */ -fun quickSettingsFragmentReducer( +internal fun quickSettingsFragmentReducer( state: QuickSettingsFragmentState, action: QuickSettingsFragmentAction ): QuickSettingsFragmentState { @@ -24,6 +26,12 @@ fun quickSettingsFragmentReducer( action ) ) + is TrackingProtectionAction -> state.copy( + trackingProtectionState = TrackingProtectionStateReducer.reduce( + state = state.trackingProtectionState, + action = action + ) + ) } } @@ -58,3 +66,19 @@ object WebsitePermissionsStateReducer { } } } + +object TrackingProtectionStateReducer { + /** + * Handles creating a new [TrackingProtectionState] based on the specific + * [TrackingProtectionAction]. + */ + fun reduce( + state: TrackingProtectionState, + action: TrackingProtectionAction + ): TrackingProtectionState { + return when (action) { + is TrackingProtectionAction.ToggleTrackingProtectionEnabled -> + state.copy(isTrackingProtectionEnabled = action.isTrackingProtectionEnabled) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentState.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentState.kt index 0d9e8c75d9..203b6ec72d 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentState.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentState.kt @@ -5,7 +5,6 @@ package org.mozilla.fenix.settings.quicksettings import android.content.Context -import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes import mozilla.components.concept.engine.permission.SitePermissions @@ -15,6 +14,7 @@ import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayA import mozilla.components.lib.state.State import org.mozilla.fenix.R import org.mozilla.fenix.settings.PhoneFeature +import org.mozilla.fenix.trackingprotection.TrackingProtectionState import org.mozilla.fenix.utils.Settings /** @@ -24,7 +24,8 @@ import org.mozilla.fenix.utils.Settings */ data class QuickSettingsFragmentState( val webInfoState: WebsiteInfoState, - val websitePermissionsState: WebsitePermissionsState + val websitePermissionsState: WebsitePermissionsState, + val trackingProtectionState: TrackingProtectionState ) : State /** @@ -39,22 +40,43 @@ data class WebsiteInfoState( val websiteTitle: String, val websiteSecurityUiValues: WebsiteSecurityUiValues, val certificateName: String -) : State +) : State { + + companion object { + /** + * Construct an initial [WebsiteInfoState] + * based on the current website's status and connection. + * While being displayed users have no way of modifying it. + * + * @param websiteUrl [String] the URL of the current web page. + * @param websiteTitle [String] the title of the current web page. + * @param isSecured [Boolean] whether the connection is secured (TLS) or not. + * @param certificateName [String] the certificate name of the current web page. + */ + fun createWebsiteInfoState( + websiteUrl: String, + websiteTitle: String, + isSecured: Boolean, + certificateName: String + ): WebsiteInfoState { + val uiValues = + if (isSecured) WebsiteSecurityUiValues.SECURE else WebsiteSecurityUiValues.INSECURE + return WebsiteInfoState(websiteUrl, websiteTitle, uiValues, certificateName) + } + } +} enum class WebsiteSecurityUiValues( @StringRes val securityInfoRes: Int, - @DrawableRes val iconRes: Int, - @ColorRes val iconTintRes: Int + @DrawableRes val iconRes: Int ) { SECURE( - R.string.quick_settings_sheet_secure_connection, - R.drawable.mozac_ic_lock, - R.color.photonGreen50 + R.string.quick_settings_sheet_secure_connection_2, + R.drawable.ic_lock ), INSECURE( - R.string.quick_settings_sheet_insecure_connection, - R.drawable.mozac_ic_globe, - R.color.photonRed50 + R.string.quick_settings_sheet_insecure_connection_2, + R.drawable.mozac_ic_broken_lock ) } diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentStore.kt index cbf36b871e..40606e34c4 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentStore.kt @@ -6,16 +6,20 @@ package org.mozilla.fenix.settings.quicksettings import android.content.Context import androidx.annotation.VisibleForTesting +import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.state.content.PermissionHighlightsState import mozilla.components.concept.engine.permission.SitePermissions import mozilla.components.lib.state.Action import mozilla.components.lib.state.Reducer import mozilla.components.lib.state.State import mozilla.components.lib.state.Store +import org.mozilla.fenix.ext.components import org.mozilla.fenix.settings.PhoneFeature import org.mozilla.fenix.settings.quicksettings.QuickSettingsFragmentStore.Companion.createStore +import org.mozilla.fenix.settings.quicksettings.WebsiteInfoState.Companion.createWebsiteInfoState import org.mozilla.fenix.settings.quicksettings.ext.shouldBeEnabled import org.mozilla.fenix.settings.quicksettings.ext.shouldBeVisible +import org.mozilla.fenix.trackingprotection.TrackingProtectionState import org.mozilla.fenix.utils.Settings import java.util.EnumMap @@ -48,7 +52,10 @@ class QuickSettingsFragmentStore( * @param isSecured [Boolean] whether the connection is secured (TLS) or not. * @param permissions [SitePermissions]? list of website permissions and their status. * @param settings [Settings] application settings. - * @param certificateName [String] the certificate name of the current web page. + * @param certificateName [String] the certificate name of the current web page. + * @param sessionId [String] The current session ID. + * @param isTrackingProtectionEnabled [Boolean] Current status of tracking protection + * for this session. */ @Suppress("LongParameterList") fun createStore( @@ -59,7 +66,9 @@ class QuickSettingsFragmentStore( isSecured: Boolean, permissions: SitePermissions?, permissionHighlights: PermissionHighlightsState, - settings: Settings + settings: Settings, + sessionId: String, + isTrackingProtectionEnabled: Boolean ) = QuickSettingsFragmentStore( QuickSettingsFragmentState( webInfoState = createWebsiteInfoState( @@ -73,30 +82,16 @@ class QuickSettingsFragmentStore( permissions, permissionHighlights, settings + ), + trackingProtectionState = createTrackingProtectionState( + context, + sessionId, + websiteUrl, + isTrackingProtectionEnabled ) ) ) - /** - * Construct an initial [WebsiteInfoState] to be rendered by [WebsiteInfoView] - * based on the current website's status and connection. - * - * While being displayed users have no way of modifying it. - * - * @param websiteUrl [String] the URL of the current web page. - * @param isSecured [Boolean] whether the connection is secured (TLS) or not. - */ - @VisibleForTesting - fun createWebsiteInfoState( - websiteUrl: String, - websiteTitle: String, - isSecured: Boolean, - certificateName: String - ): WebsiteInfoState { - val uiValues = if (isSecured) WebsiteSecurityUiValues.SECURE else WebsiteSecurityUiValues.INSECURE - return WebsiteInfoState(websiteUrl, websiteTitle, uiValues, certificateName) - } - /** * Construct an initial [WebsitePermissions * State] to be rendered by [WebsitePermissionsView] @@ -127,6 +122,33 @@ class QuickSettingsFragmentStore( return state } + /** + * Construct an initial [TrackingProtectionState] to be rendered by + * [TrackingProtectionView]. + * + * @param context [Context] used for various Android interactions. + * @param sessionId [String] The current session ID. + * @param websiteUrl [String] the URL of the current web page. + * @param isTrackingProtectionEnabled [Boolean] Current status of tracking protection + * for this session. + */ + @VisibleForTesting + fun createTrackingProtectionState( + context: Context, + sessionId: String, + websiteUrl: String, + isTrackingProtectionEnabled: Boolean + ): TrackingProtectionState { + return TrackingProtectionState( + tab = context.components.core.store.state.findTabOrCustomTab(sessionId), + url = websiteUrl, + isTrackingProtectionEnabled = isTrackingProtectionEnabled, + listTrackers = listOf(), + mode = TrackingProtectionState.Mode.Normal, + lastAccessedCategory = "" + ) + } + /** * [PhoneFeature] to a [WebsitePermission] mapper. */ diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsInteractor.kt index 0b5ea9086e..69ae5ae1e3 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsInteractor.kt @@ -15,7 +15,7 @@ package org.mozilla.fenix.settings.quicksettings */ class QuickSettingsInteractor( private val controller: QuickSettingsController -) : WebsitePermissionInteractor { +) : WebsitePermissionInteractor, TrackingProtectionInteractor, WebSiteInfoInteractor { override fun onPermissionsShown() { controller.handlePermissionsShown() } @@ -27,4 +27,16 @@ class QuickSettingsInteractor( override fun onAutoplayChanged(value: AutoplayValue) { controller.handleAutoplayChanged(value) } + + override fun onTrackingProtectionToggled(isEnabled: Boolean) { + controller.handleTrackingProtectionToggled(isEnabled) + } + + override fun onDetailsClicked() { + controller.handleDetailsClicked() + } + + override fun onConnectionDetailsClicked() { + controller.handleConnectionDetailsClicked() + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsSheetDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsSheetDialogFragment.kt index eb933655c5..1d86c2b00f 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsSheetDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsSheetDialogFragment.kt @@ -4,39 +4,38 @@ package org.mozilla.fenix.settings.quicksettings -import android.app.Dialog import android.content.Intent import android.content.pm.PackageManager.PERMISSION_GRANTED -import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.net.Uri import android.os.Bundle -import android.view.Gravity.BOTTOM import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.LinearLayout -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.appcompat.view.ContextThemeWrapper +import androidx.annotation.VisibleForTesting import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.plus +import mozilla.components.browser.state.selector.findTabOrCustomTab +import mozilla.components.browser.state.state.SessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.lib.state.ext.consumeFlow import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged import org.mozilla.fenix.BuildConfig -import org.mozilla.fenix.HomeActivity -import org.mozilla.fenix.IntentReceiverActivity import org.mozilla.fenix.R +import org.mozilla.fenix.android.FenixDialogFragment import org.mozilla.fenix.databinding.FragmentQuickSettingsDialogSheetBinding import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.settings.PhoneFeature -import com.google.android.material.R as MaterialR /** * Dialog that presents the user with information about @@ -44,17 +43,27 @@ import com.google.android.material.R as MaterialR * - website tracking protection. * - website permission. */ -class QuickSettingsSheetDialogFragment : AppCompatDialogFragment() { +@Suppress("TooManyFunctions") +class QuickSettingsSheetDialogFragment : FenixDialogFragment() { private lateinit var quickSettingsStore: QuickSettingsFragmentStore private lateinit var quickSettingsController: QuickSettingsController private lateinit var websiteInfoView: WebsiteInfoView private lateinit var websitePermissionsView: WebsitePermissionsView + + @VisibleForTesting + internal lateinit var trackingProtectionView: TrackingProtectionView + private lateinit var interactor: QuickSettingsInteractor + private var tryToRequestPermissions: Boolean = false private val args by navArgs() - private lateinit var binding: FragmentQuickSettingsDialogSheetBinding + private var _binding: FragmentQuickSettingsDialogSheetBinding? = null + // This property is only valid between onCreateView and onDestroyView. + private val binding get() = _binding!! + override val gravity: Int get() = args.gravity + override val layoutId: Int = R.layout.fragment_quick_settings_dialog_sheet @Suppress("DEPRECATION") // https://github.com/mozilla-mobile/fenix/issues/19920 @@ -67,7 +76,7 @@ class QuickSettingsSheetDialogFragment : AppCompatDialogFragment() { val components = context.components val rootView = inflateRootView(container) - binding = FragmentQuickSettingsDialogSheetBinding.bind(rootView) + _binding = FragmentQuickSettingsDialogSheetBinding.bind(rootView) quickSettingsStore = QuickSettingsFragmentStore.createStore( context = context, @@ -77,7 +86,9 @@ class QuickSettingsSheetDialogFragment : AppCompatDialogFragment() { permissions = args.sitePermissions, settings = components.settings, certificateName = args.certificateName, - permissionHighlights = args.permissionHighlights + permissionHighlights = args.permissionHighlights, + sessionId = args.sessionId, + isTrackingProtectionEnabled = args.isTrackingProtectionEnabled ) quickSettingsController = DefaultQuickSettingsController( @@ -85,7 +96,7 @@ class QuickSettingsSheetDialogFragment : AppCompatDialogFragment() { quickSettingsStore = quickSettingsStore, browserStore = components.core.store, ioScope = viewLifecycleOwner.lifecycleScope + Dispatchers.IO, - navController = findNavController(), + navController = { findNavController() }, sessionId = args.sessionId, sitePermissions = args.sitePermissions, settings = components.settings, @@ -101,51 +112,32 @@ class QuickSettingsSheetDialogFragment : AppCompatDialogFragment() { ) interactor = QuickSettingsInteractor(quickSettingsController) - - websiteInfoView = WebsiteInfoView(binding.websiteInfoLayout) + websiteInfoView = WebsiteInfoView(binding.websiteInfoLayout, interactor = interactor) websitePermissionsView = WebsitePermissionsView(binding.websitePermissionsLayout, interactor) + trackingProtectionView = + TrackingProtectionView(binding.trackingProtectionLayout, interactor, context.settings()) return rootView } - private fun inflateRootView(container: ViewGroup? = null): View { - val contextThemeWrapper = ContextThemeWrapper( - activity, - (activity as HomeActivity).themeManager.currentThemeResource - ) - return LayoutInflater.from(contextThemeWrapper).inflate( - R.layout.fragment_quick_settings_dialog_sheet, - container, - false - ) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return if (args.gravity == BOTTOM) { - BottomSheetDialog(requireContext(), this.theme).apply { - setOnShowListener { - val bottomSheet = - findViewById(MaterialR.id.design_bottom_sheet) as FrameLayout - val behavior = BottomSheetBehavior.from(bottomSheet) - behavior.state = BottomSheetBehavior.STATE_EXPANDED - } - } - } else { - Dialog(requireContext()).applyCustomizationsForTopDialog(inflateRootView()) - } - } - @ExperimentalCoroutinesApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - + observeTrackersChange(requireComponents.core.store) consumeFrom(quickSettingsStore) { websiteInfoView.update(it.webInfoState) websitePermissionsView.update(it.websitePermissionsState) + trackingProtectionView.update(it.trackingProtectionState) } } + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } + override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, @@ -170,24 +162,6 @@ class QuickSettingsSheetDialogFragment : AppCompatDialogFragment() { tryToRequestPermissions = false } - private fun Dialog.applyCustomizationsForTopDialog(rootView: View): Dialog { - addContentView( - rootView, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT - ) - ) - - window?.apply { - setGravity(args.gravity) - setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - // This must be called after addContentView, or it won't fully fill to the edge. - setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - } - return this - } - private fun arePermissionsGranted(requestCode: Int, grantResults: IntArray) = requestCode == REQUEST_CODE_QUICK_SETTINGS_PERMISSIONS && grantResults.all { it == PERMISSION_GRANTED } @@ -195,14 +169,6 @@ class QuickSettingsSheetDialogFragment : AppCompatDialogFragment() { binding.websitePermissionsGroup.isVisible = true } - private fun launchIntentReceiver() { - context?.let { context -> - val intent = Intent(context, IntentReceiverActivity::class.java) - intent.action = Intent.ACTION_VIEW - context.startActivity(intent) - } - } - private fun openSystemSettings() { startActivity( Intent().apply { @@ -212,6 +178,42 @@ class QuickSettingsSheetDialogFragment : AppCompatDialogFragment() { ) } + @VisibleForTesting + internal fun provideTabId(): String = args.sessionId + + @VisibleForTesting + @ExperimentalCoroutinesApi + internal fun observeTrackersChange(store: BrowserStore) { + consumeFlow(store) { flow -> + flow.mapNotNull { state -> + state.findTabOrCustomTab(provideTabId()) + }.ifAnyChanged { tab -> + arrayOf( + tab.trackingProtection.blockedTrackers, + tab.trackingProtection.loadedTrackers + ) + }.collect { + updateTrackers(it) + } + } + } + + @VisibleForTesting + internal fun updateTrackers(tab: SessionState) { + provideTrackingProtectionUseCases().fetchTrackingLogs( + tab.id, + onSuccess = { trackers -> + trackingProtectionView.updateDetailsSection(trackers.isNotEmpty()) + }, + onError = { + Logger.error("QuickSettingsSheetDialogFragment - fetchTrackingLogs onError", it) + } + ) + } + + @VisibleForTesting + internal fun provideTrackingProtectionUseCases() = requireComponents.useCases.trackingProtectionUseCases + private companion object { const val REQUEST_CODE_QUICK_SETTINGS_PERMISSIONS = 4 } diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/TrackingProtectionView.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/TrackingProtectionView.kt new file mode 100644 index 0000000000..846f16f5cb --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/TrackingProtectionView.kt @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.quicksettings + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.core.view.isVisible +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.QuicksettingsTrackingProtectionBinding +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.trackingprotection.TrackingProtectionState +import org.mozilla.fenix.utils.Settings + +/** + * Contract declaring all possible user interactions with [TrackingProtectionView]. + */ +interface TrackingProtectionInteractor { + + /** + * Called whenever the tracking protection toggle for this site is toggled. + * + * @param isEnabled Whether or not tracking protection is enabled. + */ + fun onTrackingProtectionToggled(isEnabled: Boolean) + + /** + * Navigates to the tracking protection preferences. Called when a user clicks on the + * "Details" button. + */ + fun onDetailsClicked() +} + +/** + * MVI View that displays the tracking protection toggle and navigation to additional tracking + * protection details. + * + * @param containerView [ViewGroup] in which this View will inflate itself. + * @param interactor [TrackingProtectionInteractor] which will have delegated to all user + * @param settings [Settings] application settings. + * interactions. + */ +class TrackingProtectionView( + val containerView: ViewGroup, + val interactor: TrackingProtectionInteractor, + val settings: Settings +) { + private val context = containerView.context + @VisibleForTesting + internal val binding = QuicksettingsTrackingProtectionBinding.inflate( + LayoutInflater.from(containerView.context), + containerView, + true + ) + fun update(state: TrackingProtectionState) { + bindTrackingProtectionInfo(state.isTrackingProtectionEnabled) + binding.root.isVisible = settings.shouldUseTrackingProtection + binding.trackingProtectionDetails.setOnClickListener { + interactor.onDetailsClicked() + } + } + + fun updateDetailsSection(show: Boolean) { + binding.trackingProtectionDetails.isVisible = show + } + + private fun bindTrackingProtectionInfo(isTrackingProtectionEnabled: Boolean) { + binding.trackingProtectionSwitch.trackingProtectionCategoryItemDescription.text = + context.getString(if (isTrackingProtectionEnabled) R.string.etp_panel_on else R.string.etp_panel_off) + binding.trackingProtectionSwitch.switchWidget.isChecked = isTrackingProtectionEnabled + binding.trackingProtectionSwitch.switchWidget.jumpDrawablesToCurrentState() + + binding.trackingProtectionSwitch.switchWidget.setOnCheckedChangeListener { _, isChecked -> + interactor.onTrackingProtectionToggled(isChecked) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebSiteInfoInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebSiteInfoInteractor.kt new file mode 100644 index 0000000000..150fd80530 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebSiteInfoInteractor.kt @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.quicksettings + +/** + * Contract declaring all possible user interactions with [WebsitePermissionsView]. + */ +interface WebSiteInfoInteractor { + /** + * Indicates there are website permissions allowed / blocked for the current website. + * which, status which is shown to the user. + */ + fun onConnectionDetailsClicked() = Unit + + /** + * Called whenever back is pressed. + */ + fun onBackPressed() = Unit +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebsiteInfoView.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebsiteInfoView.kt index 4a50c37830..7d92c8f3c9 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebsiteInfoView.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebsiteInfoView.kt @@ -4,13 +4,15 @@ package org.mozilla.fenix.settings.quicksettings +import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.content.ContextCompat.getColor -import androidx.core.view.isVisible -import mozilla.components.support.ktx.android.content.getDrawableWithTint -import org.mozilla.fenix.R +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl import org.mozilla.fenix.databinding.QuicksettingsWebsiteInfoBinding +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.loadIntoView /** * MVI View that knows to display a whether the current website uses a secure connection or not. @@ -18,14 +20,16 @@ import org.mozilla.fenix.databinding.QuicksettingsWebsiteInfoBinding * Currently it does not support any user interaction. * * @param container [ViewGroup] in which this View will inflate itself. + * @param icons Icons component for loading, caching and processing website icons. + * @param interactor [WebSiteInfoInteractor] which will have delegated to all user interactions. */ class WebsiteInfoView( - container: ViewGroup + container: ViewGroup, + private val icons: BrowserIcons = container.context.components.core.icons, + val interactor: WebSiteInfoInteractor, ) { val binding = QuicksettingsWebsiteInfoBinding.inflate( - LayoutInflater.from(container.context), - container, - true + LayoutInflater.from(container.context), container, true ) /** @@ -34,32 +38,28 @@ class WebsiteInfoView( * @param state [WebsiteInfoState] to be rendered. */ fun update(state: WebsiteInfoState) { + icons.loadIntoView(binding.faviconImage, state.websiteUrl) bindUrl(state.websiteUrl) - bindTitle(state.websiteTitle) bindSecurityInfo(state.websiteSecurityUiValues) - bindCertificateName(state.certificateName) } private fun bindUrl(websiteUrl: String) { - binding.url.text = websiteUrl + binding.url.text = websiteUrl.tryGetHostFromUrl() } - private fun bindTitle(websiteTitle: String) { - binding.title.text = websiteTitle + private fun bindSecurityInfo(uiValues: WebsiteSecurityUiValues) { + binding.securityInfo.setText(uiValues.securityInfoRes) + bindConnectionDetailsListener() + binding.securityInfoIcon.setImageResource(uiValues.iconRes) } - private fun bindCertificateName(cert: String) { - val certificateLabel = - binding.root.context.getString(R.string.certificate_info_verified_by, cert) - binding.certificateInfo.text = certificateLabel - binding.certificateInfo.isVisible = cert.isNotEmpty() + @VisibleForTesting + internal fun bindConnectionDetailsListener() { + binding.securityInfo.setOnClickListener { + interactor.onConnectionDetailsClicked() + } } - private fun bindSecurityInfo(uiValues: WebsiteSecurityUiValues) { - val tint = getColor(binding.root.context, uiValues.iconTintRes) - binding.securityInfo.setText(uiValues.securityInfoRes) - binding.securityInfoIcon.setImageDrawable( - binding.root.context.getDrawableWithTint(uiValues.iconRes, tint) - ) - } + @VisibleForTesting + internal fun provideContext(): Context = binding.root.context } diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/SearchStringValidator.kt b/app/src/main/java/org/mozilla/fenix/settings/search/SearchStringValidator.kt index 9cf97ecb45..baa8c7eb0b 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/search/SearchStringValidator.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/search/SearchStringValidator.kt @@ -28,7 +28,7 @@ object SearchStringValidator { } // read the response stream to ensure the body is closed correctly. workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1603114 - response.body.string() + response.body.close() return if (response.isSuccess || isTestQueryParamNotFound(response.status) ) Result.Success else Result.CannotReach diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/CustomViewHolder.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/CustomViewHolder.kt new file mode 100644 index 0000000000..6c7a2b00a4 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/CustomViewHolder.kt @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.studies + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton + +/** + * A base view holder for Studies. + */ +sealed class CustomViewHolder(view: View) : RecyclerView.ViewHolder(view) { + /** + * A view holder for displaying section items. + */ + class SectionViewHolder( + view: View, + val titleView: TextView, + val divider: View + ) : CustomViewHolder(view) + + /** + * A view holder for displaying study items. + */ + class StudyViewHolder( + view: View, + val titleView: TextView, + val summaryView: TextView, + val deleteButton: MaterialButton, + ) : CustomViewHolder(view) +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt new file mode 100644 index 0000000000..d08a1762d7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt @@ -0,0 +1,189 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.studies + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.button.MaterialButton +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.R +import org.mozilla.fenix.settings.studies.CustomViewHolder.SectionViewHolder +import org.mozilla.fenix.settings.studies.CustomViewHolder.StudyViewHolder + +private const val VIEW_HOLDER_TYPE_SECTION = 0 +private const val VIEW_HOLDER_TYPE_STUDY = 1 + +/** + * An adapter for displaying studies items. This will display information related to the state of + * a study such as active. In addition, it will perform actions such as removing a study. + * + * @property studiesDelegate Delegate that will provides method for handling + * the studies actions items. + * @param studies The list of studies. + * * @property studiesDelegate Delegate that will provides method for handling + * the studies actions items. + * @param shouldSubmitOnInit The sole purpose of this property is to prevent the submitList function + * to run on init, it should only be used from tests. + */ +@Suppress("LargeClass") +class StudiesAdapter( + private val studiesDelegate: StudiesAdapterDelegate, + studies: List, + @VisibleForTesting + internal val shouldSubmitOnInit: Boolean = true +) : ListAdapter(DifferCallback) { + /** + * Represents all the studies that will be distributed in multiple headers like + * active, and completed, this helps to have the data source of the items, + * displayed in the UI. + */ + @VisibleForTesting + internal var studiesMap: MutableMap = + studies.associateBy({ it.slug }, { it }).toMutableMap() + + init { + if (shouldSubmitOnInit) { + submitList(createListWithSections(studies)) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { + return when (viewType) { + VIEW_HOLDER_TYPE_STUDY -> createStudiesViewHolder(parent) + VIEW_HOLDER_TYPE_SECTION -> createSectionViewHolder(parent) + else -> throw IllegalArgumentException("Unrecognized viewType") + } + } + + private fun createSectionViewHolder(parent: ViewGroup): CustomViewHolder { + val context = parent.context + val inflater = LayoutInflater.from(context) + val view = inflater.inflate(R.layout.studies_section_item, parent, false) + val titleView = view.findViewById(R.id.title) + val divider = view.findViewById(R.id.divider) + return SectionViewHolder(view, titleView, divider) + } + + private fun createStudiesViewHolder(parent: ViewGroup): StudyViewHolder { + val context = parent.context + val view = LayoutInflater.from(context).inflate(R.layout.study_item, parent, false) + val titleView = view.findViewById(R.id.studyTitle) + val summaryView = view.findViewById(R.id.study_description) + val removeButton = view.findViewById(R.id.remove_button) + return StudyViewHolder( + view, + titleView, + summaryView, + removeButton + ) + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is EnrolledExperiment -> VIEW_HOLDER_TYPE_STUDY + is Section -> VIEW_HOLDER_TYPE_SECTION + else -> throw IllegalArgumentException("items[position] has unrecognized type") + } + } + + override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { + val item = getItem(position) + + when (holder) { + is SectionViewHolder -> bindSection(holder, item as Section) + is StudyViewHolder -> bindStudy(holder, item as EnrolledExperiment) + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun bindSection(holder: SectionViewHolder, section: Section) { + holder.titleView.setText(section.title) + holder.divider.isVisible = section.visibleDivider + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun bindStudy(holder: StudyViewHolder, study: EnrolledExperiment) { + holder.titleView.text = study.userFacingName + holder.summaryView.text = study.userFacingDescription + + holder.deleteButton.setOnClickListener { + showDeleteDialog(holder.titleView.context, study) + } + } + + @VisibleForTesting + internal fun showDeleteDialog(context: Context, study: EnrolledExperiment): AlertDialog { + val builder = AlertDialog.Builder(context) + .setPositiveButton( + R.string.studies_restart_dialog_ok + ) { dialog, _ -> + studiesDelegate.onRemoveButtonClicked(study) + dialog.dismiss() + } + .setNegativeButton( + R.string.studies_restart_dialog_cancel + ) { dialog: DialogInterface, _ -> + dialog.dismiss() + } + .setTitle(R.string.preference_experiments_2) + .setMessage(R.string.studies_restart_app) + .setCancelable(false) + val alertDialog: AlertDialog = builder.create() + alertDialog.show() + return alertDialog + } + + internal fun createListWithSections(studies: List): List { + val itemsWithSections = ArrayList() + val activeStudies = ArrayList() + + activeStudies.addAll(studies) + + if (activeStudies.isNotEmpty()) { + itemsWithSections.add(Section(R.string.studies_active, true)) + itemsWithSections.addAll(activeStudies) + } + + return itemsWithSections + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal data class Section(@StringRes val title: Int, val visibleDivider: Boolean = true) + + /** + * Removes the portion of the list that contains the provided [study]. + * @property study The study to be removed. + */ + fun removeStudy(study: EnrolledExperiment) { + studiesMap.remove(study.slug) + submitList(createListWithSections(studiesMap.values.toList())) + } + + internal object DifferCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean { + return when { + oldItem is EnrolledExperiment && newItem is EnrolledExperiment -> oldItem.slug == newItem.slug + oldItem is Section && newItem is Section -> oldItem.title == newItem.title + else -> false + } + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapterDelegate.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapterDelegate.kt new file mode 100644 index 0000000000..86ef6a4948 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapterDelegate.kt @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.studies + +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment + +/** + * Provides methods for handling the studies items. + */ +interface StudiesAdapterDelegate { + /** + * Handler for when the remove button is clicked. + * + * @param experiment The [EnrolledExperiment] to remove. + */ + fun onRemoveButtonClicked(experiment: EnrolledExperiment) = Unit +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesFragment.kt new file mode 100644 index 0000000000..1a168f7418 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesFragment.kt @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.studies + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.databinding.SettingsStudiesBinding +import org.mozilla.fenix.ext.metrics +import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.settings + +/** + * Lets the users control studies settings. + */ +class StudiesFragment : Fragment() { + private var _binding: SettingsStudiesBinding? = null + // This property is only valid between onCreateView and + // onDestroyView. + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val experiments = requireComponents.analytics.experiments + _binding = SettingsStudiesBinding.inflate(inflater, container, false) + val interactor = DefaultStudiesInteractor((activity as HomeActivity), experiments) + StudiesView( + lifecycleScope, + requireContext(), + binding, + interactor, + requireContext().settings(), + experiments, + ::isAttached, + requireContext().metrics + ).bind() + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun isAttached(): Boolean = context != null +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt new file mode 100644 index 0000000000..0a0d6e0af4 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.studies + +import androidx.annotation.VisibleForTesting +import mozilla.components.service.nimbus.NimbusApi +import org.mozilla.experiments.nimbus.NimbusInterface +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity +import kotlin.system.exitProcess + +interface StudiesInteractor { + /** + * Open the given [url] in the browser. + */ + fun openWebsite(url: String) + + /** + * Remove a study by the given [experiment]. + */ + fun removeStudy(experiment: EnrolledExperiment) +} + +class DefaultStudiesInteractor( + private val homeActivity: HomeActivity, + private val experiments: NimbusApi, +) : StudiesInteractor { + override fun openWebsite(url: String) { + homeActivity.openToBrowserAndLoad( + searchTermOrURL = url, + newTab = true, + from = BrowserDirection.FromStudiesFragment + ) + } + + override fun removeStudy(experiment: EnrolledExperiment) { + experiments.register(object : NimbusInterface.Observer { + override fun onUpdatesApplied(updated: List) { + // Wait until the experiment is unrolled from nimbus to restart. + killApplication() + } + }) + experiments.optOut(experiment.slug) + experiments.applyPendingExperiments() + } + + @VisibleForTesting + internal fun killApplication() { + exitProcess(0) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesView.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesView.kt new file mode 100644 index 0000000000..b5ac2a38fd --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesView.kt @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.studies + +import android.content.Context +import android.text.SpannableStringBuilder +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.view.View +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SwitchCompat +import androidx.core.text.HtmlCompat +import androidx.core.text.getSpans +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.service.nimbus.NimbusApi +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.databinding.SettingsStudiesBinding +import org.mozilla.fenix.ext.getPreferenceKey +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.settings.SupportUtils +import org.mozilla.fenix.settings.SupportUtils.SumoTopic.OPT_OUT_STUDIES +import org.mozilla.fenix.utils.Settings +import kotlin.system.exitProcess + +@Suppress("LongParameterList") +class StudiesView( + private val scope: CoroutineScope, + private val context: Context, + private val binding: SettingsStudiesBinding, + private val interactor: StudiesInteractor, + private val settings: Settings, + private val experiments: NimbusApi, + private val isAttached: () -> Boolean, + private val metrics: MetricController +) : StudiesAdapterDelegate { + private val logger = Logger("StudiesView") + + @VisibleForTesting + internal lateinit var adapter: StudiesAdapter + + @Suppress("TooGenericExceptionCaught", "ApplySharedPref") + fun bind() { + provideStudiesTitle().text = getSwitchTitle() + provideStudiesSwitch().isChecked = settings.isExperimentationEnabled + provideStudiesSwitch().setOnClickListener { + val isChecked = provideStudiesSwitch().isChecked + metrics.track(Event.StudiesSettings) + provideStudiesTitle().text = getSwitchCheckedTitle() + val builder = AlertDialog.Builder(context) + .setPositiveButton( + R.string.studies_restart_dialog_ok + ) { dialog, _ -> + settings.isExperimentationEnabled = isChecked + val experimentsKey = context.getPreferenceKey(R.string.pref_key_experimentation) + // In this case, we are using commit() on purpose as we want to warranty + // that we are changing the setting before quitting the app. + context.settings().preferences.edit().putBoolean(experimentsKey, isChecked) + .commit() + + experiments.globalUserParticipation = isChecked + dialog.dismiss() + quitTheApp() + } + .setNegativeButton( + R.string.studies_restart_dialog_cancel + ) { dialog, _ -> + provideStudiesSwitch().isChecked = !isChecked + provideStudiesTitle().text = getSwitchTitle() + dialog.dismiss() + } + .setTitle(R.string.preference_experiments_2) + .setMessage(R.string.studies_restart_app) + .setCancelable(false) + val alertDialog: AlertDialog = builder.create() + alertDialog.show() + } + bindDescription() + + scope.launch(Dispatchers.IO) { + try { + val experiments = experiments.getActiveExperiments() + scope.launch(Dispatchers.Main) { + if (isAttached()) { + adapter = StudiesAdapter( + this@StudiesView, + experiments + ) + provideStudiesList().adapter = adapter + } + } + } catch (e: Throwable) { + logger.error("Failed to getActiveExperiments()", e) + } + } + } + + override fun onRemoveButtonClicked(experiment: EnrolledExperiment) { + interactor.removeStudy(experiment) + adapter.removeStudy(experiment) + } + + @VisibleForTesting + internal fun bindDescription() { + val sumoUrl = SupportUtils.getSumoURLForTopic(context, OPT_OUT_STUDIES) + val description = context.getString(R.string.studies_description) + val learnMore = context.getString(R.string.studies_learn_more) + val rawText = "$description $learnMore" + val text = HtmlCompat.fromHtml(rawText, HtmlCompat.FROM_HTML_MODE_COMPACT) + + val spannableStringBuilder = SpannableStringBuilder(text) + val links = spannableStringBuilder.getSpans() + for (link in links) { + addActionToLinks(spannableStringBuilder, link) + } + binding.studiesDescription.text = spannableStringBuilder + binding.studiesDescription.movementMethod = LinkMovementMethod.getInstance() + } + + private fun addActionToLinks( + spannableStringBuilder: SpannableStringBuilder, + link: URLSpan + ) { + val start = spannableStringBuilder.getSpanStart(link) + val end = spannableStringBuilder.getSpanEnd(link) + val flags = spannableStringBuilder.getSpanFlags(link) + val clickable: ClickableSpan = object : ClickableSpan() { + override fun onClick(view: View) { + view.setOnClickListener { + interactor.openWebsite(link.url) + } + } + } + spannableStringBuilder.setSpan(clickable, start, end, flags) + spannableStringBuilder.removeSpan(link) + } + + @VisibleForTesting + internal fun getSwitchTitle(): String { + val stringId = if (settings.isExperimentationEnabled) { + R.string.studies_on + } else { + R.string.studies_off + } + return context.getString(stringId) + } + + @VisibleForTesting + internal fun getSwitchCheckedTitle(): String { + val stringId = if (provideStudiesSwitch().isChecked) { + R.string.studies_on + } else { + R.string.studies_off + } + return context.getString(stringId) + } + + @VisibleForTesting + internal fun provideStudiesTitle(): TextView = binding.studiesTitle + + @VisibleForTesting + internal fun provideStudiesSwitch(): SwitchCompat = binding.studiesSwitch + + @VisibleForTesting + internal fun provideStudiesList(): RecyclerView = binding.studiesList + + @VisibleForTesting + internal fun quitTheApp() { + exitProcess(0) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/share/AddNewDeviceFragment.kt b/app/src/main/java/org/mozilla/fenix/share/AddNewDeviceFragment.kt index 17c0db0f01..bf91328750 100644 --- a/app/src/main/java/org/mozilla/fenix/share/AddNewDeviceFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/share/AddNewDeviceFragment.kt @@ -8,10 +8,10 @@ import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment -import kotlinx.android.synthetic.main.fragment_add_new_device.* import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentAddNewDeviceBinding import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.SupportUtils @@ -27,7 +27,9 @@ class AddNewDeviceFragment : Fragment(R.layout.fragment_add_new_device) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - learn_button.setOnClickListener { + + val binding = FragmentAddNewDeviceBinding.bind(view) + binding.learnButton.setOnClickListener { (activity as HomeActivity).openToBrowserAndLoad( searchTermOrURL = SupportUtils.getSumoURLForTopic( requireContext(), @@ -38,7 +40,7 @@ class AddNewDeviceFragment : Fragment(R.layout.fragment_add_new_device) { ) } - connect_button.setOnClickListener { + binding.connectButton.setOnClickListener { AlertDialog.Builder(requireContext()).apply { setMessage(R.string.sync_connect_device_dialog) setPositiveButton(R.string.sync_confirmation_button) { dialog, _ -> dialog.cancel() } diff --git a/app/src/main/java/org/mozilla/fenix/share/ShareCloseView.kt b/app/src/main/java/org/mozilla/fenix/share/ShareCloseView.kt index 6c70939ec3..719035beaf 100644 --- a/app/src/main/java/org/mozilla/fenix/share/ShareCloseView.kt +++ b/app/src/main/java/org/mozilla/fenix/share/ShareCloseView.kt @@ -7,10 +7,8 @@ package org.mozilla.fenix.share import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.share_close.* import mozilla.components.concept.engine.prompt.ShareData -import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ShareCloseBinding import org.mozilla.fenix.share.listadapters.ShareTabsAdapter /** @@ -21,20 +19,23 @@ interface ShareCloseInteractor { } class ShareCloseView( - override val containerView: ViewGroup, - private val interactor: ShareCloseInteractor -) : LayoutContainer { + val containerView: ViewGroup, + private val interactor: ShareCloseInteractor, +) { val adapter = ShareTabsAdapter() init { - LayoutInflater.from(containerView.context) - .inflate(R.layout.share_close, containerView, true) + val binding = ShareCloseBinding.inflate( + LayoutInflater.from(containerView.context), + containerView, + true + ) - closeButton.setOnClickListener { interactor.onShareClosed() } + binding.closeButton.setOnClickListener { interactor.onShareClosed() } - shared_site_list.layoutManager = LinearLayoutManager(containerView.context) - shared_site_list.adapter = adapter + binding.sharedSiteList.layoutManager = LinearLayoutManager(containerView.context) + binding.sharedSiteList.adapter = adapter } fun setTabs(tabs: List) { diff --git a/app/src/main/java/org/mozilla/fenix/share/ShareFragment.kt b/app/src/main/java/org/mozilla/fenix/share/ShareFragment.kt index 8920808889..51f9fadf77 100644 --- a/app/src/main/java/org/mozilla/fenix/share/ShareFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/share/ShareFragment.kt @@ -16,7 +16,6 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_share.view.* import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.concept.engine.prompt.PromptRequest @@ -24,6 +23,7 @@ import mozilla.components.feature.accounts.push.SendTabUseCases import mozilla.components.feature.share.RecentAppsStorage import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar +import org.mozilla.fenix.databinding.FragmentShareBinding import org.mozilla.fenix.ext.getRootView import org.mozilla.fenix.ext.requireComponents @@ -58,8 +58,12 @@ class ShareFragment : AppCompatDialogFragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_share, container, false) + ): View { + val binding = FragmentShareBinding.inflate( + inflater, + container, + false + ) val shareData = args.data.toList() val accountManager = requireComponents.backgroundServices.accountManager @@ -89,24 +93,24 @@ class ShareFragment : AppCompatDialogFragment() { } ) - view.shareWrapper.setOnClickListener { shareInteractor.onShareClosed() } + binding.shareWrapper.setOnClickListener { shareInteractor.onShareClosed() } shareToAccountDevicesView = - ShareToAccountDevicesView(view.devicesShareLayout, shareInteractor) + ShareToAccountDevicesView(binding.devicesShareLayout, shareInteractor) if (args.showPage) { // Show the previous fragment underneath the share background scrim // by making it translucent. - view.closeSharingScrim.alpha = SHOW_PAGE_ALPHA - view.shareWrapper.setOnClickListener { shareInteractor.onShareClosed() } + binding.closeSharingScrim.alpha = SHOW_PAGE_ALPHA + binding.shareWrapper.setOnClickListener { shareInteractor.onShareClosed() } } else { // Otherwise, show a list of tabs to share. - view.closeSharingScrim.alpha = 1.0f - shareCloseView = ShareCloseView(view.closeSharingContent, shareInteractor) + binding.closeSharingScrim.alpha = 1.0f + shareCloseView = ShareCloseView(binding.closeSharingContent, shareInteractor) shareCloseView.setTabs(shareData) } - shareToAppsView = ShareToAppsView(view.appsShareLayout, shareInteractor) + shareToAppsView = ShareToAppsView(binding.appsShareLayout, shareInteractor) - return view + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/mozilla/fenix/share/ShareToAccountDevicesView.kt b/app/src/main/java/org/mozilla/fenix/share/ShareToAccountDevicesView.kt index acf2120eef..fe8cd1efb6 100644 --- a/app/src/main/java/org/mozilla/fenix/share/ShareToAccountDevicesView.kt +++ b/app/src/main/java/org/mozilla/fenix/share/ShareToAccountDevicesView.kt @@ -6,10 +6,8 @@ package org.mozilla.fenix.share import android.view.LayoutInflater import android.view.ViewGroup -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.share_to_account_devices.* import mozilla.components.concept.sync.Device -import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ShareToAccountDevicesBinding import org.mozilla.fenix.share.listadapters.AccountDevicesShareAdapter import org.mozilla.fenix.share.listadapters.SyncShareOption @@ -25,17 +23,20 @@ interface ShareToAccountDevicesInteractor { } class ShareToAccountDevicesView( - override val containerView: ViewGroup, + containerView: ViewGroup, interactor: ShareToAccountDevicesInteractor -) : LayoutContainer { +) { private val adapter = AccountDevicesShareAdapter(interactor) init { - LayoutInflater.from(containerView.context) - .inflate(R.layout.share_to_account_devices, containerView, true) + val binding = ShareToAccountDevicesBinding.inflate( + LayoutInflater.from(containerView.context), + containerView, + true + ) - devicesList.adapter = adapter + binding.devicesList.adapter = adapter } fun setShareTargets(targets: List) { diff --git a/app/src/main/java/org/mozilla/fenix/share/ShareToAppsView.kt b/app/src/main/java/org/mozilla/fenix/share/ShareToAppsView.kt index 93c7c7bc63..4556a83dff 100644 --- a/app/src/main/java/org/mozilla/fenix/share/ShareToAppsView.kt +++ b/app/src/main/java/org/mozilla/fenix/share/ShareToAppsView.kt @@ -7,9 +7,7 @@ package org.mozilla.fenix.share import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.share_to_apps.* -import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ShareToAppsBinding import org.mozilla.fenix.share.listadapters.AppShareAdapter import org.mozilla.fenix.share.listadapters.AppShareOption @@ -21,36 +19,38 @@ interface ShareToAppsInteractor { } class ShareToAppsView( - override val containerView: ViewGroup, + containerView: ViewGroup, interactor: ShareToAppsInteractor -) : LayoutContainer { +) { private val adapter = AppShareAdapter(interactor) private val recentAdapter = AppShareAdapter(interactor) + private var binding: ShareToAppsBinding = ShareToAppsBinding.inflate( + LayoutInflater.from(containerView.context), + containerView, + true + ) init { - LayoutInflater.from(containerView.context) - .inflate(R.layout.share_to_apps, containerView, true) - - appsList.adapter = adapter - recentAppsList.adapter = recentAdapter + binding.appsList.adapter = adapter + binding.recentAppsList.adapter = recentAdapter } fun setShareTargets(targets: List) { - progressBar.visibility = View.GONE + binding.progressBar.visibility = View.GONE - appsList.visibility = View.VISIBLE + binding.appsList.visibility = View.VISIBLE adapter.submitList(targets) } fun setRecentShareTargets(recentTargets: List) { if (recentTargets.isEmpty()) { - recentAppsContainer.visibility = View.GONE + binding.recentAppsContainer.visibility = View.GONE return } - progressBar.visibility = View.GONE + binding.progressBar.visibility = View.GONE - recentAppsContainer.visibility = View.VISIBLE + binding.recentAppsContainer.visibility = View.VISIBLE recentAdapter.submitList(recentTargets) } } diff --git a/app/src/main/java/org/mozilla/fenix/shortcut/CreateShortcutFragment.kt b/app/src/main/java/org/mozilla/fenix/shortcut/CreateShortcutFragment.kt index c7289d93ec..d9b9df3dc1 100644 --- a/app/src/main/java/org/mozilla/fenix/shortcut/CreateShortcutFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/shortcut/CreateShortcutFragment.kt @@ -11,14 +11,17 @@ import android.view.ViewGroup import androidx.core.widget.addTextChangedListener import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope -import kotlinx.android.synthetic.main.fragment_create_shortcut.* import kotlinx.coroutines.launch import mozilla.components.browser.state.selector.selectedTab import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentCreateShortcutBinding import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.ext.requireComponents class CreateShortcutFragment : DialogFragment() { + private var _binding: FragmentCreateShortcutBinding? = null + private val binding get() = _binding!! + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NO_TITLE, R.style.CreateShortcutDialogStyle) @@ -28,7 +31,11 @@ class CreateShortcutFragment : DialogFragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? = inflater.inflate(R.layout.fragment_create_shortcut, container, false) + ): View { + _binding = FragmentCreateShortcutBinding.inflate(inflater, container, false) + + return binding.root + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -38,29 +45,35 @@ class CreateShortcutFragment : DialogFragment() { if (tab == null) { dismiss() } else { - requireComponents.core.icons.loadIntoView(favicon_image, tab.content.url) + requireComponents.core.icons.loadIntoView(binding.faviconImage, tab.content.url) - cancel_button.setOnClickListener { dismiss() } - add_button.setOnClickListener { - val text = shortcut_text.text.toString().trim() + binding.cancelButton.setOnClickListener { dismiss() } + binding.addButton.setOnClickListener { + val text = binding.shortcutText.text.toString().trim() requireActivity().lifecycleScope.launch { requireComponents.useCases.webAppUseCases.addToHomescreen(text) } dismiss() } - shortcut_text.addTextChangedListener { + binding.shortcutText.addTextChangedListener { updateAddButtonEnabledState() } - shortcut_text.setText(tab.content.title) + binding.shortcutText.setText(tab.content.title) } } + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + } + private fun updateAddButtonEnabledState() { - val text = shortcut_text.text - add_button.isEnabled = text.isNotBlank() - add_button.alpha = if (text.isNotBlank()) ENABLED_ALPHA else DISABLED_ALPHA + val text = binding.shortcutText.text + binding.addButton.isEnabled = text.isNotBlank() + binding.addButton.alpha = if (text.isNotBlank()) ENABLED_ALPHA else DISABLED_ALPHA } companion object { diff --git a/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingDialogFragment.kt index 05d8520e3e..952751fa25 100644 --- a/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingDialogFragment.kt @@ -10,9 +10,9 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope -import kotlinx.android.synthetic.main.fragment_create_shortcut.* import kotlinx.coroutines.launch import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentPwaOnboardingBinding import org.mozilla.fenix.ext.requireComponents /** @@ -33,9 +33,10 @@ class PwaOnboardingDialogFragment : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val components = requireComponents + val binding = FragmentPwaOnboardingBinding.bind(view) - cancel_button.setOnClickListener { dismiss() } - add_button.setOnClickListener { + binding.cancelButton.setOnClickListener { dismiss() } + binding.addButton.setOnClickListener { viewLifecycleOwner.lifecycleScope.launch { components.useCases.webAppUseCases.addToHomescreen() }.invokeOnCompletion { diff --git a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsViewHolder.kt b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsViewHolder.kt index e924b1e5ce..bf57fb1d43 100644 --- a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsViewHolder.kt @@ -9,15 +9,16 @@ import android.view.View.GONE import android.view.View.VISIBLE import android.view.animation.Animation import android.view.animation.AnimationUtils +import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.sync_tabs_error_row.view.* -import kotlinx.android.synthetic.main.sync_tabs_list_item.view.* -import kotlinx.android.synthetic.main.view_synced_tabs_group.view.* -import kotlinx.android.synthetic.main.view_synced_tabs_title.view.* import mozilla.components.browser.toolbar.MAX_URI_LENGTH import mozilla.components.feature.syncedtabs.view.SyncedTabsView import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.SyncTabsErrorRowBinding +import org.mozilla.fenix.databinding.SyncTabsListItemBinding +import org.mozilla.fenix.databinding.ViewSyncedTabsGroupBinding +import org.mozilla.fenix.databinding.ViewSyncedTabsTitleBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.sync.SyncedTabsAdapter.AdapterItem @@ -31,7 +32,6 @@ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(item abstract fun bind(item: T, interactor: SyncedTabsView.Listener) class TabViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { - override fun bind(item: T, interactor: SyncedTabsView.Listener) { bindTab(item as AdapterItem.Tab) @@ -42,8 +42,9 @@ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(item private fun bindTab(tab: AdapterItem.Tab) { val active = tab.tab.active() - itemView.synced_tab_item_title.text = active.title - itemView.synced_tab_item_url.text = active.url + val binding = SyncTabsListItemBinding.bind(itemView) + binding.syncedTabItemTitle.text = active.title + binding.syncedTabItemUrl.text = active.url .toShortUrl(itemView.context.components.publicSuffixList) .take(MAX_URI_LENGTH) } @@ -54,17 +55,17 @@ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(item } class ErrorViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { - override fun bind(item: T, interactor: SyncedTabsView.Listener) { val errorItem = item as AdapterItem.Error + val binding = SyncTabsErrorRowBinding.bind(itemView) - itemView.sync_tabs_error_description.text = + binding.syncTabsErrorDescription.text = itemView.context.getString(errorItem.descriptionResId) - itemView.sync_tabs_error_cta_button.visibility = GONE + binding.syncTabsErrorCtaButton.visibility = GONE errorItem.navController?.let { navController -> - itemView.sync_tabs_error_cta_button.visibility = VISIBLE - itemView.sync_tabs_error_cta_button.setOnClickListener { + binding.syncTabsErrorCtaButton.visibility = VISIBLE + binding.syncTabsErrorCtaButton.setOnClickListener { navController.navigate(NavGraphDirections.actionGlobalTurnOnSync()) } } @@ -77,12 +78,15 @@ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(item class DeviceViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { + @VisibleForTesting + internal val binding = ViewSyncedTabsGroupBinding.bind(itemView) + override fun bind(item: T, interactor: SyncedTabsView.Listener) { bindHeader(item as AdapterItem.Device) } private fun bindHeader(device: AdapterItem.Device) { - itemView.synced_tabs_group_name.text = device.device.displayName + binding.syncedTabsGroupName.text = device.device.displayName } companion object { @@ -99,9 +103,9 @@ sealed class SyncedTabsViewHolder(itemView: View) : RecyclerView.ViewHolder(item } class TitleViewHolder(itemView: View) : SyncedTabsViewHolder(itemView) { - override fun bind(item: T, interactor: SyncedTabsView.Listener) { - itemView.refresh_icon.setOnClickListener { v -> + val binding = ViewSyncedTabsTitleBinding.bind(itemView) + binding.refreshIcon.setOnClickListener { v -> val rotation = AnimationUtils.loadAnimation( itemView.context, R.anim.full_rotation diff --git a/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryDialogFragment.kt index eece98ceab..f8b3cb2786 100644 --- a/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryDialogFragment.kt @@ -12,7 +12,6 @@ import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import kotlinx.android.synthetic.main.fragment_tab_history_dialog.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.mapNotNull @@ -21,6 +20,7 @@ import mozilla.components.lib.state.ext.flowScoped import mozilla.components.support.ktx.android.content.getColorFromAttr import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentTabHistoryDialogBinding import org.mozilla.fenix.ext.requireComponents class TabHistoryDialogFragment : BottomSheetDialogFragment() { @@ -37,6 +37,8 @@ class TabHistoryDialogFragment : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val binding = FragmentTabHistoryDialogBinding.bind(view) + view.setBackgroundColor(view.context.getColorFromAttr(R.attr.foundation)) customTabSessionId = requireArguments().getString(EXTRA_SESSION_ID) @@ -47,7 +49,7 @@ class TabHistoryDialogFragment : BottomSheetDialogFragment() { customTabId = customTabSessionId ) val tabHistoryView = TabHistoryView( - container = tabHistoryLayout, + container = binding.tabHistoryLayout, expandDialog = ::expand, interactor = TabHistoryInteractor(controller) ) diff --git a/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryView.kt b/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryView.kt index 789db3d600..87f710e56e 100644 --- a/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryView.kt +++ b/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryView.kt @@ -5,14 +5,11 @@ package org.mozilla.fenix.tabhistory import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.component_tabhistory.* import mozilla.components.browser.state.state.content.HistoryState -import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ComponentTabhistoryBinding interface TabHistoryViewInteractor { @@ -26,13 +23,12 @@ class TabHistoryView( container: ViewGroup, private val expandDialog: () -> Unit, interactor: TabHistoryViewInteractor -) : LayoutContainer { +) { - override val containerView: View = LayoutInflater.from(container.context) - .inflate(R.layout.component_tabhistory, container, true) + private val binding = ComponentTabhistoryBinding.inflate(LayoutInflater.from(container.context), container, true) private val adapter = TabHistoryAdapter(interactor) - private val layoutManager = object : LinearLayoutManager(containerView.context) { + private val layoutManager = object : LinearLayoutManager(container.context) { private var shouldScrollToSelected = true @@ -45,10 +41,10 @@ class TabHistoryView( // Force expansion of the dialog, otherwise scrolling to the current history item // won't work when its position is near the bottom of the recyclerview. expandDialog.invoke() - val itemView = tabHistoryRecyclerView.findViewHolderForLayoutPosition( + val itemView = binding.tabHistoryRecyclerView.findViewHolderForLayoutPosition( findFirstCompletelyVisibleItemPosition() )?.itemView - val offset = tabHistoryRecyclerView.height / 2 - (itemView?.height ?: 0) / 2 + val offset = binding.tabHistoryRecyclerView.height / 2 - (itemView?.height ?: 0) / 2 scrollToPositionWithOffset(index, offset) shouldScrollToSelected = false } @@ -61,8 +57,8 @@ class TabHistoryView( private var currentIndex: Int? = null init { - tabHistoryRecyclerView.adapter = adapter - tabHistoryRecyclerView.layoutManager = layoutManager + binding.tabHistoryRecyclerView.adapter = adapter + binding.tabHistoryRecyclerView.layoutManager = layoutManager } fun updateState(historyState: HistoryState) { diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayController.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayController.kt index d55d1cf2b4..7c6c388ef0 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayController.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayController.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix.tabstray import androidx.annotation.VisibleForTesting import androidx.navigation.NavController import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.browser.state.action.DebugAction import mozilla.components.browser.state.action.LastAccessAction import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.selector.getNormalOrPrivateTabs @@ -15,6 +16,7 @@ import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.base.profiler.Profiler import mozilla.components.concept.tabstray.Tab import mozilla.components.feature.tabs.TabsUseCases +import mozilla.components.lib.state.DelicateAction import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager @@ -22,6 +24,7 @@ import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.home.HomeFragment import org.mozilla.fenix.tabstray.browser.DEFAULT_ACTIVE_DAYS +import org.mozilla.fenix.tabstray.ext.inactiveTabs import java.util.concurrent.TimeUnit interface TabsTrayController { @@ -74,6 +77,11 @@ interface TabsTrayController { tabs: Collection, numOfDays: Long = DEFAULT_ACTIVE_DAYS + 1 ) + + /** + * Deletes all inactive tabs. + */ + fun handleDeleteAllInactiveTabs() } class DefaultTabsTrayController( @@ -172,18 +180,24 @@ class DefaultTabsTrayController( dismissTray() navController.navigate(R.id.recentlyClosedFragment) + + metrics.track(Event.TabsTrayRecentlyClosedPressed) } /** - * Marks all the [tabs] with the [TabSessionState.lastAccess] to 5 days; enough time to + * Marks all the [tabs] with the [TabSessionState.lastAccess] to 15 days; enough time to * have a tab considered as inactive. * * ⚠️ DO NOT USE THIS OUTSIDE OF DEBUGGING/TESTING. */ + @OptIn(DelicateAction::class) override fun forceTabsAsInactive(tabs: Collection, numOfDays: Long) { tabs.forEach { tab -> val daysSince = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(numOfDays) - browserStore.dispatch(LastAccessAction.UpdateLastAccessAction(tab.id, daysSince)) + browserStore.apply { + dispatch(LastAccessAction.UpdateLastAccessAction(tab.id, daysSince)) + dispatch(DebugAction.UpdateCreatedAtAction(tab.id, daysSince)) + } } } @@ -203,4 +217,12 @@ class DefaultTabsTrayController( dismissTray() navigateToHomeAndDeleteSession(sessionId) } + + override fun handleDeleteAllInactiveTabs() { + metrics.track(Event.TabsTrayCloseAllInactiveTabs) + browserStore.state.inactiveTabs.map { it.id }.let { + tabsUseCases.removeTabs(it) + } + showUndoSnackbarForTab(false) + } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt index 2e4752aba8..2f216d2576 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -21,12 +21,6 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.tabs.TabLayout -import kotlinx.android.synthetic.main.component_tabstray2.* -import kotlinx.android.synthetic.main.component_tabstray2.view.* -import kotlinx.android.synthetic.main.component_tabstray_fab.* -import kotlinx.android.synthetic.main.fragment_tab_tray_dialog.* -import kotlinx.android.synthetic.main.tabs_tray_tab_counter2.* -import kotlinx.android.synthetic.main.tabstray_multiselect_items.* import kotlinx.coroutines.Dispatchers import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.state.selector.normalTabs @@ -37,13 +31,18 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar -import org.mozilla.fenix.share.ShareFragment import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.ComponentTabstray2Binding +import org.mozilla.fenix.databinding.ComponentTabstrayFabBinding +import org.mozilla.fenix.databinding.FragmentTabTrayDialogBinding +import org.mozilla.fenix.databinding.TabsTrayTabCounter2Binding +import org.mozilla.fenix.databinding.TabstrayMultiselectItemsBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings import org.mozilla.fenix.home.HomeScreenViewModel +import org.mozilla.fenix.share.ShareFragment import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor import org.mozilla.fenix.tabstray.browser.DefaultBrowserTrayInteractor import org.mozilla.fenix.tabstray.browser.SelectionBannerBinding @@ -60,7 +59,6 @@ import kotlin.math.max @Suppress("TooManyFunctions", "LargeClass") class TabsTrayFragment : AppCompatDialogFragment() { - private var fabView: View? = null @VisibleForTesting internal lateinit var tabsTrayStore: TabsTrayStore private lateinit var browserTrayInteractor: BrowserTrayInteractor private lateinit var tabsTrayInteractor: TabsTrayInteractor @@ -74,6 +72,17 @@ class TabsTrayFragment : AppCompatDialogFragment() { private val selectionHandleBinding = ViewBoundFeatureWrapper() private val tabsTrayCtaBinding = ViewBoundFeatureWrapper() private val secureTabsTrayBinding = ViewBoundFeatureWrapper() + private val tabsTrayInactiveTabsOnboardingBinding = ViewBoundFeatureWrapper() + + @VisibleForTesting @Suppress("VariableNaming") + internal var _tabsTrayBinding: ComponentTabstray2Binding? = null + private val tabsTrayBinding get() = _tabsTrayBinding!! + @VisibleForTesting @Suppress("VariableNaming") + internal var _tabsTrayDialogBinding: FragmentTabTrayDialogBinding? = null + private val tabsTrayDialogBinding get() = _tabsTrayDialogBinding!! + @VisibleForTesting @Suppress("VariableNaming") + internal var _fabButtonBinding: ComponentTabstrayFabBinding? = null + private val fabButtonBinding get() = _fabButtonBinding!! override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -88,8 +97,21 @@ class TabsTrayFragment : AppCompatDialogFragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - val containerView = inflater.inflate(R.layout.fragment_tab_tray_dialog, container, false) - inflater.inflate(R.layout.component_tabstray2, containerView as ViewGroup, true) + _tabsTrayDialogBinding = FragmentTabTrayDialogBinding.inflate( + inflater, + container, + false + ) + _tabsTrayBinding = ComponentTabstray2Binding.inflate( + inflater, + tabsTrayDialogBinding.root, + true + ) + _fabButtonBinding = ComponentTabstrayFabBinding.inflate( + LayoutInflater.from(tabsTrayDialogBinding.root.context), + tabsTrayDialogBinding.root, + true + ) val args by navArgs() val initialMode = if (args.enterMultiselect) { @@ -106,10 +128,14 @@ class TabsTrayFragment : AppCompatDialogFragment() { ) } - fabView = LayoutInflater.from(containerView.context) - .inflate(R.layout.component_tabstray_fab, containerView, true) + return tabsTrayDialogBinding.root + } - return containerView + override fun onDestroyView() { + super.onDestroyView() + _tabsTrayBinding = null + _tabsTrayDialogBinding = null + _fabButtonBinding = null } @Suppress("LongMethod") @@ -118,7 +144,8 @@ class TabsTrayFragment : AppCompatDialogFragment() { val activity = activity as HomeActivity if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - new_tab_button.accessibilityTraversalAfter = tab_layout.id + fabButtonBinding.newTabButton.accessibilityTraversalAfter = + tabsTrayBinding.tabLayout.id } requireComponents.analytics.metrics.track(Event.TabsTrayOpened) @@ -165,7 +192,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { requireComponents.analytics.metrics ) - setupMenu(view, navigationInteractor) + setupMenu(navigationInteractor) setupPager( view.context, tabsTrayStore, @@ -180,7 +207,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { } trayBehaviorManager = TabSheetBehaviorManager( - behavior = BottomSheetBehavior.from(view.tab_wrapper), + behavior = BottomSheetBehavior.from(tabsTrayBinding.tabWrapper), orientation = resources.configuration.orientation, maxNumberOfTabs = max( requireContext().components.core.store.state.normalTabs.size, @@ -199,7 +226,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { feature = TabsTrayInfoBannerBinding( context = view.context, store = requireComponents.core.store, - infoBannerView = view.info_banner, + infoBannerView = tabsTrayBinding.infoBanner, settings = requireComponents.settings, navigationInteractor = navigationInteractor ), @@ -209,7 +236,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { tabLayoutMediator.set( feature = TabLayoutMediator( - tabLayout = tab_layout, + tabLayout = tabsTrayBinding.tabLayout, interactor = tabsTrayInteractor, browsingModeManager = activity.browsingModeManager, tabsTrayStore = tabsTrayStore, @@ -219,10 +246,14 @@ class TabsTrayFragment : AppCompatDialogFragment() { view = view ) + val tabsTrayTabCounter2Binding = TabsTrayTabCounter2Binding.bind( + tabsTrayBinding.tabLayout + ) + tabCounterBinding.set( feature = TabCounterBinding( store = requireComponents.core.store, - counter = tab_counter + counter = tabsTrayTabCounter2Binding.tabCounter ), owner = this, view = view @@ -231,32 +262,37 @@ class TabsTrayFragment : AppCompatDialogFragment() { floatingActionButtonBinding.set( feature = FloatingActionButtonBinding( store = tabsTrayStore, - actionButton = new_tab_button, + actionButton = fabButtonBinding.newTabButton, browserTrayInteractor = browserTrayInteractor ), owner = this, view = view ) + val tabsTrayMultiselectItemsBinding = TabstrayMultiselectItemsBinding.bind( + tabsTrayBinding.root + ) + selectionBannerBinding.set( feature = SelectionBannerBinding( context = requireContext(), + binding = tabsTrayBinding, store = tabsTrayStore, navInteractor = navigationInteractor, tabsTrayInteractor = tabsTrayInteractor, containerView = view, - backgroundView = topBar, + backgroundView = tabsTrayBinding.topBar, showOnSelectViews = VisibilityModifier( - collect_multi_select, - share_multi_select, - menu_multi_select, - multiselect_title, - exit_multi_select + tabsTrayMultiselectItemsBinding.collectMultiSelect, + tabsTrayMultiselectItemsBinding.shareMultiSelect, + tabsTrayMultiselectItemsBinding.menuMultiSelect, + tabsTrayBinding.multiselectTitle, + tabsTrayBinding.exitMultiSelect ), showOnNormalViews = VisibilityModifier( - tab_layout, - tab_tray_overflow, - new_tab_button + tabsTrayBinding.tabLayout, + tabsTrayBinding.tabTrayOverflow, + fabButtonBinding.newTabButton ) ), owner = this, @@ -266,8 +302,8 @@ class TabsTrayFragment : AppCompatDialogFragment() { selectionHandleBinding.set( feature = SelectionHandleBinding( store = tabsTrayStore, - handle = handle, - containerLayout = tab_wrapper + handle = tabsTrayBinding.handle, + containerLayout = tabsTrayBinding.tabWrapper ), owner = this, view = view @@ -284,6 +320,18 @@ class TabsTrayFragment : AppCompatDialogFragment() { view = view ) + tabsTrayInactiveTabsOnboardingBinding.set( + feature = TabsTrayInactiveTabsOnboardingBinding( + context = requireContext(), + store = requireComponents.core.store, + tabsTrayBinding = tabsTrayBinding, + settings = requireComponents.settings, + navigationInteractor = navigationInteractor + ), + owner = this, + view = view + ) + setFragmentResultListener(ShareFragment.RESULT_KEY) { _, _ -> dismissTabsTray() } @@ -295,7 +343,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { trayBehaviorManager.updateDependingOnOrientation(newConfig.orientation) if (requireContext().settings().gridTabView) { - tabsTray.adapter?.notifyDataSetChanged() + tabsTrayBinding.tabsTray.adapter?.notifyDataSetChanged() } } @@ -321,7 +369,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { }, operation = { }, elevation = ELEVATION, - anchorView = if (new_tab_button.isVisible) new_tab_button else null + anchorView = if (fabButtonBinding.newTabButton.isVisible) fabButtonBinding.newTabButton else null ) } @@ -333,7 +381,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { browserInteractor: BrowserTrayInteractor, navigationInteractor: NavigationInteractor ) { - tabsTray.apply { + tabsTrayBinding.tabsTray.apply { adapter = TrayPagerAdapter( context, store, @@ -347,8 +395,8 @@ class TabsTrayFragment : AppCompatDialogFragment() { } @VisibleForTesting - internal fun setupMenu(view: View, navigationInteractor: NavigationInteractor) { - view.tab_tray_overflow.setOnClickListener { anchor -> + internal fun setupMenu(navigationInteractor: NavigationInteractor) { + tabsTrayBinding.tabTrayOverflow.setOnClickListener { anchor -> requireComponents.analytics.metrics.track(Event.TabsTrayMenuOpened) @@ -356,7 +404,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { context = requireContext(), browserStore = requireComponents.core.store, tabsTrayStore = tabsTrayStore, - tabLayout = tab_layout, + tabLayout = tabsTrayBinding.tabLayout, navigationInteractor = navigationInteractor ).build() @@ -375,8 +423,8 @@ class TabsTrayFragment : AppCompatDialogFragment() { @VisibleForTesting internal fun setupBackgroundDismissalListener(block: (View) -> Unit) { - tabLayout.setOnClickListener(block) - handle.setOnClickListener(block) + tabsTrayDialogBinding.tabLayout.setOnClickListener(block) + tabsTrayBinding.handle.setOnClickListener(block) } @VisibleForTesting @@ -396,8 +444,8 @@ class TabsTrayFragment : AppCompatDialogFragment() { @VisibleForTesting internal fun selectTabPosition(position: Int, smoothScroll: Boolean) { - tabsTray.setCurrentItem(position, smoothScroll) - tab_layout.getTabAt(position)?.select() + tabsTrayBinding.tabsTray.setCurrentItem(position, smoothScroll) + tabsTrayBinding.tabLayout.getTabAt(position)?.select() } @VisibleForTesting @@ -447,7 +495,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { return if (requireComponents.settings.accessibilityServicesEnabled) { null } else { - new_tab_button + fabButtonBinding.newTabButton } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInactiveTabsOnboardingBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInactiveTabsOnboardingBinding.kt new file mode 100644 index 0000000000..a9414a7341 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInactiveTabsOnboardingBinding.kt @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.UnderlineSpan +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import mozilla.components.browser.state.selector.normalTabs +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.lib.state.helpers.AbstractBinding +import mozilla.components.support.ktx.android.util.dpToPx +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.R +import org.mozilla.fenix.browser.infobanner.InfoBanner +import org.mozilla.fenix.databinding.ComponentTabstray2Binding +import org.mozilla.fenix.databinding.OnboardingInactiveTabsCfrBinding +import org.mozilla.fenix.tabstray.ext.inactiveTabs +import org.mozilla.fenix.utils.Settings + +@OptIn(ExperimentalCoroutinesApi::class) +class TabsTrayInactiveTabsOnboardingBinding( + private val context: Context, + private val store: BrowserStore, + private val tabsTrayBinding: ComponentTabstray2Binding?, + private val settings: Settings, + private val navigationInteractor: NavigationInteractor +) : AbstractBinding(store) { + + @VisibleForTesting + internal var banner: InfoBanner? = null + + override suspend fun onState(flow: Flow) { + flow.map { state -> state.normalTabs.size } + .ifChanged() + .collect { + val inactiveTabsList = + if (settings.inactiveTabsAreEnabled) { store.state.inactiveTabs } else emptyList() + if (inactiveTabsList.isNotEmpty() && shouldShowOnboardingForInactiveTabs()) { + createInactiveCFR() + } + } + } + + private fun shouldShowOnboardingForInactiveTabs() = + settings.shouldShowInactiveTabsOnboardingPopup && + settings.canShowCfr + + private fun createInactiveCFR() { + val context: Context = context + val anchorPosition = IntArray(2) + val popupBinding = OnboardingInactiveTabsCfrBinding.inflate(LayoutInflater.from(context)) + val popup = Dialog(context) + + popup.apply { + setContentView(popupBinding.root) + setCancelable(false) + // removing title or setting it as an empty string does not prevent a11y services from assigning one + setTitle(" ") + } + popupBinding.closeInfoBanner.setOnClickListener { + popup.dismiss() + settings.shouldShowInactiveTabsOnboardingPopup = false + } + + popupBinding.bannerInfoMessage.setOnClickListener { + popup.dismiss() + settings.shouldShowInactiveTabsOnboardingPopup = false + navigationInteractor.onTabSettingsClicked() + } + + val messageText = context.getString(R.string.tab_tray_inactive_onboarding_message) + val actionText = context.getString(R.string.tab_tray_inactive_onboarding_button_text) + val spannableStringBuilder = SpannableStringBuilder(messageText) + + spannableStringBuilder.append(" ") + .append(actionText, UnderlineSpan(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + popupBinding.bannerInfoMessage.text = spannableStringBuilder + + tabsTrayBinding?.tabsTray?.getLocationOnScreen(anchorPosition) + + val (x, y) = anchorPosition + + if (x == 0 && y == 0) { + return + } + + popupBinding.root.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + + popup.window?.apply { + val attr = attributes + setGravity(Gravity.START or Gravity.TOP) + attr.x = x + 15.dpToPx(context.resources.displayMetrics) + attr.y = y + 20.dpToPx(context.resources.displayMetrics) + attributes = attr + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + popup.show() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt index 9923b0eb07..bae7a729a8 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt @@ -34,6 +34,11 @@ interface TabsTrayInteractor { * Called when clicking the debug menu option for inactive tabs. */ fun onInactiveDebugClicked(tabs: Collection) + + /** + * Deletes all inactive tabs. + */ + fun onDeleteInactiveTabs() } /** @@ -63,4 +68,8 @@ class DefaultTabsTrayInteractor( override fun onInactiveDebugClicked(tabs: Collection) { controller.forceTabsAsInactive(tabs) } + + override fun onDeleteInactiveTabs() { + controller.handleDeleteAllInactiveTabs() + } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt index cbeeb757b2..e33b709c2a 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt @@ -10,14 +10,14 @@ import android.view.ViewGroup import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.RecyclerView -import mozilla.components.browser.state.selector.normalTabs -import mozilla.components.browser.state.selector.privateTabs -import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.store.BrowserStore +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.sync.SyncedTabsAdapter import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor +import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter +import org.mozilla.fenix.tabstray.browser.TabGroupAdapter import org.mozilla.fenix.tabstray.syncedtabs.TabClickDelegate import org.mozilla.fenix.tabstray.viewholders.AbstractPageViewHolder import org.mozilla.fenix.tabstray.viewholders.NormalBrowserPageViewHolder @@ -26,48 +26,62 @@ import org.mozilla.fenix.tabstray.viewholders.SyncedTabsPageViewHolder class TrayPagerAdapter( @VisibleForTesting internal val context: Context, - @VisibleForTesting internal val store: TabsTrayStore, + @VisibleForTesting internal val tabsTrayStore: TabsTrayStore, @VisibleForTesting internal val browserInteractor: BrowserTrayInteractor, @VisibleForTesting internal val navInteractor: NavigationInteractor, @VisibleForTesting internal val interactor: TabsTrayInteractor, @VisibleForTesting internal val browserStore: BrowserStore ) : RecyclerView.Adapter() { + /** + * ⚠️ N.B: Scrolling to the selected tab depends on the order of these adapters. If you change + * the ordering or add/remove an adapter, please update [NormalBrowserPageViewHolder.scrollToTab] and + * the layout manager. + */ private val normalAdapter by lazy { ConcatAdapter( - BrowserTabsAdapter(context, browserInteractor, store), - InactiveTabsAdapter(context, browserInteractor) + InactiveTabsAdapter(context, browserInteractor, interactor, INACTIVE_TABS_FEATURE_NAME, context.settings()), + TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME), + TitleHeaderAdapter(browserStore, context.settings()), + BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME) ) } - private val privateAdapter by lazy { BrowserTabsAdapter(context, browserInteractor, store) } - private val syncedTabsAdapter by lazy { SyncedTabsAdapter(TabClickDelegate(navInteractor)) } + private val privateAdapter by lazy { + BrowserTabsAdapter( + context, + browserInteractor, + tabsTrayStore, + TABS_TRAY_FEATURE_NAME + ) + } + private val syncedTabsAdapter by lazy { + SyncedTabsAdapter(TabClickDelegate(navInteractor)) + } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractPageViewHolder { val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false) - val selectedTab = browserStore.state.selectedTab - return when (viewType) { NormalBrowserPageViewHolder.LAYOUT_ID -> { NormalBrowserPageViewHolder( itemView, - store, - interactor, - browserStore.state.normalTabs.indexOf(selectedTab) + tabsTrayStore, + browserStore, + interactor ) } PrivateBrowserPageViewHolder.LAYOUT_ID -> { PrivateBrowserPageViewHolder( itemView, - store, - interactor, - browserStore.state.privateTabs.indexOf(selectedTab) + tabsTrayStore, + browserStore, + interactor ) } SyncedTabsPageViewHolder.LAYOUT_ID -> { SyncedTabsPageViewHolder( itemView, - store + tabsTrayStore ) } else -> throw IllegalStateException("Unknown viewType.") @@ -93,11 +107,24 @@ class TrayPagerAdapter( } } + override fun onViewAttachedToWindow(holder: AbstractPageViewHolder) { + holder.attachedToWindow() + } + + override fun onViewDetachedFromWindow(holder: AbstractPageViewHolder) { + holder.detachedFromWindow() + } + override fun getItemCount(): Int = TRAY_TABS_COUNT companion object { const val TRAY_TABS_COUNT = 3 + // Telemetry keys for identifying from which app features the a was opened / closed. + const val TABS_TRAY_FEATURE_NAME = "Tabs tray" + const val TAB_GROUP_FEATURE_NAME = "Tab group" + const val INACTIVE_TABS_FEATURE_NAME = "Inactive tabs" + val POSITION_NORMAL_TABS = Page.NormalTabs.ordinal val POSITION_PRIVATE_TABS = Page.PrivateTabs.ordinal val POSITION_SYNCED_TABS = Page.SyncedTabs.ordinal diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTabViewHolder.kt index 75b0ba9eaa..34c5c1dbd6 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTabViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTabViewHolder.kt @@ -13,7 +13,6 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.AppCompatImageButton import androidx.core.view.isInvisible import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.checkbox_item.view.* import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.tabstray.TabViewHolder @@ -36,19 +35,28 @@ import org.mozilla.fenix.ext.removeTouchDelegate import org.mozilla.fenix.ext.showAndEnable import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.selection.SelectionHolder -import org.mozilla.fenix.selection.SelectionInteractor import org.mozilla.fenix.tabstray.TabsTrayState import org.mozilla.fenix.tabstray.TabsTrayStore import org.mozilla.fenix.tabstray.ext.isSelect /** * A RecyclerView ViewHolder implementation for "tab" items. + * + * @param itemView [View] that displays a "tab". + * @param imageLoader [ImageLoader] used to load tab thumbnails. + * @param trayStore [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + * @param store [BrowserStore] containing the complete state of the browser and methods to update that. + * @param metrics [MetricController] used for handling telemetry events. */ +@Suppress("LongParameterList") abstract class AbstractBrowserTabViewHolder( itemView: View, private val imageLoader: ImageLoader, private val trayStore: TabsTrayStore, private val selectionHolder: SelectionHolder?, + @VisibleForTesting + internal val featureName: String, private val store: BrowserStore = itemView.context.components.core.store, private val metrics: MetricController = itemView.context.components.analytics.metrics ) : TabViewHolder(itemView) { @@ -92,7 +100,7 @@ abstract class AbstractBrowserTabViewHolder( if (selectionHolder != null) { setSelectionInteractor(tab, selectionHolder, browserTrayInteractor) } else { - itemView.setOnClickListener { browserTrayInteractor.open(tab) } + itemView.setOnClickListener { browserTrayInteractor.open(tab, featureName) } } if (tab.thumbnail != null) { @@ -102,8 +110,8 @@ abstract class AbstractBrowserTabViewHolder( } } - fun showTabIsMultiSelectEnabled(isSelected: Boolean) { - itemView.selected_mask.isVisible = isSelected + fun showTabIsMultiSelectEnabled(selectedMaskView: View?, isSelected: Boolean) { + selectedMaskView?.isVisible = isSelected closeView.isInvisible = trayStore.state.mode is TabsTrayState.Mode.Select } @@ -203,12 +211,12 @@ abstract class AbstractBrowserTabViewHolder( private fun setSelectionInteractor( item: Tab, holder: SelectionHolder, - interactor: SelectionInteractor + interactor: BrowserTrayInteractor ) { itemView.setOnClickListener { val selected = holder.selectedItems when { - selected.isEmpty() && trayStore.state.mode.isSelect().not() -> interactor.open(item) + selected.isEmpty() && trayStore.state.mode.isSelect().not() -> interactor.open(item, featureName) item in selected -> interactor.deselect(item) else -> interactor.select(item) } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabGridViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabGridViewHolder.kt deleted file mode 100644 index 3257df562a..0000000000 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabGridViewHolder.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.tabstray.browser - -import android.view.View -import androidx.appcompat.content.res.AppCompatResources -import androidx.appcompat.widget.AppCompatImageButton -import mozilla.components.browser.tabstray.TabsTrayStyling -import mozilla.components.concept.base.images.ImageLoader -import mozilla.components.concept.tabstray.Tab -import mozilla.components.concept.tabstray.TabsTray -import mozilla.components.support.base.observer.Observable -import org.mozilla.fenix.R -import org.mozilla.fenix.ext.increaseTapArea -import kotlin.math.max -import kotlinx.android.synthetic.main.tab_tray_grid_item.view.tab_tray_grid_item -import org.mozilla.fenix.selection.SelectionHolder -import org.mozilla.fenix.tabstray.TabsTrayStore - -/** - * A RecyclerView ViewHolder implementation for "tab" items with grid layout. - */ -class BrowserTabGridViewHolder( - imageLoader: ImageLoader, - override val browserTrayInteractor: BrowserTrayInteractor, - store: TabsTrayStore, - selectionHolder: SelectionHolder? = null, - itemView: View -) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder) { - - private val closeButton: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close) - - override val thumbnailSize: Int - get() = max( - itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height), - itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width) - ) - - override fun updateSelectedTabIndicator(showAsSelected: Boolean) { - itemView.tab_tray_grid_item.background = if (showAsSelected) { - AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border) - } else { - null - } - return - } - - override fun bind( - tab: Tab, - isSelected: Boolean, - styling: TabsTrayStyling, - observable: Observable - ) { - super.bind(tab, isSelected, styling, observable) - - closeButton.increaseTapArea(GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabListViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabListViewHolder.kt deleted file mode 100644 index da3f13ba8b..0000000000 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabListViewHolder.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.tabstray.browser - -import android.view.View -import androidx.core.content.ContextCompat -import mozilla.components.concept.base.images.ImageLoader -import mozilla.components.concept.tabstray.Tab -import org.mozilla.fenix.R -import org.mozilla.fenix.selection.SelectionHolder -import org.mozilla.fenix.tabstray.TabsTrayStore -import kotlin.math.max - -/** - * A RecyclerView ViewHolder implementation for "tab" items with list layout. - */ -class BrowserTabListViewHolder( - imageLoader: ImageLoader, - override val browserTrayInteractor: BrowserTrayInteractor, - store: TabsTrayStore, - selectionHolder: SelectionHolder? = null, - itemView: View -) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder) { - override val thumbnailSize: Int - get() = max( - itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height), - itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width) - ) - - override fun updateSelectedTabIndicator(showAsSelected: Boolean) { - val color = if (showAsSelected) { - R.color.tab_tray_item_selected_background_normal_theme - } else { - R.color.tab_tray_item_background_normal_theme - } - itemView.setBackgroundColor( - ContextCompat.getColor( - itemView.context, - color - ) - ) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabViewHolder.kt new file mode 100644 index 0000000000..654dcf65b2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabViewHolder.kt @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.AppCompatImageButton +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.tabstray.TabsTrayStyling +import mozilla.components.concept.base.images.ImageLoader +import mozilla.components.concept.tabstray.Tab +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.support.base.observer.Observable +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabTrayGridItemBinding +import org.mozilla.fenix.ext.increaseTapArea +import kotlin.math.max +import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.tabstray.TabsTrayStore + +sealed class BrowserTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + /** + * A RecyclerView ViewHolder implementation for "tab" items with grid layout. + * + * @param imageLoader [ImageLoader] used to load tab thumbnails. + * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s. + * @param itemView [View] that displays a "tab". + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ + class GridViewHolder( + imageLoader: ImageLoader, + override val browserTrayInteractor: BrowserTrayInteractor, + store: TabsTrayStore, + selectionHolder: SelectionHolder? = null, + itemView: View, + featureName: String + ) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) { + + private val closeButton: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close) + + override val thumbnailSize: Int + get() = max( + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height), + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width) + ) + + override fun updateSelectedTabIndicator(showAsSelected: Boolean) { + val binding = TabTrayGridItemBinding.bind(itemView) + binding.tabTrayGridItem.background = if (showAsSelected) { + AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border) + } else { + null + } + return + } + + override fun bind( + tab: Tab, + isSelected: Boolean, + styling: TabsTrayStyling, + observable: Observable + ) { + super.bind(tab, isSelected, styling, observable) + + closeButton.increaseTapArea(GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_tray_grid_item + } + } + + /** + * A RecyclerView ViewHolder implementation for "tab" items with list layout. + * + * @param imageLoader [ImageLoader] used to load tab thumbnails. + * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s. + * @param itemView [View] that displays a "tab". + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ + class ListViewHolder( + imageLoader: ImageLoader, + override val browserTrayInteractor: BrowserTrayInteractor, + store: TabsTrayStore, + selectionHolder: SelectionHolder? = null, + itemView: View, + featureName: String + ) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) { + override val thumbnailSize: Int + get() = max( + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height), + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width) + ) + + override fun updateSelectedTabIndicator(showAsSelected: Boolean) { + val color = if (showAsSelected) { + R.color.tab_tray_item_selected_background_normal_theme + } else { + R.color.tab_tray_item_background_normal_theme + } + itemView.setBackgroundColor( + ContextCompat.getColor( + itemView.context, + color + ) + ) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_tray_item + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt index b69f845bf3..1bb5d03e2c 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt @@ -6,9 +6,9 @@ package org.mozilla.fenix.tabstray.browser import android.content.Context import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.tab_tray_item.view.* import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM import mozilla.components.browser.thumbnails.loader.ThumbnailLoader @@ -16,18 +16,27 @@ import mozilla.components.concept.tabstray.Tab import mozilla.components.concept.tabstray.TabsTray import mozilla.components.support.base.observer.Observable import mozilla.components.support.base.observer.ObserverRegistry -import org.mozilla.fenix.R +import org.mozilla.fenix.components.Components +import org.mozilla.fenix.databinding.TabTrayGridItemBinding +import org.mozilla.fenix.databinding.TabTrayItemBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.tabstray.TabsTrayStore /** * A [RecyclerView.Adapter] for browser tabs. + * + * @param context [Context] used for various platform interactions or accessing [Components] + * @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + * @param delegate [Observable]<[TabsTray.Observer]> for observing tabs tray changes. Defaults to [ObserverRegistry]. */ class BrowserTabsAdapter( private val context: Context, private val interactor: BrowserTrayInteractor, private val store: TabsTrayStore, + private val featureName: String, delegate: Observable = ObserverRegistry() ) : TabsAdapter(delegate) { @@ -35,8 +44,8 @@ class BrowserTabsAdapter( * The layout types for the tabs. */ enum class ViewType(val layoutRes: Int) { - LIST(R.layout.tab_tray_item), - GRID(R.layout.tab_tray_grid_item) + LIST(BrowserTabViewHolder.ListViewHolder.LAYOUT_ID), + GRID(BrowserTabViewHolder.GridViewHolder.LAYOUT_ID) } /** @@ -48,10 +57,13 @@ class BrowserTabsAdapter( private val imageLoader = ThumbnailLoader(context.components.core.thumbnailStorage) override fun getItemViewType(position: Int): Int { - return if (context.components.settings.gridTabView) { - ViewType.GRID.layoutRes - } else { - ViewType.LIST.layoutRes + return when { + context.components.settings.gridTabView -> { + ViewType.GRID.layoutRes + } + else -> { + ViewType.LIST.layoutRes + } } } @@ -60,22 +72,31 @@ class BrowserTabsAdapter( return when (viewType) { ViewType.GRID.layoutRes -> - BrowserTabGridViewHolder(imageLoader, interactor, store, selectionHolder, view) + BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) else -> - BrowserTabListViewHolder(imageLoader, interactor, store, selectionHolder, view) + BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) } } override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int) { super.onBindViewHolder(holder, position) - + var selectedMaskView: View? = null holder.tab?.let { tab -> - holder.itemView.mozac_browser_tabstray_close.setOnClickListener { - interactor.close(tab) + when (getItemViewType(position)) { + ViewType.GRID.layoutRes -> { + val gridBinding = TabTrayGridItemBinding.bind(holder.itemView) + selectedMaskView = gridBinding.checkboxInclude.selectedMask + gridBinding.mozacBrowserTabstrayClose.setOnClickListener { interactor.close(tab, featureName) } + } + ViewType.LIST.layoutRes -> { + val listBinding = TabTrayItemBinding.bind(holder.itemView) + selectedMaskView = listBinding.checkboxInclude.selectedMask + listBinding.mozacBrowserTabstrayClose.setOnClickListener { interactor.close(tab, featureName) } + } } selectionHolder?.let { - holder.showTabIsMultiSelectEnabled(it.selectedItems.contains(tab)) + holder.showTabIsMultiSelectEnabled(selectedMaskView, it.selectedItems.contains(tab)) } } } @@ -85,16 +106,14 @@ class BrowserTabsAdapter( * display itself. */ override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int, payloads: List) { - val tabs = tabs ?: return - - if (tabs.list.isEmpty()) return + if (currentList.isEmpty()) return if (payloads.isEmpty()) { onBindViewHolder(holder, position) return } - if (position == tabs.selectedIndex) { + if (position == selectedIndex) { if (payloads.contains(PAYLOAD_HIGHLIGHT_SELECTED_ITEM)) { holder.updateSelectedTabIndicator(true) } else if (payloads.contains(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM)) { @@ -103,7 +122,18 @@ class BrowserTabsAdapter( } selectionHolder?.let { - holder.showTabIsMultiSelectEnabled(it.selectedItems.contains(holder.tab)) + var selectedMaskView: View? = null + when (getItemViewType(position)) { + ViewType.GRID.layoutRes -> { + val gridBinding = TabTrayGridItemBinding.bind(holder.itemView) + selectedMaskView = gridBinding.checkboxInclude.selectedMask + } + ViewType.LIST.layoutRes -> { + val listBinding = TabTrayItemBinding.bind(holder.itemView) + selectedMaskView = listBinding.checkboxInclude.selectedMask + } + } + holder.showTabIsMultiSelectEnabled(selectedMaskView, it.selectedItems.contains(holder.tab)) } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt index d1c4ad46f9..02c4fc2117 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt @@ -21,10 +21,21 @@ import org.mozilla.fenix.tabstray.TabsTrayStore */ interface BrowserTrayInteractor : SelectionInteractor, UserInteractionHandler { + /** + * Open a tab. + * + * @param tab [Tab] to open in browser. + * @param source app feature from which the [tab] was opened. + */ + fun open(tab: Tab, source: String? = null) + /** * Close the tab. + * + * @param tab [Tab] to close. + * @param source app feature from which the [tab] was closed. */ - fun close(tab: Tab) + fun close(tab: Tab, source: String? = null) /** * TabTray's Floating Action Button clicked. @@ -65,14 +76,21 @@ class DefaultBrowserTrayInteractor( * See [SelectionInteractor.open] */ override fun open(item: Tab) { - selectTabWrapper.invoke(item.id) + open(item, null) + } + + /** + * See [BrowserTrayInteractor.open]. + */ + override fun open(tab: Tab, source: String?) { + selectTabWrapper.invoke(tab.id, source) } /** * See [BrowserTrayInteractor.close]. */ - override fun close(tab: Tab) { - removeTabWrapper.invoke(tab.id) + override fun close(tab: Tab, source: String?) { + removeTabWrapper.invoke(tab.id, source) } /** diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt index 5726b3f34b..b56d3da32e 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt @@ -5,28 +5,29 @@ package org.mozilla.fenix.tabstray.browser import android.view.View -import androidx.annotation.StringRes +import androidx.core.view.updatePadding import androidx.recyclerview.widget.RecyclerView import mozilla.components.browser.toolbar.MAX_URI_LENGTH import mozilla.components.concept.tabstray.Tab import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.databinding.InactiveFooterItemBinding -import org.mozilla.fenix.databinding.InactiveRecentlyClosedItemBinding import org.mozilla.fenix.databinding.InactiveHeaderItemBinding import org.mozilla.fenix.databinding.InactiveTabListItemBinding +import org.mozilla.fenix.databinding.InactiveTabsAutoCloseBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.loadIntoView +import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.toShortUrl -import org.mozilla.fenix.tabstray.browser.AutoCloseInterval.Manual -import org.mozilla.fenix.tabstray.browser.AutoCloseInterval.OneDay -import org.mozilla.fenix.tabstray.browser.AutoCloseInterval.OneMonth -import org.mozilla.fenix.tabstray.browser.AutoCloseInterval.OneWeek +import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.dpToPx +import org.mozilla.fenix.tabstray.TabsTrayInteractor sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { class HeaderHolder( itemView: View, - interactor: InactiveTabsInteractor + inactiveTabsInteractor: InactiveTabsInteractor, + tabsTrayInteractor: TabsTrayInteractor, ) : InactiveTabViewHolder(itemView) { private val binding = InactiveHeaderItemBinding.bind(itemView) @@ -35,26 +36,71 @@ sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(ite itemView.apply { isActivated = InactiveTabsState.isExpanded + correctHeaderBorder(isActivated) + setOnClickListener { val newState = !it.isActivated - interactor.onHeaderClicked(newState) + inactiveTabsInteractor.onHeaderClicked(newState) it.isActivated = newState - binding.chevron.rotation = ROTATION_DEGREE + + correctHeaderBorder(isActivated) + } + + binding.delete.setOnClickListener { + tabsTrayInteractor.onDeleteInactiveTabs() } } } + /** + * When the header is collapsed we use its bottom border instead of the footer's + */ + private fun correctHeaderBorder(isActivated: Boolean) { + binding.inactiveHeaderBorder.updatePadding( + bottom = binding.root.context.dpToPx(if (isActivated) 0f else 1f) + ) + } + companion object { const val LAYOUT_ID = R.layout.inactive_header_item - private const val ROTATION_DEGREE = 180F } } + class AutoCloseDialogHolder( + itemView: View, + interactor: InactiveTabsAutoCloseDialogInteractor + ) : InactiveTabViewHolder(itemView) { + private val binding = InactiveTabsAutoCloseBinding.bind(itemView) + + init { + binding.root.context.metrics.track(Event.TabsTrayAutoCloseDialogSeen) + binding.closeButton.setOnClickListener { + interactor.onCloseClicked() + } + + binding.action.setOnClickListener { + interactor.onEnabledAutoCloseClicked() + } + } + + companion object { + const val LAYOUT_ID = R.layout.inactive_tabs_auto_close + } + } + + /** + * A RecyclerView ViewHolder implementation for an inactive tab view. + * + * @param itemView the inactive tab [View]. + * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ class TabViewHolder( itemView: View, - private val browserTrayInteractor: BrowserTrayInteractor + private val browserTrayInteractor: BrowserTrayInteractor, + private val featureName: String ) : InactiveTabViewHolder(itemView) { private val binding = InactiveTabListItemBinding.bind(itemView) @@ -65,7 +111,8 @@ sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(ite val url = tab.url.toShortUrl(components.publicSuffixList).take(MAX_URI_LENGTH) itemView.setOnClickListener { - browserTrayInteractor.open(tab) + components.analytics.metrics.track(Event.TabsTrayOpenInactiveTab) + browserTrayInteractor.open(tab, featureName) } binding.siteListItem.apply { @@ -75,7 +122,8 @@ sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(ite R.drawable.mozac_ic_close, R.string.content_description_close_button ) { - browserTrayInteractor.close(tab) + components.analytics.metrics.track(Event.TabsTrayCloseInactiveTab()) + browserTrayInteractor.close(tab, featureName) } } } @@ -85,51 +133,10 @@ sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(ite } } - class RecentlyClosedHolder( - itemView: View, - private val browserTrayInteractor: BrowserTrayInteractor, - ) : InactiveTabViewHolder(itemView) { - - val binding = InactiveRecentlyClosedItemBinding.bind(itemView) - - fun bind() { - val context = itemView.context - binding.inactiveRecentlyClosedText.text = - context.getString(R.string.tab_tray_inactive_recently_closed) - - binding.inactiveRecentlyClosed.setOnClickListener { - browserTrayInteractor.onRecentlyClosedClicked() - } - } - - companion object { - const val LAYOUT_ID = R.layout.inactive_recently_closed_item - } - } - class FooterHolder(itemView: View) : InactiveTabViewHolder(itemView) { - val binding = InactiveFooterItemBinding.bind(itemView) - - fun bind(interval: AutoCloseInterval) { - val context = itemView.context - val stringRes = when (interval) { - Manual, OneDay -> { - binding.inactiveDescription.visibility = View.GONE - binding.topDivider.visibility = View.GONE - null - } - OneWeek -> { - context.getString(interval.description) - } - OneMonth -> { - context.getString(interval.description) - } - } - if (stringRes != null) { - binding.inactiveDescription.text = - context.getString(R.string.inactive_tabs_description, stringRes) - } + init { + InactiveFooterItemBinding.bind(itemView) } companion object { @@ -137,10 +144,3 @@ sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(ite } } } - -enum class AutoCloseInterval(@StringRes val description: Int) { - Manual(0), - OneDay(0), - OneWeek(R.string.inactive_tabs_7_days), - OneMonth(R.string.inactive_tabs_30_days) -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt index 165f6e3332..afd033042d 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt @@ -13,11 +13,13 @@ import mozilla.components.concept.tabstray.Tab as TabsTrayTab import mozilla.components.concept.tabstray.Tabs import mozilla.components.concept.tabstray.TabsTray import mozilla.components.support.base.observer.ObserverRegistry +import org.mozilla.fenix.components.Components +import org.mozilla.fenix.tabstray.TabsTrayInteractor +import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.AutoCloseDialogHolder import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.FooterHolder import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.HeaderHolder -import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.RecentlyClosedHolder import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.TabViewHolder -import org.mozilla.fenix.tabstray.ext.autoCloseInterval +import org.mozilla.fenix.utils.Settings import mozilla.components.support.base.observer.Observable as ComponentObservable /** @@ -32,24 +34,34 @@ private typealias Observable = ComponentObservable /** * The [ListAdapter] for displaying the list of inactive tabs. + * + * @param context [Context] used for various platform interactions or accessing [Components] + * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + * @param delegate [Observable]<[TabsTray.Observer]> for observing tabs tray changes. Defaults to [ObserverRegistry]. */ class InactiveTabsAdapter( private val context: Context, private val browserTrayInteractor: BrowserTrayInteractor, + private val tabsTrayInteractor: TabsTrayInteractor, + private val featureName: String, + private val settings: Settings, delegate: Observable = ObserverRegistry() ) : Adapter(DiffCallback), TabsTray, Observable by delegate { internal lateinit var inactiveTabsInteractor: InactiveTabsInteractor + internal lateinit var inactiveTabsAutoCloseDialogInteractor: InactiveTabsAutoCloseDialogInteractor + internal var inActiveTabsCount: Int = 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InactiveTabViewHolder { val view = LayoutInflater.from(parent.context) .inflate(viewType, parent, false) return when (viewType) { - HeaderHolder.LAYOUT_ID -> HeaderHolder(view, inactiveTabsInteractor) - TabViewHolder.LAYOUT_ID -> TabViewHolder(view, browserTrayInteractor) + AutoCloseDialogHolder.LAYOUT_ID -> AutoCloseDialogHolder(view, inactiveTabsAutoCloseDialogInteractor) + HeaderHolder.LAYOUT_ID -> HeaderHolder(view, inactiveTabsInteractor, tabsTrayInteractor) + TabViewHolder.LAYOUT_ID -> TabViewHolder(view, browserTrayInteractor, featureName) FooterHolder.LAYOUT_ID -> FooterHolder(view) - RecentlyClosedHolder.LAYOUT_ID -> RecentlyClosedHolder(view, browserTrayInteractor) else -> throw IllegalStateException("Unknown viewType: $viewType") } } @@ -60,29 +72,29 @@ class InactiveTabsAdapter( val item = getItem(position) as Item.Tab holder.bind(item.tab) } - is FooterHolder -> { - val item = getItem(position) as Item.Footer - holder.bind(item.interval) - } - is HeaderHolder -> { + + is HeaderHolder, is AutoCloseDialogHolder, is FooterHolder -> { // do nothing. } - is RecentlyClosedHolder -> { - holder.bind() - } } } override fun getItemViewType(position: Int): Int { return when (position) { 0 -> HeaderHolder.LAYOUT_ID - itemCount - 2 -> RecentlyClosedHolder.LAYOUT_ID + 1 -> if (settings.shouldShowInactiveTabsAutoCloseDialog(inActiveTabsCount)) { + AutoCloseDialogHolder.LAYOUT_ID + } else { + TabViewHolder.LAYOUT_ID + } itemCount - 1 -> FooterHolder.LAYOUT_ID else -> TabViewHolder.LAYOUT_ID } } override fun updateTabs(tabs: Tabs) { + inActiveTabsCount = tabs.list.size + // Early return with an empty list to remove the header/footer items. if (tabs.list.isEmpty()) { submitList(emptyList()) @@ -96,16 +108,16 @@ class InactiveTabsAdapter( } val items = tabs.list.map { Item.Tab(it) } - val footer = Item.Footer(context.autoCloseInterval) - - submitList(listOf(Item.Header) + items + listOf(Item.RecentlyClosed, footer)) + val footer = Item.Footer + val headerItems = if (settings.shouldShowInactiveTabsAutoCloseDialog(items.size)) { + listOf(Item.Header, Item.AutoCloseMessage) + } else { + listOf(Item.Header) + } + submitList(headerItems + items + listOf(footer)) } override fun isTabSelected(tabs: Tabs, position: Int): Boolean = false - override fun onTabsChanged(position: Int, count: Int) = Unit - override fun onTabsInserted(position: Int, count: Int) = Unit - override fun onTabsMoved(fromPosition: Int, toPosition: Int) = Unit - override fun onTabsRemoved(position: Int, count: Int) = Unit private object DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { @@ -138,14 +150,14 @@ class InactiveTabsAdapter( data class Tab(val tab: TabsTrayTab) : Item() /** - * A button that leads to the Recently Closed section in History. + * A dialog for when the inactive tabs section reach 20 tabs. */ - object RecentlyClosed : Item() + object AutoCloseMessage : Item() /** * A footer for the inactive tab section. This may be seen only * when at least one inactive tab is present. */ - data class Footer(val interval: AutoCloseInterval) : Item() + object Footer : Item() } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogController.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogController.kt new file mode 100644 index 0000000000..9cdf7a14eb --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogController.kt @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.feature.tabs.ext.toTabs +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.utils.Settings + +class InactiveTabsAutoCloseDialogController( + private val browserStore: BrowserStore, + private val settings: Settings, + private val tabFilter: (TabSessionState) -> Boolean, + private val tray: TabsTray, + private val metrics: MetricController +) { + /** + * Dismiss the auto-close dialog. + */ + fun close() { + settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true + refeshInactiveTabsSecion() + metrics.track(Event.TabsTrayAutoCloseDialogDismissed) + } + + /** + * Enable the auto-close feature with the after a month setting. + */ + fun enableAutoClosed() { + settings.closeTabsAfterOneMonth = true + settings.closeTabsAfterOneWeek = false + settings.closeTabsAfterOneDay = false + settings.manuallyCloseTabs = false + refeshInactiveTabsSecion() + metrics.track(Event.TabsTrayAutoCloseDialogTurnOnClicked) + } + + @VisibleForTesting + internal fun refeshInactiveTabsSecion() { + val tabs = browserStore.state.toTabs { tabFilter.invoke(it) } + tray.updateTabs(tabs) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogInteractor.kt new file mode 100644 index 0000000000..ec3d930d99 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogInteractor.kt @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +interface InactiveTabsAutoCloseDialogInteractor { + fun onCloseClicked() + fun onEnabledAutoCloseClicked() +} + +class DefaultInactiveTabsAutoCloseDialogInteractor( + private val controller: InactiveTabsAutoCloseDialogController +) : InactiveTabsAutoCloseDialogInteractor { + override fun onCloseClicked() { + controller.close() + } + + override fun onEnabledAutoCloseClicked() { + controller.enableAutoClosed() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsController.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsController.kt index 16f9e94b65..2658894eee 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsController.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsController.kt @@ -8,11 +8,14 @@ import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.tabstray.TabsTray import mozilla.components.feature.tabs.ext.toTabs +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.components.metrics.Event class InactiveTabsController( private val browserStore: BrowserStore, private val tabFilter: (TabSessionState) -> Boolean, - private val tray: TabsTray + private val tray: TabsTray, + private val metrics: MetricController ) { /** * Updates the inactive card to be expanded to display all the tabs, or collapsed with only @@ -21,6 +24,13 @@ class InactiveTabsController( fun updateCardExpansion(isExpanded: Boolean) { InactiveTabsState.isExpanded = isExpanded + metrics.track( + when (isExpanded) { + true -> Event.TabsTrayInactiveTabsExpanded + false -> Event.TabsTrayInactiveTabsCollapsed + } + ) + val tabs = browserStore.state.toTabs { tabFilter.invoke(it) } tray.updateTabs(tabs) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt index 08d01debb2..0dfb886ea6 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt @@ -22,5 +22,5 @@ class DefaultInactiveTabsInteractor( * TODO This should be replaced with the AppStore. */ object InactiveTabsState { - var isExpanded = true + var isExpanded = false } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt index 9e12a46a42..804bb928e6 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt @@ -9,19 +9,26 @@ import android.util.AttributeSet import androidx.recyclerview.widget.ConcatAdapter import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.tabstray.TabViewHolder +import mozilla.components.concept.tabstray.Tab +import mozilla.components.concept.tabstray.TabsTray import mozilla.components.feature.tabs.tabstray.TabsFeature -import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.TABS_TRAY_FEATURE_NAME import org.mozilla.fenix.tabstray.ext.browserAdapter import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter -import org.mozilla.fenix.tabstray.ext.isNormalTabActive import org.mozilla.fenix.tabstray.ext.isNormalTabInactive import java.util.concurrent.TimeUnit /** * The time until which a tab is considered in-active (in days). */ -const val DEFAULT_ACTIVE_DAYS = 4L +const val DEFAULT_ACTIVE_DAYS = 14L + +/** + * The maximum time from when a tab was created or accessed until it is considered "inactive". + */ +val maxActiveTime = TimeUnit.DAYS.toMillis(DEFAULT_ACTIVE_DAYS) class NormalBrowserTrayList @JvmOverloads constructor( context: Context, @@ -29,55 +36,54 @@ class NormalBrowserTrayList @JvmOverloads constructor( defStyleAttr: Int = 0 ) : AbstractBrowserTrayList(context, attrs, defStyleAttr) { - /** - * The maximum time from when a tab was created or accessed until it is considered "inactive". - */ - var maxActiveTime = TimeUnit.DAYS.toMillis(DEFAULT_ACTIVE_DAYS) - + private val swipeDelegate = SwipeToDeleteDelegate() private val concatAdapter by lazy { adapter as ConcatAdapter } + private val tabSorter by lazy { + TabSorter( + context, + context.settings(), + context.components.analytics.metrics, + concatAdapter, + context.components.core.store + ) + } + private val inactiveTabsFilter: (TabSessionState) -> Boolean = filter@{ + if (!context.settings().inactiveTabsAreEnabled) { + return@filter false + } + it.isNormalTabInactive(maxActiveTime) + } - override val tabsFeature by lazy { - val tabsAdapter = concatAdapter.browserAdapter - - TabsFeature( - tabsAdapter, - context.components.core.store, - selectTabUseCase, - removeTabUseCase, - { state -> - if (!FeatureFlags.inactiveTabs) { - return@TabsFeature !state.content.private - } - state.isNormalTabActive(maxActiveTime) - }, - {} + private val inactiveTabsInteractor by lazy { + DefaultInactiveTabsInteractor( + InactiveTabsController( + context.components.core.store, + inactiveTabsFilter, + concatAdapter.inactiveTabsAdapter, + context.components.analytics.metrics + ) ) } - /** - * NB: The setup for this feature is a bit complicated without a better dependency injection - * solution to scope it down to just this view. - */ - private val inactiveFeature by lazy { - val store = context.components.core.store - val tabFilter: (TabSessionState) -> Boolean = filter@{ - if (!FeatureFlags.inactiveTabs) { - return@filter false - } - it.isNormalTabInactive(maxActiveTime) - } - val tabsAdapter = concatAdapter.inactiveTabsAdapter.apply { - inactiveTabsInteractor = DefaultInactiveTabsInteractor( - InactiveTabsController(store, tabFilter, this) + private val inactiveTabsAutoCloseInteractor by lazy { + DefaultInactiveTabsAutoCloseDialogInteractor( + InactiveTabsAutoCloseDialogController( + context.components.core.store, + context.settings(), + inactiveTabsFilter, + concatAdapter.inactiveTabsAdapter, + context.components.analytics.metrics ) - } + ) + } + override val tabsFeature by lazy { TabsFeature( - tabsAdapter, - store, + tabSorter, + context.components.core.store, selectTabUseCase, removeTabUseCase, - tabFilter, + { !it.content.private }, {} ) } @@ -95,8 +101,12 @@ class NormalBrowserTrayList @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() + concatAdapter.inactiveTabsAdapter.inactiveTabsInteractor = inactiveTabsInteractor + concatAdapter.inactiveTabsAdapter.inactiveTabsAutoCloseDialogInteractor = inactiveTabsAutoCloseInteractor + tabsFeature.start() - inactiveFeature.start() + + concatAdapter.browserAdapter.register(swipeDelegate) touchHelper.attachToRecyclerView(this) } @@ -105,8 +115,22 @@ class NormalBrowserTrayList @JvmOverloads constructor( super.onDetachedFromWindow() tabsFeature.stop() - inactiveFeature.stop() + + concatAdapter.browserAdapter.unregister(swipeDelegate) touchHelper.attachToRecyclerView(null) } + + /** + * A delegate for handling open/selected events from swipe-to-delete gestures. + */ + inner class SwipeToDeleteDelegate : TabsTray.Observer { + override fun onTabClosed(tab: Tab) { + removeTabUseCase.invoke(tab.id, TABS_TRAY_FEATURE_NAME) + } + + override fun onTabSelected(tab: Tab) { + selectTabUseCase.invoke(tab.id) + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBinding.kt index cfb785a6ee..83d6338eb6 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBinding.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBinding.kt @@ -4,10 +4,10 @@ package org.mozilla.fenix.tabstray.browser +import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM @@ -23,19 +23,21 @@ import org.mozilla.fenix.tabstray.TabsTrayStore @OptIn(ExperimentalCoroutinesApi::class) class SelectedItemAdapterBinding( store: TabsTrayStore, - val adapter: BrowserTabsAdapter + val adapter: RecyclerView.Adapter ) : AbstractBinding(store) { override suspend fun onState(flow: Flow) { flow.map { it.mode } - // ignore initial mode update; the adapter is already in an updated state. - .drop(1) .ifChanged() .collect { mode -> notifyAdapter(mode) } } + /** + * N.B: This method should be made more performant to find the position of the multi-selected tab that has + * changed in the adapter, and then [RecyclerView.Adapter.notifyItemChanged]. + */ private fun notifyAdapter(mode: Mode) = with(adapter) { if (mode == Mode.Normal) { notifyItemRangeChanged(0, itemCount, PAYLOAD_HIGHLIGHT_SELECTED_ITEM) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt index 506d833bff..1bad432dc7 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt @@ -9,9 +9,6 @@ import android.view.View import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.component_tabstray2.view.exit_multi_select -import kotlinx.android.synthetic.main.component_tabstray2.view.multiselect_title -import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect @@ -19,6 +16,8 @@ import kotlinx.coroutines.flow.map import mozilla.components.lib.state.helpers.AbstractBinding import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ComponentTabstray2Binding +import org.mozilla.fenix.databinding.TabstrayMultiselectItemsBinding import org.mozilla.fenix.tabstray.NavigationInteractor import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayState @@ -45,6 +44,7 @@ import org.mozilla.fenix.tabstray.ext.showWithTheme @Suppress("LongParameterList") class SelectionBannerBinding( private val context: Context, + private val binding: ComponentTabstray2Binding, private val store: TabsTrayStore, private val navInteractor: NavigationInteractor, private val tabsTrayInteractor: TabsTrayInteractor, @@ -64,7 +64,7 @@ class SelectionBannerBinding( override fun start() { super.start() - initListeners(containerView) + initListeners() } override suspend fun onState(flow: Flow) { @@ -89,20 +89,22 @@ class SelectionBannerBinding( } } - private fun initListeners(containerView: View) { - containerView.share_multi_select.setOnClickListener { + private fun initListeners() { + val tabsTrayMultiselectItemsBinding = TabstrayMultiselectItemsBinding.bind(binding.root) + + tabsTrayMultiselectItemsBinding.shareMultiSelect.setOnClickListener { navInteractor.onShareTabs(store.state.mode.selectedTabs) } - containerView.collect_multi_select.setOnClickListener { + tabsTrayMultiselectItemsBinding.collectMultiSelect.setOnClickListener { navInteractor.onSaveToCollections(store.state.mode.selectedTabs) } - containerView.exit_multi_select.setOnClickListener { + binding.exitMultiSelect.setOnClickListener { store.dispatch(ExitSelectMode) } - containerView.menu_multi_select.setOnClickListener { anchor -> + tabsTrayMultiselectItemsBinding.menuMultiSelect.setOnClickListener { anchor -> val menu = SelectionMenuIntegration( context, store, @@ -133,9 +135,9 @@ class SelectionBannerBinding( @VisibleForTesting private fun updateSelectTitle(selectedMode: Boolean, tabCount: Int) { if (selectedMode) { - containerView.multiselect_title.text = + binding.multiselectTitle.text = context.getString(R.string.tab_tray_multi_select_title, tabCount) - containerView.multiselect_title.importantForAccessibility = + binding.multiselectTitle.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroup.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroup.kt new file mode 100644 index 0000000000..d1e6c9f9ce --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroup.kt @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import mozilla.components.browser.state.state.TabSessionState + +data class TabGroup( + /** + * The search term used for the tab group. + */ + val searchTerm: String, + + /** + * The list of tabSessionStates belonging to this tab group. + */ + val tabs: List, + + /** + * The last time tabs in this group was accessed. + */ + val lastAccess: Long +) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt new file mode 100644 index 0000000000..17b000ada3 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupAdapter.kt @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import TabGroupViewHolder +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.HORIZONTAL +import androidx.recyclerview.widget.RecyclerView.VERTICAL +import mozilla.components.concept.tabstray.Tabs +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.support.base.observer.ObserverRegistry +import org.mozilla.fenix.components.Components +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.tabstray.TabsTrayStore +import mozilla.components.concept.tabstray.Tab as TabsTrayTab +import mozilla.components.support.base.observer.Observable + +typealias TrayObservable = Observable + +/** + * The [ListAdapter] for displaying the list of search term tabs. + * + * @param context [Context] used for various platform interactions or accessing [Components] + * @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + * @param delegate [Observable]<[TabsTray.Observer]> for observing tabs tray changes. Defaults to [ObserverRegistry]. + */ +@Suppress("TooManyFunctions") +class TabGroupAdapter( + private val context: Context, + private val browserTrayInteractor: BrowserTrayInteractor, + private val store: TabsTrayStore, + private val featureName: String, + delegate: TrayObservable = ObserverRegistry() +) : ListAdapter(DiffCallback), TabsTray, TrayObservable by delegate { + + // TODO use [List.toSearchGroup()] + // see https://github.com/mozilla-mobile/android-components/issues/11012 + data class Group( + /** + * A title for the tab group. + */ + val title: String, + + /** + * The list of tabs belonging to this tab group. + */ + val tabs: List, + + /** + * The last time tabs in this group was accessed. + */ + val lastAccess: Long + ) + + /** + * Tracks the selected tabs in multi-select mode. + */ + var selectionHolder: SelectionHolder? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabGroupViewHolder { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + + return when { + context.components.settings.gridTabView -> { + TabGroupViewHolder(view, HORIZONTAL, browserTrayInteractor, store, selectionHolder) + } + else -> { + TabGroupViewHolder(view, VERTICAL, browserTrayInteractor, store, selectionHolder) + } + } + } + + override fun onBindViewHolder(holder: TabGroupViewHolder, position: Int) { + val group = getItem(position) + holder.bind(group, this) + } + + override fun getItemViewType(position: Int) = TabGroupViewHolder.LAYOUT_ID + + /** + * Notify the nested [RecyclerView] when this view has been attached. + */ + override fun onViewAttachedToWindow(holder: TabGroupViewHolder) { + holder.rebind() + } + + /** + * Notify the nested [RecyclerView] when this view has been detached. + */ + override fun onViewDetachedFromWindow(holder: TabGroupViewHolder) { + holder.unbind() + } + + /** + * Not implemented; implementation is handled [List.toSearchGroups] + */ + override fun updateTabs(tabs: Tabs) = throw UnsupportedOperationException("Use submitList instead.") + + /** + * Not implemented; handled by nested [RecyclerView]. + */ + override fun isTabSelected(tabs: Tabs, position: Int): Boolean = false + + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Group, newItem: Group) = oldItem.title == newItem.title + override fun areContentsTheSame(oldItem: Group, newItem: Group) = oldItem == newItem + } +} + +internal fun TabGroupAdapter.Group.containsTabId(tabId: String): Boolean { + return tabs.firstOrNull { it.id == tabId } != null +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt new file mode 100644 index 0000000000..a6fb3f28d2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupListAdapter.kt @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM +import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM +import mozilla.components.browser.tabstray.TabsTrayStyling +import mozilla.components.browser.thumbnails.loader.ThumbnailLoader +import mozilla.components.concept.tabstray.Tab +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.support.base.observer.Observable +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabTrayGridItemBinding +import org.mozilla.fenix.databinding.TabTrayItemBinding +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.dpToPx +import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.ext.MIN_COLUMN_WIDTH_DP + +/** + * The [ListAdapter] for displaying the list of tabs that have the same search term. + * + * @param context [Context] used for various platform interactions or accessing [Components] + * @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param delegate [Observable]<[TabsTray.Observer]> for observing tabs tray changes. Defaults to [ObserverRegistry]. + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ +class TabGroupListAdapter( + private val context: Context, + private val interactor: BrowserTrayInteractor, + private val store: TabsTrayStore, + private val delegate: Observable, + private val selectionHolder: SelectionHolder?, + private val featureName: String, +) : ListAdapter(DiffCallback) { + + private val selectedItemAdapterBinding = SelectedItemAdapterBinding(store, this) + private val imageLoader = ThumbnailLoader(context.components.core.thumbnailStorage) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AbstractBrowserTabViewHolder { + return when { + context.components.settings.gridTabView -> { + val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_grid_item, parent, false) + view.layoutParams.width = view.dpToPx(MIN_COLUMN_WIDTH_DP.toFloat()) + BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) + } + else -> { + val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_item, parent, false) + BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName) + } + } + } + + override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int) { + val tab = getItem(position) + val selectedTabId = context.components.core.store.state.selectedTabId + holder.bind(tab, tab.id == selectedTabId, TabsTrayStyling(), delegate) + holder.tab?.let { holderTab -> + when { + context.components.settings.gridTabView -> { + val gridBinding = TabTrayGridItemBinding.bind(holder.itemView) + gridBinding.mozacBrowserTabstrayClose.setOnClickListener { + interactor.close(holderTab, featureName) + } + } + else -> { + val listBinding = TabTrayItemBinding.bind(holder.itemView) + listBinding.mozacBrowserTabstrayClose.setOnClickListener { + interactor.close(holderTab, featureName) + } + } + } + } + } + + /** + * Over-ridden [onBindViewHolder] that uses the payloads to notify the selected tab how to + * display itself. + * + * N.B: this is a modified version of [BrowserTabsAdapter.onBindViewHolder]. + */ + override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int, payloads: List) { + val tabs = currentList + val selectedTabId = context.components.core.store.state.selectedTabId + val selectedIndex = tabs.indexOfFirst { it.id == selectedTabId } + + if (tabs.isEmpty()) return + + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + return + } + + if (position == selectedIndex) { + if (payloads.contains(PAYLOAD_HIGHLIGHT_SELECTED_ITEM)) { + holder.updateSelectedTabIndicator(true) + } else if (payloads.contains(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM)) { + holder.updateSelectedTabIndicator(false) + } + } + + selectionHolder?.let { + var selectedMaskView: View? = null + when (getItemViewType(position)) { + BrowserTabsAdapter.ViewType.GRID.layoutRes -> { + val gridBinding = TabTrayGridItemBinding.bind(holder.itemView) + selectedMaskView = gridBinding.checkboxInclude.selectedMask + } + BrowserTabsAdapter.ViewType.LIST.layoutRes -> { + val listBinding = TabTrayItemBinding.bind(holder.itemView) + selectedMaskView = listBinding.checkboxInclude.selectedMask + } + } + holder.showTabIsMultiSelectEnabled(selectedMaskView, it.selectedItems.contains(holder.tab)) + } + } + + override fun getItemViewType(position: Int): Int { + return when { + context.components.settings.gridTabView -> { + BrowserTabsAdapter.ViewType.GRID.layoutRes + } + else -> { + BrowserTabsAdapter.ViewType.LIST.layoutRes + } + } + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + selectedItemAdapterBinding.start() + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + selectedItemAdapterBinding.stop() + } + + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Tab, newItem: Tab) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: Tab, newItem: Tab) = oldItem == newItem + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt new file mode 100644 index 0000000000..d38aa5e3c6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabGroupViewHolder.kt @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.concept.tabstray.Tab +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.support.base.observer.Observable +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabGroupItemBinding +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.selection.SelectionHolder +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.TrayPagerAdapter +import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor +import org.mozilla.fenix.tabstray.browser.TabGroupAdapter +import org.mozilla.fenix.tabstray.browser.TabGroupListAdapter + +/** + * A RecyclerView ViewHolder implementation for tab group items. + * + * @param itemView [View] that displays a "tab". + * @param orientation [Int] orientation of the items. Horizontal for grid layout, vertical for list layout + * @param interactor the [BrowserTrayInteractor] for tab interactions. + * @param store the [TabsTrayStore] instance. + * @param selectionHolder the store that holds the currently selected tabs. + */ +class TabGroupViewHolder( + itemView: View, + val orientation: Int, + val interactor: BrowserTrayInteractor, + val store: TabsTrayStore, + val selectionHolder: SelectionHolder? = null +) : RecyclerView.ViewHolder(itemView) { + private val binding = TabGroupItemBinding.bind(itemView) + + lateinit var groupListAdapter: TabGroupListAdapter + + fun bind( + group: TabGroupAdapter.Group, + observable: Observable + ) { + val selectedTabId = itemView.context.components.core.store.state.selectedTabId + val selectedIndex = group.tabs.indexOfFirst { it.id == selectedTabId } + + binding.tabGroupTitle.text = group.title + binding.tabGroupList.apply { + layoutManager = LinearLayoutManager(itemView.context, orientation, false) + groupListAdapter = TabGroupListAdapter( + context = itemView.context, + interactor = interactor, + store = store, + delegate = observable, + selectionHolder = selectionHolder, + featureName = TrayPagerAdapter.TAB_GROUP_FEATURE_NAME + ) + + adapter = groupListAdapter + + groupListAdapter.submitList(group.tabs) + scrollToPosition(selectedIndex) + } + } + + /** + * Notify the nested [RecyclerView] that it has been detached. + */ + fun unbind() { + groupListAdapter.onDetachedFromRecyclerView(binding.tabGroupList) + } + + /** + * Notify the nested [RecyclerView] that it has been attached. This is so our observers know when to start again. + */ + fun rebind() { + groupListAdapter.onAttachedToRecyclerView(binding.tabGroupList) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_group_item + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabSorter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabSorter.kt new file mode 100644 index 0000000000..850fa69b5a --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabSorter.kt @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import android.content.Context +import androidx.recyclerview.widget.ConcatAdapter +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.Tab +import mozilla.components.concept.tabstray.Tabs +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.feature.tabs.tabstray.TabsFeature +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.tabstray.ext.browserAdapter +import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter +import org.mozilla.fenix.tabstray.ext.tabGroupAdapter +import org.mozilla.fenix.utils.Settings +import kotlin.math.max + +/** + * An intermediary layer to consume tabs from [TabsFeature] for sorting into the various adapters. + */ +class TabSorter( + private val context: Context, + private val settings: Settings, + private val metrics: MetricController, + private val concatAdapter: ConcatAdapter, + private val store: BrowserStore +) : TabsTray, Observable by ObserverRegistry() { + private var shouldReportMetrics: Boolean = true + + override fun updateTabs(tabs: Tabs) { + val inactiveTabs = tabs.list.getInactiveTabs(context) + val searchTermTabs = tabs.list.getSearchGroupTabs(context) + val normalTabs = tabs.list - inactiveTabs - searchTermTabs + val selectedTabId = store.state.selectedTabId + + // Inactive tabs + val selectedInactiveIndex = inactiveTabs.findSelectedIndex(selectedTabId) + concatAdapter.inactiveTabsAdapter.updateTabs((Tabs(inactiveTabs, selectedInactiveIndex))) + + // Tab groups + // We don't need to provide a selectedId, because the [TabGroupAdapter] has that built-in with support from + // NormalBrowserPageViewHolder.scrollToTab. + val (groups, remainderTabs) = searchTermTabs.toSearchGroups() + concatAdapter.tabGroupAdapter.submitList(groups) + + // Normal tabs. + val totalNormalTabs = (normalTabs + remainderTabs) + val selectedTabIndex = totalNormalTabs.findSelectedIndex(selectedTabId) + concatAdapter.browserAdapter.updateTabs(Tabs(totalNormalTabs, selectedTabIndex)) + + if (shouldReportMetrics) { + shouldReportMetrics = false + + if (settings.inactiveTabsAreEnabled) { + metrics.track(Event.TabsTrayHasInactiveTabs(inactiveTabs.size)) + } + } + } + + override fun isTabSelected(tabs: Tabs, position: Int): Boolean = false +} + +private fun List.findSelectedIndex(tabId: String?): Int { + val id = tabId ?: return -1 + return indexOfFirst { it.id == id } +} + +/** + * Returns a list of inactive tabs based on our preferences. + */ +private fun List.getInactiveTabs(context: Context): List { + val inactiveTabsEnabled = context.settings().inactiveTabsAreEnabled + return if (inactiveTabsEnabled) { + filter { !it.isActive(maxActiveTime) } + } else { + emptyList() + } +} + +/** + * Returns a list of search term tabs based on our preferences. + */ +private fun List.getSearchGroupTabs(context: Context): List { + val inactiveTabsEnabled = context.settings().inactiveTabsAreEnabled + val tabGroupsEnabled = context.settings().searchTermTabGroupsAreEnabled + return when { + tabGroupsEnabled && inactiveTabsEnabled -> + filter { it.searchTerm.isNotBlank() && it.isActive(maxActiveTime) } + + tabGroupsEnabled -> + filter { it.searchTerm.isNotBlank() } + + else -> emptyList() + } +} + +/** + * Returns true if a tab has not been selected since [maxActiveTime]. + * + * N.B: This is duplicated from [TabSessionState.isActive(Long)] to work for [Tab]. + * + * See also: https://github.com/mozilla-mobile/android-components/issues/11012 + */ +private fun Tab.isActive(maxActiveTime: Long): Boolean { + val lastActiveTime = maxOf(lastAccess, createdAt) + val now = System.currentTimeMillis() + return (now - lastActiveTime <= maxActiveTime) +} + +/** + * Creates a list of grouped search term tabs sorted by last access time and a list of tabs + * that have search terms but would only create groups with a single tab. + * + * N.B: This is duplicated from [List.toSearchGroup()] to work for [Tab]. + * + * See also: https://github.com/mozilla-mobile/android-components/issues/11012 + */ +private fun List.toSearchGroups(): Pair, List> { + val data = groupBy { it.searchTerm.lowercase() } + + val groupings = data.map { mapEntry -> + // Uppercase since we use it for the title. + val searchTerm = mapEntry.key.replaceFirstChar(Char::uppercase) + val groupTabs = mapEntry.value + + // Calculate when the group was last used. + val groupMax = groupTabs.fold(0L) { acc, tab -> + max(tab.lastAccess, acc) + } + + TabGroupAdapter.Group( + title = searchTerm, + tabs = groupTabs, + lastAccess = groupMax + ) + } + + val groups = groupings.filter { it.tabs.size > 1 }.sortedBy { it.lastAccess } + val remainderTabs = (groupings - groups).flatMap { it.tabs } + + return groups to remainderTabs +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabsAdapter.kt index 77aa6509ca..7c2c29eee5 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TabsAdapter.kt @@ -30,39 +30,24 @@ abstract class TabsAdapter( delegate: Observable = ObserverRegistry() ) : ListAdapter(DiffCallback), TabsTray, Observable by delegate { - protected var tabs: Tabs? = null + protected var selectedIndex: Int? = null protected var styling: TabsTrayStyling = TabsTrayStyling() @CallSuper override fun updateTabs(tabs: Tabs) { - this.tabs = tabs + this.selectedIndex = tabs.selectedIndex + + submitList(tabs.list) notifyObservers { onTabsUpdated() } } @CallSuper override fun onBindViewHolder(holder: T, position: Int) { - val tabs = tabs ?: return - - holder.bind(tabs.list[position], isTabSelected(tabs, position), styling, this) + holder.bind(getItem(position), selectedIndex == position, styling, this) } - override fun getItemCount(): Int = tabs?.list?.size ?: 0 - - final override fun isTabSelected(tabs: Tabs, position: Int): Boolean = - tabs.selectedIndex == position - - final override fun onTabsChanged(position: Int, count: Int) = - notifyItemRangeChanged(position, count) - - final override fun onTabsInserted(position: Int, count: Int) = - notifyItemRangeInserted(position, count) - - final override fun onTabsMoved(fromPosition: Int, toPosition: Int) = - notifyItemMoved(fromPosition, toPosition) - - final override fun onTabsRemoved(position: Int, count: Int) = - notifyItemRangeRemoved(position, count) + override fun isTabSelected(tabs: Tabs, position: Int): Boolean = false private object DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Tab, newItem: Tab): Boolean { diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt new file mode 100644 index 0000000000..0b421a694e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderAdapter.kt @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.state.store.BrowserStore +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TabTrayTitleHeaderItemBinding +import org.mozilla.fenix.utils.Settings + +/** + * A [RecyclerView.Adapter] for tab header. + */ +class TitleHeaderAdapter( + browserStore: BrowserStore, + settings: Settings +) : ListAdapter(DiffCallback) { + + class Header + + private val normalTabsHeaderBinding = TitleHeaderBinding(browserStore, settings, ::handleListChanges) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + return HeaderViewHolder(view) + } + + override fun getItemViewType(position: Int) = HeaderViewHolder.LAYOUT_ID + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + normalTabsHeaderBinding.start() + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + normalTabsHeaderBinding.stop() + } + + /* Do nothing */ + override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) = Unit + + private fun handleListChanges(showHeader: Boolean) { + val header = if (showHeader) { + listOf(Header()) + } else { + emptyList() + } + + submitList(header) + } + + class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val binding = TabTrayTitleHeaderItemBinding.bind(itemView) + + fun bind() { + binding.tabTrayHeaderTitle.text = + itemView.context.getString(R.string.tab_tray_header_title_1) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_tray_title_header_item + } + } + + private object DiffCallback : DiffUtil.ItemCallback
() { + override fun areItemsTheSame(oldItem: Header, newItem: Header) = true + override fun areContentsTheSame(oldItem: Header, newItem: Header) = true + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBinding.kt new file mode 100644 index 0000000000..7a4b0514b8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/TitleHeaderBinding.kt @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.lib.state.helpers.AbstractBinding +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.tabstray.ext.getNormalTrayTabs +import org.mozilla.fenix.tabstray.ext.getSearchTabGroups +import org.mozilla.fenix.utils.Settings + +/** + * A binding class to notify an observer to show a title if there is at least one tab available. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TitleHeaderBinding( + store: BrowserStore, + private val settings: Settings, + private val showHeader: (Boolean) -> Unit +) : AbstractBinding(store) { + override suspend fun onState(flow: Flow) { + val groupsEnabled = settings.searchTermTabGroupsAreEnabled + val inactiveEnabled = settings.inactiveTabsAreEnabled + + flow.map { it.getSearchTabGroups(groupsEnabled) to it.getNormalTrayTabs(groupsEnabled, inactiveEnabled) } + .ifChanged() + .collect { (groups, normalTrayTabs) -> + if (groups.isEmpty() || normalTrayTabs.isEmpty()) { + showHeader(false) + } else { + showHeader(true) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/UseCases.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/UseCases.kt index 377453bb86..dbb69d236f 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/UseCases.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/UseCases.kt @@ -13,19 +13,27 @@ class SelectTabUseCaseWrapper( private val selectTab: TabsUseCases.SelectTabUseCase, private val onSelect: (String) -> Unit ) : TabsUseCases.SelectTabUseCase { - override fun invoke(tabId: String) { - metrics.track(Event.OpenedExistingTab) + operator fun invoke(tabId: String, source: String? = null) { + metrics.track(Event.OpenedExistingTab(source ?: "unknown")) selectTab(tabId) onSelect(tabId) } + + override fun invoke(tabId: String) { + invoke(tabId, null) + } } class RemoveTabUseCaseWrapper( private val metrics: MetricController, - private val onRemove: (String) -> Unit + private val onRemove: (String) -> Unit, ) : TabsUseCases.RemoveTabUseCase { - override fun invoke(tabId: String) { - metrics.track(Event.ClosedExistingTab) + operator fun invoke(tabId: String, source: String? = null) { + metrics.track(Event.ClosedExistingTab(source ?: "unknown")) onRemove(tabId) } + + override fun invoke(tabId: String) { + invoke(tabId, null) + } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/ConcatAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/ConcatAdapter.kt index 5b052f0951..ff9cf3d77e 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/ext/ConcatAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/ConcatAdapter.kt @@ -6,7 +6,9 @@ package org.mozilla.fenix.tabstray.ext import androidx.recyclerview.widget.ConcatAdapter import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter +import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter +import org.mozilla.fenix.tabstray.browser.TabGroupAdapter /** * A convenience binding for retrieving the [BrowserTabsAdapter] from the [ConcatAdapter]. @@ -19,3 +21,15 @@ internal val ConcatAdapter.browserAdapter */ internal val ConcatAdapter.inactiveTabsAdapter get() = adapters.find { it is InactiveTabsAdapter } as InactiveTabsAdapter + +/** + * A convenience binding for retrieving the [TabGroupAdapter] from the [ConcatAdapter]. + */ +internal val ConcatAdapter.tabGroupAdapter + get() = adapters.find { it is TabGroupAdapter } as TabGroupAdapter + +/** + * A convenience binding for retrieving the [TitleHeaderAdapter] from the [ConcatAdapter]. + */ +internal val ConcatAdapter.titleHeaderAdapter + get() = adapters.find { it is TitleHeaderAdapter } as TitleHeaderAdapter diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/Context.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/Context.kt index 663968364b..e8082ac6e7 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/ext/Context.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/Context.kt @@ -6,9 +6,8 @@ package org.mozilla.fenix.tabstray.ext import android.content.Context import org.mozilla.fenix.ext.components -import org.mozilla.fenix.tabstray.browser.AutoCloseInterval -private const val MIN_COLUMN_WIDTH_DP = 180 +const val MIN_COLUMN_WIDTH_DP = 180 /** * Returns the number of grid columns we can fit on the screen in the tabs tray. @@ -32,16 +31,3 @@ internal val Context.defaultBrowserLayoutColumns: Int 1 } } - -/** - * Returns the appropriate [AutoCloseInterval] based on user preferences. - */ -internal val Context.autoCloseInterval: AutoCloseInterval - get() = with(components.settings) { - when { - closeTabsAfterOneDay -> AutoCloseInterval.OneDay - closeTabsAfterOneWeek -> AutoCloseInterval.OneWeek - closeTabsAfterOneMonth -> AutoCloseInterval.OneMonth - else -> AutoCloseInterval.Manual - } - } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/RecyclerViewAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/RecyclerViewAdapter.kt new file mode 100644 index 0000000000..3034da27e8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/RecyclerViewAdapter.kt @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.ext + +import androidx.recyclerview.widget.RecyclerView + +/** + * Observes the adapter and invokes the callback [block] only when data is first inserted to the adapter. + */ +fun RecyclerView.Adapter.observeFirstInsert(block: () -> Unit) { + val observer = object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + block.invoke() + unregisterAdapterDataObserver(this) + } + } + registerAdapterDataObserver(observer) +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSelectors.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSelectors.kt new file mode 100644 index 0000000000..a1a5d3d7b0 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSelectors.kt @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.ext + +import mozilla.components.browser.state.selector.normalTabs +import mozilla.components.browser.state.selector.privateTabs +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.TabSessionState +import org.mozilla.fenix.ext.toSearchGroup +import org.mozilla.fenix.tabstray.browser.TabGroup +import org.mozilla.fenix.tabstray.browser.maxActiveTime + +/** + * The currently selected tab if there's one that is private. + * + * NB: Upstream to Selectors.kt. + */ +val BrowserState.selectedPrivateTab: TabSessionState? + get() = selectedTabId?.let { id -> findPrivateTab(id) } + +/** + * Finds and returns the private tab with the given id. Returns null if no + * matching tab could be found. + * + * @param tabId The ID of the tab to search for. + * @return The [TabSessionState] with the provided [tabId] or null if it could not be found. + * + * NB: Upstream to Selectors.kt. + */ +fun BrowserState.findPrivateTab(tabId: String): TabSessionState? { + return privateTabs.firstOrNull { it.id == tabId } +} + +/** + * The list of inactive tabs in the tabs tray filtered based on [maxActiveTime]. + */ +val BrowserState.inactiveTabs: List + get() = normalTabs.filter { it.isNormalTabInactive(maxActiveTime) } + +/** + * The list of normal tabs in the tabs tray filtered appropriately based on feature flags. + */ +fun BrowserState.getNormalTrayTabs( + searchTermTabGroupsAreEnabled: Boolean, + inactiveTabsEnabled: Boolean +): List { + return normalTabs.run { + if (searchTermTabGroupsAreEnabled && inactiveTabsEnabled) { + filter { it.isNormalTabActiveWithoutSearchTerm(maxActiveTime) } + } else if (inactiveTabsEnabled) { + filter { it.isNormalTabActive(maxActiveTime) } + } else if (searchTermTabGroupsAreEnabled) { + filter { it.isNormalTabWithSearchTerm() } + } else { + this + } + } +} + +/** + * The list of search groups filtered appropriately based on feature flags. + */ +fun BrowserState.getSearchTabGroups( + searchTermTabGroupsAreEnabled: Boolean +): List = if (searchTermTabGroupsAreEnabled) { + normalTabs.toSearchGroup().filter { it.tabs.size > 1 } +} else { + emptyList() +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSessionState.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSessionState.kt index 495539c434..5d968c2bea 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSessionState.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabSessionState.kt @@ -13,14 +13,50 @@ private fun TabSessionState.isActive(maxActiveTime: Long): Boolean { } /** - * Returns true if a [TabSessionState] is considered active based on the [maxActiveTime]. + * Returns true if the [TabSessionState] has a search term. + */ +private fun TabSessionState.hasSearchTerm(): Boolean { + return content.searchTerms.isNotEmpty() || !historyMetadata?.searchTerm.isNullOrBlank() +} + +/** + * Returns true if the [TabSessionState] is considered active based on the [maxActiveTime]. */ internal fun TabSessionState.isNormalTabActive(maxActiveTime: Long): Boolean { return isActive(maxActiveTime) && !content.private } /** - * Returns true if a [TabSessionState] is considered active based on the [maxActiveTime]. + * Returns true if the [TabSessionState] is considered active based on the [maxActiveTime] and + * does not have a search term + */ +internal fun TabSessionState.isNormalTabActiveWithoutSearchTerm(maxActiveTime: Long): Boolean { + return isNormalTabActive(maxActiveTime) && !hasSearchTerm() +} + +/** + * Returns true if the [TabSessionState] have a search term. + */ +internal fun TabSessionState.isNormalTabActiveWithSearchTerm(maxActiveTime: Long): Boolean { + return isNormalTabActive(maxActiveTime) && hasSearchTerm() +} + +/** + * Returns true if the [TabSessionState] has a search term but may or may not be active. + */ +internal fun TabSessionState.isNormalTabWithSearchTerm(): Boolean { + return hasSearchTerm() && !content.private +} + +/** + * Returns true if the [TabSessionState] has a search term but may or may not be active. + */ +internal fun TabSessionState.isNormalTabWithoutSearchTerm(): Boolean { + return !hasSearchTerm() && !content.private +} + +/** + * Returns true if the [TabSessionState] is considered active based on the [maxActiveTime]. */ internal fun TabSessionState.isNormalTabInactive(maxActiveTime: Long): Boolean { return !isActive(maxActiveTime) && !content.private diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsTrayLayout.kt b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsTrayLayout.kt index 84e7895508..60e1e99dbd 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsTrayLayout.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsTrayLayout.kt @@ -10,7 +10,6 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.fragment.app.findFragment import androidx.navigation.NavController import androidx.navigation.fragment.findNavController -import kotlinx.android.synthetic.main.component_sync_tabs_tray_layout.view.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -20,6 +19,7 @@ import mozilla.components.feature.syncedtabs.SyncedTabsFeature import mozilla.components.feature.syncedtabs.view.SyncedTabsView import mozilla.components.support.base.observer.Observable import mozilla.components.support.base.observer.ObserverRegistry +import org.mozilla.fenix.databinding.ComponentSyncTabsTrayLayoutBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.sync.SyncedTabsAdapter import org.mozilla.fenix.sync.SyncedTabsTitleDecoration @@ -40,6 +40,8 @@ class SyncedTabsTrayLayout @JvmOverloads constructor( private val lifecycleProvider = LifecycleViewProvider(this) private val coroutineScope = CoroutineScope(Dispatchers.Main) + private var _binding: ComponentSyncTabsTrayLayoutBinding? = null + private val binding get() = _binding!! private val syncedTabsFeature by lazy { SyncedTabsFeature( @@ -67,14 +69,15 @@ class SyncedTabsTrayLayout @JvmOverloads constructor( override var listener: SyncedTabsView.Listener? = null override fun onFinishInflate() { - synced_tabs_list.addItemDecoration(SyncedTabsTitleDecoration(context)) + _binding = ComponentSyncTabsTrayLayoutBinding.bind(this) + binding.syncedTabsList.addItemDecoration(SyncedTabsTitleDecoration(context)) super.onFinishInflate() } override fun displaySyncedTabs(syncedTabs: List) { coroutineScope.launch { - (synced_tabs_list.adapter as SyncedTabsAdapter).updateData(syncedTabs) + (binding.syncedTabsList.adapter as SyncedTabsAdapter).updateData(syncedTabs) } } @@ -93,7 +96,7 @@ class SyncedTabsTrayLayout @JvmOverloads constructor( val errorItem = error.toAdapterItem(descriptionResId, navController) val errorList: List = listOf(errorItem) - (synced_tabs_list.adapter as SyncedTabsAdapter).submitList(errorList) + (binding.syncedTabsList.adapter as SyncedTabsAdapter).submitList(errorList) } } @@ -106,6 +109,7 @@ class SyncedTabsTrayLayout @JvmOverloads constructor( override fun onDetachedFromWindow() { super.onDetachedFromWindow() + _binding = null syncedTabsFeature.stop() syncButtonBinding.stop() diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolder.kt index 071c2a335a..e3a5db8a0b 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolder.kt @@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView import org.mozilla.fenix.R import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.TrayPagerAdapter import org.mozilla.fenix.tabstray.browser.AbstractBrowserTrayList /** @@ -22,11 +23,13 @@ abstract class AbstractBrowserPageViewHolder( containerView: View, tabsTrayStore: TabsTrayStore, interactor: TabsTrayInteractor, - private val currentTabIndex: Int ) : AbstractPageViewHolder(containerView) { private val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item) private val emptyList: TextView = itemView.findViewById(R.id.tab_tray_empty_view) + private var adapterObserver: RecyclerView.AdapterDataObserver? = null + private var adapterRef: RecyclerView.Adapter? = null + abstract val emptyStringText: String init { @@ -35,21 +38,66 @@ abstract class AbstractBrowserPageViewHolder( emptyList.text = emptyStringText } + /** + * A way for an implementor of [AbstractBrowserPageViewHolder] to define their own scroll-to-tab behaviour. + */ + abstract fun scrollToTab( + adapter: RecyclerView.Adapter, + layoutManager: RecyclerView.LayoutManager + ) + @CallSuper protected fun bind( adapter: RecyclerView.Adapter, layoutManager: RecyclerView.LayoutManager ) { - adapter.registerAdapterDataObserver( - OneTimeAdapterObserver(adapter) { - trayList.scrollToPosition(currentTabIndex) - updateTrayVisibility(adapter.itemCount) - } - ) + adapterRef = adapter + + scrollToTab(adapter, layoutManager) + trayList.layoutManager = layoutManager trayList.adapter = adapter } + /** + * When the [RecyclerView.Adapter] is attached to the window we register a data observer to + * always check whether to call [updateTrayVisibility]. + * + * We keep a constant observer instead of using [RecyclerView.Adapter.observeFirstInsert], + * because some adapters can insert empty lists and trigger the one-shot observer too soon. + * + * See also [AbstractPageViewHolder.attachedToWindow]. + */ + override fun attachedToWindow() { + adapterRef?.let { adapter -> + adapterObserver = object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + updateTrayVisibility(adapter.itemCount) + } + + override fun onItemRangeRemoved(positionstart: Int, itemcount: Int) { + updateTrayVisibility(adapter.itemCount) + } + } + adapterObserver?.let { + adapter.registerAdapterDataObserver(it) + } + } + } + + /** + * [RecyclerView.AdapterDataObserver]s are responsible to be unregistered when they are done, + * so we do that here when our [TrayPagerAdapter] page is detached from the window. + * + * See also [AbstractPageViewHolder.detachedFromWindow]. + */ + override fun detachedFromWindow() { + adapterObserver?.let { + adapterRef?.unregisterAdapterDataObserver(it) + adapterObserver = null + } + } + private fun updateTrayVisibility(size: Int) { if (size == 0) { trayList.visibility = GONE @@ -60,16 +108,3 @@ abstract class AbstractBrowserPageViewHolder( } } } - -/** - * Observes the adapter and invokes the callback when data is first inserted. - */ -class OneTimeAdapterObserver( - private val adapter: RecyclerView.Adapter, - private val onAdapterReady: () -> Unit -) : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - onAdapterReady.invoke() - adapter.unregisterAdapterDataObserver(this) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractPageViewHolder.kt index 870b6ab321..3e922ca1e2 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/AbstractPageViewHolder.kt @@ -15,7 +15,21 @@ abstract class AbstractPageViewHolder constructor( val containerView: View ) : RecyclerView.ViewHolder(containerView) { + /** + * Invoked when the nested [RecyclerView.Adapter] is bound to the [RecyclerView.ViewHolder]. + */ abstract fun bind( adapter: RecyclerView.Adapter ) + + /** + * Invoked when the [RecyclerView.ViewHolder] is attached from the window. This could have + * previously been bound and is now attached again. + */ + abstract fun attachedToWindow() + + /** + * Invoked when the [RecyclerView.ViewHolder] is detached from the window. + */ + abstract fun detachedFromWindow() } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/NormalBrowserPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/NormalBrowserPageViewHolder.kt index a6c370cfb3..f947013b19 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/NormalBrowserPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/NormalBrowserPageViewHolder.kt @@ -4,33 +4,42 @@ package org.mozilla.fenix.tabstray.viewholders +import android.content.Context import android.view.View import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.state.selector.selectedNormalTab +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.tabstray.Tab import org.mozilla.fenix.R +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.browser.containsTabId +import org.mozilla.fenix.tabstray.browser.InactiveTabsState +import org.mozilla.fenix.tabstray.browser.maxActiveTime import org.mozilla.fenix.tabstray.ext.browserAdapter import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns +import org.mozilla.fenix.tabstray.ext.getNormalTrayTabs +import org.mozilla.fenix.tabstray.ext.inactiveTabs +import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter +import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter +import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm +import org.mozilla.fenix.tabstray.ext.isNormalTabInactive +import org.mozilla.fenix.tabstray.ext.observeFirstInsert +import org.mozilla.fenix.tabstray.ext.tabGroupAdapter /** * View holder for the normal tabs tray list. */ class NormalBrowserPageViewHolder( containerView: View, - private val store: TabsTrayStore, + private val tabsTrayStore: TabsTrayStore, + private val browserStore: BrowserStore, interactor: TabsTrayInteractor, - currentTabIndex: Int -) : AbstractBrowserPageViewHolder( - containerView, - store, - interactor, - currentTabIndex -), - SelectionHolder { +) : AbstractBrowserPageViewHolder(containerView, tabsTrayStore, interactor), SelectionHolder { /** * Holds the list of selected tabs. @@ -39,7 +48,7 @@ class NormalBrowserPageViewHolder( * to select tabs. */ override val selectedItems: Set - get() = store.state.mode.selectedTabs + get() = tabsTrayStore.state.mode.selectedTabs override val emptyStringText: String get() = itemView.resources.getString(R.string.no_open_tabs_description) @@ -47,23 +56,119 @@ class NormalBrowserPageViewHolder( override fun bind( adapter: RecyclerView.Adapter ) { - val browserAdapter = (adapter as ConcatAdapter).browserAdapter + val concatAdapter = adapter as ConcatAdapter + val browserAdapter = concatAdapter.browserAdapter + val tabGroupAdapter = concatAdapter.tabGroupAdapter + val manager = setupLayoutManager(containerView.context, concatAdapter) browserAdapter.selectionHolder = this + tabGroupAdapter.selectionHolder = this - val number = containerView.context.defaultBrowserLayoutColumns - val manager = GridLayoutManager(containerView.context, number).apply { + super.bind(adapter, manager) + } + + /** + * Add giant explanation why this is complicated. + */ + override fun scrollToTab( + adapter: RecyclerView.Adapter, + layoutManager: RecyclerView.LayoutManager + ) { + val concatAdapter = adapter as ConcatAdapter + val headerAdapter = concatAdapter.titleHeaderAdapter + val browserAdapter = concatAdapter.browserAdapter + val inactiveTabAdapter = concatAdapter.inactiveTabsAdapter + val tabGroupAdapter = concatAdapter.tabGroupAdapter + val inactiveTabsAreEnabled = containerView.context.settings().inactiveTabsAreEnabled + val searchTermTabGroupsAreEnabled = containerView.context.settings().searchTermTabGroupsAreEnabled + + val selectedTab = browserStore.state.selectedNormalTab ?: return + + // Update tabs into the inactive adapter. + if (inactiveTabsAreEnabled && selectedTab.isNormalTabInactive(maxActiveTime)) { + val inactiveTabsList = browserStore.state.inactiveTabs + // We want to expand the inactive section first before we want to fire our scroll observer. + InactiveTabsState.isExpanded = true + inactiveTabAdapter.observeFirstInsert { + inactiveTabsList.forEachIndexed { tabIndex, item -> + if (item.id == selectedTab.id) { + // Inactive Tabs are first + inactive header item. + val indexToScrollTo = tabIndex + 1 + layoutManager.scrollToPosition(indexToScrollTo) + + return@observeFirstInsert + } + } + } + } + + // Updates tabs into the search term group adapter. + if (searchTermTabGroupsAreEnabled && selectedTab.isNormalTabActiveWithSearchTerm(maxActiveTime)) { + tabGroupAdapter.observeFirstInsert { + // With a grouping, we need to use the list of the adapter that is already grouped + // together for the UI, so we know the final index of the grouping to scroll to. + // + // N.B: Why are we using currentList here and no where else? `currentList` is an API on top of + // `ListAdapter` which is updated when the [ListAdapter.submitList] is invoked. For our BrowserAdapter + // as an example, the updates are coming from [TabsFeature] which internally uses the internal + // [DiffUtil.calculateDiff] directly to submit a changed list which evades the `ListAdapter` from being + // notified of updates, so it therefore returns an empty list. + tabGroupAdapter.currentList.forEachIndexed { groupIndex, group -> + if (group.containsTabId(selectedTab.id)) { + + // Index is based on tabs above (inactive) with our calculated index. + val indexToScrollTo = inactiveTabAdapter.itemCount + groupIndex + layoutManager.scrollToPosition(indexToScrollTo) + + return@observeFirstInsert + } + } + } + } + + // Updates tabs into the normal browser tabs adapter. + browserAdapter.observeFirstInsert { + val activeTabsList = browserStore.state.getNormalTrayTabs( + searchTermTabGroupsAreEnabled, + inactiveTabsAreEnabled + ) + activeTabsList.forEachIndexed { tabIndex, trayTab -> + if (trayTab.id == selectedTab.id) { + + // Index is based on tabs above (inactive + groups + header) with our calculated index. + val indexToScrollTo = inactiveTabAdapter.itemCount + + tabGroupAdapter.itemCount + + headerAdapter.itemCount + tabIndex + + layoutManager.scrollToPosition(indexToScrollTo) + + return@observeFirstInsert + } + } + } + } + + private fun setupLayoutManager( + context: Context, + concatAdapter: ConcatAdapter + ): GridLayoutManager { + val headerAdapter = concatAdapter.titleHeaderAdapter + val inactiveTabAdapter = concatAdapter.inactiveTabsAdapter + val tabGroupAdapter = concatAdapter.tabGroupAdapter + + val numberOfColumns = containerView.context.defaultBrowserLayoutColumns + return GridLayoutManager(context, numberOfColumns).apply { spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { - return if (position >= browserAdapter.itemCount) { - number - } else { + return if (position >= inactiveTabAdapter.itemCount + tabGroupAdapter.itemCount + + headerAdapter.itemCount + ) { 1 + } else { + numberOfColumns } } } } - - super.bind(adapter, manager) } companion object { diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/PrivateBrowserPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/PrivateBrowserPageViewHolder.kt index 7e83b5903a..78810e9527 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/PrivateBrowserPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/PrivateBrowserPageViewHolder.kt @@ -7,29 +7,44 @@ package org.mozilla.fenix.tabstray.viewholders import android.view.View import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.state.selector.privateTabs +import mozilla.components.browser.state.store.BrowserStore import org.mozilla.fenix.R import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayStore import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns +import org.mozilla.fenix.tabstray.ext.observeFirstInsert +import org.mozilla.fenix.tabstray.ext.selectedPrivateTab /** * View holder for the private tabs tray list. */ class PrivateBrowserPageViewHolder( containerView: View, - store: TabsTrayStore, - interactor: TabsTrayInteractor, - currentTabIndex: Int + tabsTrayStore: TabsTrayStore, + private val browserStore: BrowserStore, + interactor: TabsTrayInteractor ) : AbstractBrowserPageViewHolder( containerView, - store, + tabsTrayStore, interactor, - currentTabIndex ) { override val emptyStringText: String get() = itemView.resources.getString(R.string.no_private_tabs_description) + override fun scrollToTab( + adapter: RecyclerView.Adapter, + layoutManager: RecyclerView.LayoutManager + ) { + adapter.observeFirstInsert { + val selectedTab = browserStore.state.selectedPrivateTab ?: return@observeFirstInsert + val scrollIndex = browserStore.state.privateTabs.indexOf(selectedTab) + + layoutManager.scrollToPosition(scrollIndex) + } + } + override fun bind( adapter: RecyclerView.Adapter ) { diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/SyncedTabsPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/SyncedTabsPageViewHolder.kt index 68c2cea3e9..c695b71109 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/SyncedTabsPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/SyncedTabsPageViewHolder.kt @@ -27,6 +27,9 @@ class SyncedTabsPageViewHolder( binding.syncedTabsTrayLayout.tabsTrayStore = tabsTrayStore } + override fun detachedFromWindow() = Unit // no-op + override fun attachedToWindow() = Unit // no-op + companion object { const val LAYOUT_ID = R.layout.component_sync_tabs_tray_layout } diff --git a/app/src/main/java/org/mozilla/fenix/theme/FirefoxTheme.kt b/app/src/main/java/org/mozilla/fenix/theme/FirefoxTheme.kt new file mode 100644 index 0000000000..c2ef2c24fb --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/theme/FirefoxTheme.kt @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import mozilla.components.ui.colors.PhotonColors + +/** + * The theme for Mozilla Firefox for Android (Fenix). + */ +@Composable +fun FirefoxTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) darkColorPalette else lightColorPalette + + ProvideFirefoxColors(colors) { + MaterialTheme( + content = content + ) + } +} + +object FirefoxTheme { + val colors: FirefoxColors + @Composable + get() = localFirefoxColors.current +} + +private val darkColorPalette = FirefoxColors( + surface = PhotonColors.DarkGrey50, + textPrimary = PhotonColors.LightGrey05, + textSecondary = PhotonColors.LightGrey05, + dividerLine = PhotonColors.DarkGrey05 +) + +private val lightColorPalette = FirefoxColors( + surface = PhotonColors.White, + textPrimary = PhotonColors.DarkGrey90, + textSecondary = PhotonColors.DarkGrey05, + dividerLine = PhotonColors.LightGrey30 +) + +/** + * A custom Color Palette for Mozilla Firefox for Android (Fenix). + */ +@Stable +class FirefoxColors( + surface: Color, + textPrimary: Color, + textSecondary: Color, + dividerLine: Color +) { + var surface by mutableStateOf(surface) + private set + var textPrimary by mutableStateOf(textPrimary) + private set + var textSecondary by mutableStateOf(textSecondary) + private set + var dividerLine by mutableStateOf(dividerLine) + private set + + fun update(other: FirefoxColors) { + surface = other.surface + textPrimary = other.textPrimary + textSecondary = other.textSecondary + dividerLine = other.dividerLine + } + + fun copy(): FirefoxColors = FirefoxColors( + surface = surface, + textPrimary = textPrimary, + textSecondary = textSecondary, + dividerLine = dividerLine + ) +} + +@Composable +fun ProvideFirefoxColors( + colors: FirefoxColors, + content: @Composable () -> Unit +) { + val colorPalette = remember { + // Explicitly creating a new object here so we don't mutate the initial [colors] + // provided, and overwrite the values set in it. + colors.copy() + } + colorPalette.update(colors) + CompositionLocalProvider(localFirefoxColors provides colorPalette, content = content) +} + +private val localFirefoxColors = staticCompositionLocalOf { + error("No FirefoxColors provided") +} diff --git a/app/src/main/java/org/mozilla/fenix/theme/ThemeManager.kt b/app/src/main/java/org/mozilla/fenix/theme/ThemeManager.kt index c67e0368df..4c89e68bba 100644 --- a/app/src/main/java/org/mozilla/fenix/theme/ThemeManager.kt +++ b/app/src/main/java/org/mozilla/fenix/theme/ThemeManager.kt @@ -14,6 +14,9 @@ import android.os.Build.VERSION.SDK_INT import android.util.TypedValue import android.view.Window import androidx.annotation.StyleRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import mozilla.components.support.ktx.android.content.getColorFromAttr import mozilla.components.support.ktx.android.view.getWindowInsetsController import org.mozilla.fenix.HomeActivity @@ -72,6 +75,12 @@ abstract class ThemeManager { return typedValue.resourceId } + @Composable + fun resolveAttributeColor(attribute: Int): androidx.compose.ui.graphics.Color { + val resourceId = resolveAttribute(attribute, LocalContext.current) + return colorResource(resourceId) + } + private fun updateLightSystemBars(window: Window, context: Context) { if (SDK_INT >= Build.VERSION_CODES.M) { window.statusBarColor = context.getColorFromAttr(android.R.attr.statusBarColor) diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/SwitchWithDescription.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/SwitchWithDescription.kt index 930fe5d1bd..d25105c62f 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/SwitchWithDescription.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/SwitchWithDescription.kt @@ -7,10 +7,11 @@ package org.mozilla.fenix.trackingprotection import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater +import android.widget.TextView import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.SwitchCompat import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.withStyledAttributes -import kotlinx.android.synthetic.main.switch_with_description.view.* import mozilla.components.support.ktx.android.view.putCompoundDrawablesRelativeWithIntrinsicBounds import org.mozilla.fenix.R @@ -20,6 +21,10 @@ class SwitchWithDescription @JvmOverloads constructor( defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr) { + lateinit var switchWidget: SwitchCompat + lateinit var trackingProtectionCategoryTitle: TextView + lateinit var trackingProtectionCategoryItemDescription: TextView + init { LayoutInflater.from(context).inflate(R.layout.switch_with_description, this, true) @@ -28,7 +33,10 @@ class SwitchWithDescription @JvmOverloads constructor( R.styleable.SwitchWithDescription_switchIcon, R.drawable.ic_tracking_protection ) - switch_widget.putCompoundDrawablesRelativeWithIntrinsicBounds( + switchWidget = findViewById(R.id.switch_widget) + trackingProtectionCategoryTitle = findViewById(R.id.trackingProtectionCategoryTitle) + trackingProtectionCategoryItemDescription = findViewById(R.id.trackingProtectionCategoryItemDescription) + switchWidget.putCompoundDrawablesRelativeWithIntrinsicBounds( start = AppCompatResources.getDrawable(context, id) ) trackingProtectionCategoryTitle.text = resources.getString( diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackerBuckets.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackerBuckets.kt index aaf11831ac..cccd6cd7e2 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackerBuckets.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackerBuckets.kt @@ -7,7 +7,6 @@ package org.mozilla.fenix.trackingprotection import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory import mozilla.components.concept.engine.content.blocking.Tracker import mozilla.components.concept.engine.content.blocking.TrackerLog -import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.CROSS_SITE_TRACKING_COOKIES import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.CRYPTOMINERS import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.FINGERPRINTERS @@ -15,7 +14,7 @@ import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.SOCIAL_ME import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.TRACKING_CONTENT import java.util.EnumMap -typealias BucketMap = Map> +typealias BucketMap = Map> /** * Sorts [Tracker]s into different buckets and exposes them as a map. @@ -85,14 +84,14 @@ class TrackerBuckets { * Create an empty mutable map of [TrackingProtectionCategory] to hostnames. */ private fun createMap() = - EnumMap>(TrackingProtectionCategory::class.java) + EnumMap>(TrackingProtectionCategory::class.java) /** * Add the hostname of the [TrackerLog.url] into the map for the given category * from Android Components. The category is transformed into a corresponding Fenix bucket, * and the item is discarded if the category doesn't have a match. */ - private fun MutableMap>.addTrackerHost( + private fun MutableMap>.addTrackerHost( category: TrackingCategory, tracker: TrackerLog ) { @@ -107,13 +106,13 @@ class TrackerBuckets { } /** - * Add the hostname of the [TrackerLog.url] into the map for the given [TrackingProtectionCategory]. + * Add the hostname of the [TrackerLog] into the map for the given [TrackingProtectionCategory]. */ - private fun MutableMap>.addTrackerHost( + private fun MutableMap>.addTrackerHost( key: TrackingProtectionCategory, tracker: TrackerLog ) { - getOrPut(key) { mutableListOf() }.add(tracker.url.tryGetHostFromUrl()) + getOrPut(key) { mutableListOf() }.add(tracker) } } } diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionBlockingFragment.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionBlockingFragment.kt index d071e887ab..cd0dd0e9bf 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionBlockingFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionBlockingFragment.kt @@ -9,8 +9,8 @@ import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_tracking_protection_blocking.* import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.FragmentTrackingProtectionBlockingBinding import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar @@ -23,23 +23,24 @@ class TrackingProtectionBlockingFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val binding = FragmentTrackingProtectionBlockingBinding.bind(view) when (args.protectionMode) { TrackingProtectionMode.STANDARD -> { - category_tracking_content.isVisible = false + binding.categoryTrackingContent.isVisible = false } TrackingProtectionMode.STRICT -> {} TrackingProtectionMode.CUSTOM -> { - category_fingerprinters.isVisible = + binding.categoryFingerprinters.isVisible = settings.blockFingerprintersInCustomTrackingProtection - category_cryptominers.isVisible = + binding.categoryCryptominers.isVisible = settings.blockCryptominersInCustomTrackingProtection - category_cookies.isVisible = + binding.categoryCookies.isVisible = settings.blockCookiesInCustomTrackingProtection - category_tracking_content.isVisible = + binding.categoryTrackingContent.isVisible = settings.blockTrackingContentInCustomTrackingProtection - category_redirect_trackers.isVisible = + binding.categoryRedirectTrackers.isVisible = settings.blockRedirectTrackersInCustomTrackingProtection } } diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionCategoryItem.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionCategoryItem.kt index 9a40e612a9..fb6d8a965b 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionCategoryItem.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionCategoryItem.kt @@ -9,8 +9,8 @@ import android.util.AttributeSet import android.view.LayoutInflater import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.withStyledAttributes -import kotlinx.android.synthetic.main.tracking_protection_category.view.* import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.TrackingProtectionCategoryBinding class TrackingProtectionCategoryItem @JvmOverloads constructor( context: Context, @@ -18,7 +18,10 @@ class TrackingProtectionCategoryItem @JvmOverloads constructor( defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr) { init { - LayoutInflater.from(context).inflate(R.layout.tracking_protection_category, this, true) + val binding = TrackingProtectionCategoryBinding.inflate( + LayoutInflater.from(context), + this + ) context.withStyledAttributes( attrs, @@ -26,13 +29,13 @@ class TrackingProtectionCategoryItem @JvmOverloads constructor( defStyleAttr, 0 ) { - trackingProtectionCategoryTitle?.text = resources.getString( + binding.trackingProtectionCategoryTitle.text = resources.getString( getResourceId( R.styleable.TrackingProtectionCategory_categoryItemTitle, R.string.etp_cookies_title ) ) - trackingProtectionCategoryItemDescription?.text = resources.getString( + binding.trackingProtectionCategoryItemDescription.text = resources.getString( getResourceId( R.styleable.TrackingProtectionCategory_categoryItemDescription, R.string.etp_cookies_description diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionMode.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionMode.kt index 08baeafb0e..abcf61e0f8 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionMode.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionMode.kt @@ -6,7 +6,7 @@ package org.mozilla.fenix.trackingprotection import android.os.Parcelable import androidx.annotation.StringRes -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize import org.mozilla.fenix.R @Parcelize diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionOverlay.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionOverlay.kt deleted file mode 100644 index 3e03dc54d3..0000000000 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionOverlay.kt +++ /dev/null @@ -1,193 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.trackingprotection - -import android.app.Dialog -import android.content.Context -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.view.Gravity -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.widget.ImageView -import androidx.annotation.VisibleForTesting -import androidx.core.view.isVisible -import androidx.core.view.marginTop -import androidx.lifecycle.LifecycleOwner -import com.google.android.material.appbar.AppBarLayout -import kotlinx.android.synthetic.main.tracking_protection_onboarding_popup.* -import kotlinx.android.synthetic.main.tracking_protection_onboarding_popup.view.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.mapNotNull -import mozilla.components.browser.state.selector.selectedTab -import mozilla.components.browser.state.state.SessionState -import mozilla.components.browser.state.store.BrowserStore -import mozilla.components.lib.state.ext.flowScoped -import mozilla.components.support.base.feature.LifecycleAwareFeature -import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged -import org.mozilla.fenix.R -import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.components.metrics.MetricController -import org.mozilla.fenix.components.toolbar.ToolbarPosition -import org.mozilla.fenix.ext.increaseTapArea -import org.mozilla.fenix.utils.Settings - -/** - * Displays an overlay above the tracking protection button in the browser toolbar - * to onboard the user about tracking protection. - */ -@ExperimentalCoroutinesApi -class TrackingProtectionOverlay( - private val context: Context, - private val settings: Settings, - private val metrics: MetricController, - private val store: BrowserStore, - private val lifecycleOwner: LifecycleOwner, - private val getToolbar: () -> View -) : LifecycleAwareFeature { - - @VisibleForTesting - internal var scope: CoroutineScope? = null - - override fun start() { - store.flowScoped(lifecycleOwner) { flow -> - flow.mapNotNull { state -> - state.selectedTab - }.ifChanged { tab -> - tab.content.loading - } - .collect { tab -> - onLoadingStateChanged(tab) - } - } - } - - override fun stop() { - cancelScope() - } - - @VisibleForTesting - internal fun cancelScope() = scope?.cancel() - - @VisibleForTesting - internal fun onLoadingStateChanged(tab: SessionState) { - if (shouldShowTrackingProtectionOnboarding(tab) && - tab.content.progress == FULL_PROGRESS && - settings.shouldUseTrackingProtection - ) { - showTrackingProtectionOnboarding() - } - } - - private fun shouldShowTrackingProtectionOnboarding(tab: SessionState) = - tab.trackingProtection.enabled && - tab.trackingProtection.blockedTrackers.isNotEmpty() && - settings.shouldShowTrackingProtectionCfr - - @Suppress("MagicNumber", "InflateParams") - private fun showTrackingProtectionOnboarding() { - - if (!getToolbar().hasWindowFocus()) return - - val toolbarPosition = settings.toolbarPosition - - when (toolbarPosition) { - ToolbarPosition.BOTTOM -> { - if (getToolbar().translationY > 0) { - return - } - } - ToolbarPosition.TOP -> { - val appBarLayout = getToolbar().parent as? AppBarLayout - appBarLayout?.let { appBar -> - if (appBar.y != 0.toFloat()) { - return - } - } - } - } - - val trackingOnboardingDialog = object : Dialog(context) { - override fun onTouchEvent(event: MotionEvent): Boolean { - - if (event.action == MotionEvent.ACTION_DOWN) { - metrics.track(Event.ContextualHintETPOutsideTap) - } - return super.onTouchEvent(event) - } - } - - val layout = LayoutInflater.from(context) - .inflate(R.layout.tracking_protection_onboarding_popup, null) - - layout.drop_down_triangle.isVisible = toolbarPosition == ToolbarPosition.TOP - layout.pop_up_triangle.isVisible = toolbarPosition == ToolbarPosition.BOTTOM - - layout.onboarding_message.text = - context.getString( - R.string.etp_onboarding_cfr_message, - context.getString(R.string.app_name) - ) - - val closeButton = layout.findViewById(R.id.close_onboarding) - closeButton.increaseTapArea(BUTTON_INCREASE_DPS) - closeButton.setOnClickListener { - metrics.track(Event.ContextualHintETPDismissed) - trackingOnboardingDialog.dismiss() - } - - val res = context.resources - val triangleWidthPx = res.getDimension(R.dimen.cfr_triangle_height) - val triangleMarginStartPx = res.getDimension(R.dimen.cfr_triangle_margin_edge) - - val toolbar = getToolbar() - val trackingProtectionIcon: View = - toolbar.findViewById(R.id.mozac_browser_toolbar_tracking_protection_indicator) - - val xOffset = triangleMarginStartPx + triangleWidthPx / 2 - - val gravity = Gravity.START or toolbarPosition.androidGravity - - trackingOnboardingDialog.apply { - setContentView(layout) - setCancelable(false) - // removing title or setting it as an empty string does not prevent a11y services from assigning one - setTitle(" ") - } - - trackingOnboardingDialog.window?.let { - it.setGravity(gravity) - val attr = it.attributes - attr.x = - (trackingProtectionIcon.x + trackingProtectionIcon.width / 2 - xOffset).toInt() - attr.y = - (trackingProtectionIcon.y + trackingProtectionIcon.height - trackingProtectionIcon.marginTop).toInt() - it.attributes = attr - it.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - } - - val etpShield = - getToolbar().findViewById(R.id.mozac_browser_toolbar_tracking_protection_indicator) - trackingOnboardingDialog.message.setOnClickListener { - metrics.track(Event.ContextualHintETPInsideTap) - trackingOnboardingDialog.dismiss() - etpShield.performClick() - } - - metrics.track(Event.ContextualHintETPDisplayed) - trackingOnboardingDialog.show() - settings.lastCfrShownTimeInMillis = System.currentTimeMillis() - settings.incrementTrackingProtectionOnboardingCount() - } - - private companion object { - private const val FULL_PROGRESS = 100 - private const val BUTTON_INCREASE_DPS = 12 - } -} diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt index ec7ad0cdc4..9a6476323d 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt @@ -19,10 +19,10 @@ import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.view.ContextThemeWrapper import androidx.lifecycle.lifecycleScope import androidx.lifecycle.whenStarted +import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog -import kotlinx.android.synthetic.main.fragment_tracking_protection.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.mapNotNull @@ -37,14 +37,16 @@ import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.ext.components +import org.mozilla.fenix.databinding.FragmentTrackingProtectionBinding import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.settings.SupportUtils @ExperimentalCoroutinesApi @Suppress("TooManyFunctions") @@ -79,17 +81,17 @@ class TrackingProtectionPanelDialogFragment : AppCompatDialogFragment(), UserInt inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { val store = requireComponents.core.store val view = inflateRootView(container) - val tab = store.state.findTabOrCustomTab(provideTabId()) + val tab = store.state.findTabOrCustomTab(provideCurrentTabId()) trackingProtectionStore = StoreProvider.get(this) { TrackingProtectionStore( TrackingProtectionState( - tab, - args.url, - args.trackingProtectionEnabled, + tab = tab, + url = args.url, + isTrackingProtectionEnabled = args.trackingProtectionEnabled, listTrackers = listOf(), mode = TrackingProtectionState.Mode.Normal, lastAccessedCategory = "" @@ -97,12 +99,19 @@ class TrackingProtectionPanelDialogFragment : AppCompatDialogFragment(), UserInt ) } trackingProtectionInteractor = TrackingProtectionPanelInteractor( - trackingProtectionStore, - ::toggleTrackingProtection, - ::openTrackingProtectionSettings + context = requireContext(), + fragment = this, + store = trackingProtectionStore, + navController = { findNavController() }, + openTrackingProtectionSettings = ::openTrackingProtectionSettings, + openLearnMoreLink = ::handleLearnMoreClicked, + sitePermissions = args.sitePermissions, + gravity = args.gravity, + getCurrentTab = ::getCurrentTab ) + val binding = FragmentTrackingProtectionBinding.bind(view) trackingProtectionView = - TrackingProtectionPanelView(view.fragment_tp, trackingProtectionInteractor) + TrackingProtectionPanelView(binding.fragmentTp, trackingProtectionInteractor) tab?.let { updateTrackers(it) } return view } @@ -143,23 +152,14 @@ class TrackingProtectionPanelDialogFragment : AppCompatDialogFragment(), UserInt ) } - private fun toggleTrackingProtection(isEnabled: Boolean) { - context?.let { context -> - val session = context.components.core.store.state.findTabOrCustomTab(args.sessionId) - session?.let { - if (isEnabled) { - trackingProtectionUseCases.removeException(it.id) - } else { - context.metrics.track(Event.TrackingProtectionException) - trackingProtectionUseCases.addException(it.id) - } - - with(context.components) { - useCases.sessionUseCases.reload.invoke(session.id) - } - } - } - trackingProtectionStore.dispatch(TrackingProtectionAction.TrackerBlockingChanged(isEnabled)) + private fun handleLearnMoreClicked() { + (activity as HomeActivity).openToBrowserAndLoad( + searchTermOrURL = SupportUtils.getGenericSumoURLForTopic( + SupportUtils.SumoTopic.SMARTBLOCK + ), + newTab = true, + from = BrowserDirection.FromTrackingProtectionDialog + ) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -214,7 +214,7 @@ class TrackingProtectionPanelDialogFragment : AppCompatDialogFragment(), UserInt internal fun observeUrlChange(store: BrowserStore) { consumeFlow(store) { flow -> flow.mapNotNull { state -> - state.findTabOrCustomTab(provideTabId()) + state.findTabOrCustomTab(provideCurrentTabId()) }.ifChanged { tab -> tab.content.url } .collect { trackingProtectionStore.dispatch(TrackingProtectionAction.UrlChange(it.content.url)) @@ -223,13 +223,13 @@ class TrackingProtectionPanelDialogFragment : AppCompatDialogFragment(), UserInt } @VisibleForTesting - internal fun provideTabId(): String = args.sessionId + internal fun provideCurrentTabId(): String = args.sessionId @VisibleForTesting internal fun observeTrackersChange(store: BrowserStore) { consumeFlow(store) { flow -> flow.mapNotNull { state -> - state.findTabOrCustomTab(provideTabId()) + state.findTabOrCustomTab(provideCurrentTabId()) }.ifAnyChanged { tab -> arrayOf( tab.trackingProtection.blockedTrackers, @@ -240,4 +240,8 @@ class TrackingProtectionPanelDialogFragment : AppCompatDialogFragment(), UserInt } } } + + private fun getCurrentTab(): SessionState? { + return requireComponents.core.store.state.findTabOrCustomTab(args.sessionId) + } } diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelInteractor.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelInteractor.kt index d6e326b4a5..9f48b71722 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelInteractor.kt @@ -4,28 +4,69 @@ package org.mozilla.fenix.trackingprotection +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.navigation.NavController +import mozilla.components.browser.state.state.SessionState +import mozilla.components.concept.engine.permission.SitePermissions +import org.mozilla.fenix.browser.BrowserFragmentDirections +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.runIfFragmentIsAttached + /** * Interactor for the tracking protection panel * Provides implementations for the TrackingProtectionPanelViewInteractor */ +@Suppress("LongParameterList") class TrackingProtectionPanelInteractor( + private val context: Context, + private val fragment: Fragment, private val store: TrackingProtectionStore, - private val toggleTrackingProtection: (Boolean) -> Unit, - private val openTrackingProtectionSettings: () -> Unit + private val navController: () -> NavController, + private val openTrackingProtectionSettings: () -> Unit, + private val openLearnMoreLink: () -> Unit, + internal var sitePermissions: SitePermissions?, + private val gravity: Int, + private val getCurrentTab: () -> SessionState? ) : TrackingProtectionPanelViewInteractor { + override fun openDetails(category: TrackingProtectionCategory, categoryBlocked: Boolean) { store.dispatch(TrackingProtectionAction.EnterDetailsMode(category, categoryBlocked)) } + override fun onLearnMoreClicked() { + openLearnMoreLink() + } + override fun selectTrackingProtectionSettings() { openTrackingProtectionSettings.invoke() } - override fun trackingProtectionToggled(isEnabled: Boolean) { - toggleTrackingProtection.invoke(isEnabled) + override fun onBackPressed() { + getCurrentTab()?.let { tab -> + context.components.useCases.trackingProtectionUseCases.containsException(tab.id) { contains -> + fragment.runIfFragmentIsAttached { + navController().popBackStack() + val isTrackingProtectionEnabled = tab.trackingProtection.enabled && !contains + val directions = + BrowserFragmentDirections.actionGlobalQuickSettingsSheetDialogFragment( + sessionId = tab.id, + url = tab.content.url, + title = tab.content.title, + isSecured = tab.content.securityInfo.secure, + sitePermissions = sitePermissions, + gravity = gravity, + certificateName = tab.content.securityInfo.issuer, + permissionHighlights = tab.content.permissionHighlights, + isTrackingProtectionEnabled = isTrackingProtectionEnabled + ) + navController().navigate(directions) + } + } + } } - override fun onBackPressed() { + override fun onExitDetailMode() { store.dispatch(TrackingProtectionAction.ExitDetailsMode) } } diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelView.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelView.kt index 7b86e88a00..0c7570be03 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelView.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelView.kt @@ -4,32 +4,33 @@ package org.mozilla.fenix.trackingprotection +import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent +import androidx.annotation.VisibleForTesting import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.net.toUri +import androidx.core.text.HtmlCompat import androidx.core.view.AccessibilityDelegateCompat import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.core.view.isGone import androidx.core.view.isVisible -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.component_tracking_protection_panel.* -import kotlinx.android.synthetic.main.component_tracking_protection_panel.details_blocking_header -import kotlinx.android.synthetic.main.switch_with_description.view.* import mozilla.components.browser.state.state.CustomTabSessionState -import mozilla.components.support.ktx.android.net.hostWithoutCommonPrefixes +import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.databinding.ComponentTrackingProtectionPanelBinding +import org.mozilla.fenix.ext.addUnderline import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.CROSS_SITE_TRACKING_COOKIES import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.CRYPTOMINERS import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.FINGERPRINTERS +import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.REDIRECT_TRACKERS import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.SOCIAL_MEDIA_TRACKERS import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.TRACKING_CONTENT -import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.REDIRECT_TRACKERS /** * Interface for the TrackingProtectionPanelViewInteractor. This interface is implemented by objects that want @@ -42,15 +43,14 @@ interface TrackingProtectionPanelViewInteractor { fun selectTrackingProtectionSettings() /** - * Called whenever the tracking protection toggle for this site is toggled - * @param isEnabled new status of session tracking protection + * Called whenever back is pressed */ - fun trackingProtectionToggled(isEnabled: Boolean) + fun onBackPressed() /** - * Called whenever back is pressed + * Called whenever back button is pressed in Detail mode. */ - fun onBackPressed() + fun onExitDetailMode() /** * Called whenever an active tracking protection category is tapped @@ -58,6 +58,11 @@ interface TrackingProtectionPanelViewInteractor { * @param categoryBlocked The trackers from this category were blocked */ fun openDetails(category: TrackingProtectionCategory, categoryBlocked: Boolean) + + /** + * Called when the Learn more link for SmartBlock is clicked. + */ + fun onLearnMoreClicked() } /** @@ -65,13 +70,18 @@ interface TrackingProtectionPanelViewInteractor { */ @SuppressWarnings("TooManyFunctions") class TrackingProtectionPanelView( - override val containerView: ViewGroup, + val containerView: ViewGroup, val interactor: TrackingProtectionPanelInteractor -) : LayoutContainer, View.OnClickListener { +) : View.OnClickListener { + + @VisibleForTesting + internal val binding = ComponentTrackingProtectionPanelBinding.inflate( + LayoutInflater.from(containerView.context), + containerView, + true + ) - val view: ConstraintLayout = LayoutInflater.from(containerView.context) - .inflate(R.layout.component_tracking_protection_panel, containerView, true) - .findViewById(R.id.panel_wrapper) + val view: ConstraintLayout = binding.panelWrapper private var mode: TrackingProtectionState.Mode = TrackingProtectionState.Mode.Normal @@ -80,12 +90,18 @@ class TrackingProtectionPanelView( private var shouldFocusAccessibilityView: Boolean = true init { - protection_settings.setOnClickListener { + binding.protectionSettings.setOnClickListener { interactor.selectTrackingProtectionSettings() } - details_back.setOnClickListener { + + binding.detailsBack.setOnClickListener { + interactor.onExitDetailMode() + } + + binding.navigateBack.setOnClickListener { interactor.onBackPressed() } + setCategoryClickListeners() } @@ -101,19 +117,17 @@ class TrackingProtectionPanelView( ) } - setAccessibilityViewHierarchy(details_back, category_title) + setAccessibilityViewHierarchy(binding.detailsBack, binding.categoryTitle) } private fun setUIForNormalMode(state: TrackingProtectionState) { - details_mode.visibility = View.GONE - normal_mode.visibility = View.VISIBLE - protection_settings.isGone = state.tab is CustomTabSessionState + binding.detailsMode.visibility = View.GONE + binding.normalMode.visibility = View.VISIBLE - not_blocking_header.isGone = bucketedTrackers.loadedIsEmpty() - bindUrl(state.url) - bindTrackingProtectionInfo(state.isTrackingProtectionEnabled) + binding.protectionSettings.isGone = state.tab is CustomTabSessionState + binding.notBlockingHeader.isGone = bucketedTrackers.loadedIsEmpty() + binding.blockingHeader.isGone = bucketedTrackers.blockedIsEmpty() - blocking_header.isGone = bucketedTrackers.blockedIsEmpty() updateCategoryVisibility() focusAccessibilityLastUsedCategory(state.lastAccessedCategory) } @@ -122,12 +136,30 @@ class TrackingProtectionPanelView( category: TrackingProtectionCategory, categoryBlocked: Boolean ) { - normal_mode.visibility = View.GONE - details_mode.visibility = View.VISIBLE - category_title.setText(category.title) - blocking_text_list.text = bucketedTrackers.get(category, categoryBlocked).joinToString("\n") - category_description.setText(category.description) - details_blocking_header.setText( + val containASmartBlockItem = bucketedTrackers.get(category, categoryBlocked).any { it.unBlockedBySmartBlock } + binding.normalMode.visibility = View.GONE + binding.detailsMode.visibility = View.VISIBLE + binding.categoryTitle.setText(category.title) + + binding.smartblockDescription.isVisible = containASmartBlockItem + binding.smartblockLearnMore.isVisible = containASmartBlockItem + + val trackersList = bucketedTrackers.get(category, categoryBlocked).joinToString("
") { + createTrackerItem(it, containASmartBlockItem) + } + + binding.blockingTextList.text = HtmlCompat.fromHtml(trackersList, HtmlCompat.FROM_HTML_MODE_COMPACT) + + // show description for SmartBlock tracking content in details + if (containASmartBlockItem) { + with(binding.smartblockLearnMore) { + movementMethod = LinkMovementMethod.getInstance() + addUnderline() + setOnClickListener { interactor.onLearnMoreClicked() } + } + } + binding.categoryDescription.setText(category.description) + binding.detailsBlockingHeader.setText( if (categoryBlocked) { R.string.enhanced_tracking_protection_blocked } else { @@ -135,8 +167,17 @@ class TrackingProtectionPanelView( } ) - details_back.requestFocus() - details_back.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) + binding.detailsBack.requestFocus() + binding.detailsBack.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) + } + + private fun createTrackerItem(tracker: TrackerLog, isUnblockedSection: Boolean): String { + val space = if (isUnblockedSection) "  " else "" + return if (tracker.unBlockedBySmartBlock) { + "*${tracker.url.tryGetHostFromUrl()}" + } else { + "$space${tracker.url.tryGetHostFromUrl()}" + } } /** @@ -160,58 +201,59 @@ class TrackingProtectionPanelView( */ private fun getLastUsedCategoryView(categoryTitle: String) = when (categoryTitle) { CROSS_SITE_TRACKING_COOKIES.name -> { - if (cross_site_tracking.isGone) cross_site_tracking_loaded else cross_site_tracking + if (binding.crossSiteTracking.isGone) binding.crossSiteTrackingLoaded else binding.crossSiteTracking } SOCIAL_MEDIA_TRACKERS.name -> { - if (social_media_trackers.isGone) social_media_trackers_loaded else social_media_trackers + if (binding.socialMediaTrackers.isGone) binding.socialMediaTrackersLoaded else binding.socialMediaTrackers } FINGERPRINTERS.name -> { - if (fingerprinters.isGone) fingerprinters_loaded else fingerprinters + if (binding.fingerprinters.isGone) binding.fingerprintersLoaded else binding.fingerprinters } TRACKING_CONTENT.name -> { - if (tracking_content.isGone) tracking_content_loaded else tracking_content + if (binding.trackingContent.isGone) binding.trackingContentLoaded else binding.trackingContent } CRYPTOMINERS.name -> { - if (cryptominers.isGone) cryptominers_loaded else cryptominers + if (binding.cryptominers.isGone) binding.cryptominersLoaded else binding.cryptominers } REDIRECT_TRACKERS.name -> { - if (redirect_trackers.isGone) redirect_trackers_loaded else redirect_trackers + if (binding.redirectTrackers.isGone) binding.redirectTrackersLoaded else binding.redirectTrackers } else -> null } private fun updateCategoryVisibility() { - cross_site_tracking.isGone = + binding.crossSiteTracking.isGone = bucketedTrackers.get(CROSS_SITE_TRACKING_COOKIES, true).isEmpty() - social_media_trackers.isGone = + binding.socialMediaTrackers.isGone = bucketedTrackers.get(SOCIAL_MEDIA_TRACKERS, true).isEmpty() - fingerprinters.isGone = bucketedTrackers.get(FINGERPRINTERS, true).isEmpty() - tracking_content.isGone = bucketedTrackers.get(TRACKING_CONTENT, true).isEmpty() - cryptominers.isGone = bucketedTrackers.get(CRYPTOMINERS, true).isEmpty() - redirect_trackers.isGone = bucketedTrackers.get(REDIRECT_TRACKERS, true).isEmpty() + binding.fingerprinters.isGone = bucketedTrackers.get(FINGERPRINTERS, true).isEmpty() + binding.trackingContent.isGone = bucketedTrackers.get(TRACKING_CONTENT, true).isEmpty() + binding.cryptominers.isGone = bucketedTrackers.get(CRYPTOMINERS, true).isEmpty() + binding.redirectTrackers.isGone = bucketedTrackers.get(REDIRECT_TRACKERS, true).isEmpty() - cross_site_tracking_loaded.isGone = + binding.crossSiteTrackingLoaded.isGone = bucketedTrackers.get(CROSS_SITE_TRACKING_COOKIES, false).isEmpty() - social_media_trackers_loaded.isGone = + binding.socialMediaTrackersLoaded.isGone = bucketedTrackers.get(SOCIAL_MEDIA_TRACKERS, false).isEmpty() - fingerprinters_loaded.isGone = bucketedTrackers.get(FINGERPRINTERS, false).isEmpty() - tracking_content_loaded.isGone = bucketedTrackers.get(TRACKING_CONTENT, false).isEmpty() - cryptominers_loaded.isGone = bucketedTrackers.get(CRYPTOMINERS, false).isEmpty() - redirect_trackers_loaded.isGone = bucketedTrackers.get(REDIRECT_TRACKERS, false).isEmpty() + binding.fingerprintersLoaded.isGone = bucketedTrackers.get(FINGERPRINTERS, false).isEmpty() + binding.trackingContentLoaded.isGone = bucketedTrackers.get(TRACKING_CONTENT, false).isEmpty() + binding.cryptominersLoaded.isGone = bucketedTrackers.get(CRYPTOMINERS, false).isEmpty() + binding.redirectTrackersLoaded.isGone = bucketedTrackers.get(REDIRECT_TRACKERS, false).isEmpty() } private fun setCategoryClickListeners() { - social_media_trackers.setOnClickListener(this) - fingerprinters.setOnClickListener(this) - cross_site_tracking.setOnClickListener(this) - tracking_content.setOnClickListener(this) - cryptominers.setOnClickListener(this) - cross_site_tracking_loaded.setOnClickListener(this) - social_media_trackers_loaded.setOnClickListener(this) - fingerprinters_loaded.setOnClickListener(this) - tracking_content_loaded.setOnClickListener(this) - cryptominers_loaded.setOnClickListener(this) - redirect_trackers_loaded.setOnClickListener(this) + binding.socialMediaTrackers.setOnClickListener(this) + binding.fingerprinters.setOnClickListener(this) + binding.crossSiteTracking.setOnClickListener(this) + binding.trackingContent.setOnClickListener(this) + binding.cryptominers.setOnClickListener(this) + + binding.crossSiteTrackingLoaded.setOnClickListener(this) + binding.socialMediaTrackersLoaded.setOnClickListener(this) + binding.fingerprintersLoaded.setOnClickListener(this) + binding.trackingContentLoaded.setOnClickListener(this) + binding.cryptominersLoaded.setOnClickListener(this) + binding.redirectTrackersLoaded.setOnClickListener(this) } override fun onClick(v: View) { @@ -221,21 +263,6 @@ class TrackingProtectionPanelView( interactor.openDetails(category, categoryBlocked = !isLoaded(v)) } - private fun bindUrl(url: String) { - this.url.text = url.toUri().hostWithoutCommonPrefixes - } - - private fun bindTrackingProtectionInfo(isTrackingProtectionOn: Boolean) { - trackingProtectionSwitch.trackingProtectionCategoryItemDescription.text = - view.context.getString(if (isTrackingProtectionOn) R.string.etp_panel_on else R.string.etp_panel_off) - trackingProtectionSwitch.switch_widget.isChecked = isTrackingProtectionOn - trackingProtectionSwitch.switch_widget.jumpDrawablesToCurrentState() - - trackingProtectionSwitch.switch_widget.setOnCheckedChangeListener { _, isChecked -> - interactor.trackingProtectionToggled(isChecked) - } - } - fun onBackPressed(): Boolean { return when (mode) { is TrackingProtectionState.Mode.Details -> { diff --git a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionStore.kt b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionStore.kt index 394587b9fe..4ba568ff4d 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionStore.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionStore.kt @@ -34,8 +34,6 @@ sealed class TrackingProtectionAction : Action { data class UrlChange(val url: String) : TrackingProtectionAction() data class TrackerLogChange(val listTrackers: List) : TrackingProtectionAction() - data class TrackerBlockingChanged(val isTrackingProtectionEnabled: Boolean) : - TrackingProtectionAction() object ExitDetailsMode : TrackingProtectionAction() data class EnterDetailsMode( @@ -47,12 +45,14 @@ sealed class TrackingProtectionAction : Action { /** * The state for the Tracking Protection Panel + * @property tab Current session to display * @property url Current URL to display - * @property isTrackingProtectionEnabled Current status of tracking protection for this session (ie is an exception) + * @property isTrackingProtectionEnabled Current status of tracking protection for this session + * (ie is an exception) * @property listTrackers Current Tracker Log list of blocked and loaded tracker categories * @property mode Current Mode of TrackingProtection * @property lastAccessedCategory Remembers the last accessed details category, used to move - * accessibly focus after returning from details_moode + * accessibly focus after returning from details_mode */ data class TrackingProtectionState( val tab: SessionState?, @@ -132,7 +132,5 @@ fun trackingProtectionStateReducer( ), lastAccessedCategory = action.category.name ) - is TrackingProtectionAction.TrackerBlockingChanged -> - state.copy(isTrackingProtectionEnabled = action.isTrackingProtectionEnabled) } } diff --git a/app/src/main/java/org/mozilla/fenix/utils/ClearableEditText.kt b/app/src/main/java/org/mozilla/fenix/utils/ClearableEditText.kt index 4f081e0e67..7a4a7ebf1c 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/ClearableEditText.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/ClearableEditText.kt @@ -53,7 +53,7 @@ class ClearableEditText @JvmOverloads constructor( // lengthAfter has inconsistent behaviour when there are spaces in the entered text, so we'll use text.length. val textLength = text?.length ?: 0 val drawable = if (shouldShowClearButton(textLength)) { - AppCompatResources.getDrawable(context, R.drawable.ic_clear)?.apply { + AppCompatResources.getDrawable(context, R.drawable.mozac_ic_clear)?.apply { colorFilter = createBlendModeColorFilterCompat(context.getColorFromAttr(R.attr.primaryText), SRC_IN) } } else { diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index a10dd2a5ba..413d665dda 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -32,6 +32,7 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.metrics.MozillaProductDetector import org.mozilla.fenix.components.settings.counterPreference import org.mozilla.fenix.components.settings.featureFlagPreference +import org.mozilla.fenix.components.settings.lazyFeatureFlagPreference import org.mozilla.fenix.components.toolbar.ToolbarPosition import org.mozilla.fenix.experiments.ExperimentBranch import org.mozilla.fenix.experiments.FeatureId @@ -66,6 +67,7 @@ class Settings(private val appContext: Context) : PreferencesHolder { private const val CFR_COUNT_CONDITION_FOCUS_INSTALLED = 1 private const val CFR_COUNT_CONDITION_FOCUS_NOT_INSTALLED = 3 private const val APP_LAUNCHES_TO_SHOW_DEFAULT_BROWSER_CARD = 3 + private const val INACTIVE_TAB_MINIMUM_TO_SHOW_AUTO_CLOSE_DIALOG = 20 const val FOUR_HOURS_MS = 60 * 60 * 4 * 1000L const val ONE_DAY_MS = 60 * 60 * 24 * 1000L @@ -107,9 +109,10 @@ class Settings(private val appContext: Context) : PreferencesHolder { override val preferences: SharedPreferences = appContext.getSharedPreferences(FENIX_PREFERENCES, MODE_PRIVATE) - var showTopFrecentSites by booleanPreference( + var showTopFrecentSites by lazyFeatureFlagPreference( appContext.getPreferenceKey(R.string.pref_key_enable_top_frecent_sites), - default = true + featureFlag = true, + default = { appContext.components.analytics.features.homeScreen.isTopSitesActive() } ) var numberOfAppLaunches by intPreference( @@ -237,16 +240,8 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false ) - private var trackingProtectionOnboardingShownThisSession = false var isOverrideTPPopupsForPerformanceTest = false - val shouldShowTrackingProtectionCfr: Boolean - get() = !isOverrideTPPopupsForPerformanceTest && canShowCfr && - ( - trackingProtectionOnboardingCount.underMaxCount() && - !trackingProtectionOnboardingShownThisSession - ) - var showSecretDebugMenuThisSession = false val shouldShowSecurityPinWarningSync: Boolean @@ -360,6 +355,19 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false ) + val isFirstRun: Boolean = + if (!preferences.contains(appContext.getPreferenceKey(R.string.pref_key_is_first_run))) { + preferences.edit() + .putBoolean( + appContext.getPreferenceKey(R.string.pref_key_is_first_run), + false + ) + .apply() + true + } else { + false + } + /** * Indicates the last time when the user was interacting with the [BrowserFragment], * This is useful to determine if the user has to start on the [HomeFragment] @@ -407,6 +415,24 @@ class Settings(private val appContext: Context) : PreferencesHolder { } } + /** + * Indicates if the user has enabled the inactive tabs feature. + */ + var inactiveTabsAreEnabled by featureFlagPreference( + appContext.getPreferenceKey(R.string.pref_key_inactive_tabs), + default = FeatureFlags.inactiveTabs, + featureFlag = FeatureFlags.inactiveTabs + ) + + /** + * Indicates if the user has enabled the search term tab groups feature. + */ + var searchTermTabGroupsAreEnabled by featureFlagPreference( + appContext.getPreferenceKey(R.string.pref_key_search_term_tab_groups), + default = FeatureFlags.tabGroupFeature, + featureFlag = FeatureFlags.tabGroupFeature + ) + @VisibleForTesting internal fun timeNowInMillis(): Long = System.currentTimeMillis() @@ -762,6 +788,14 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false ) + /** + * Indicates if the home onboarding dialog has already shown before. + */ + var hasShownHomeOnboardingDialog by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_has_shown_home_onboarding), + default = false + ) + fun incrementVisitedInstallableCount() = pwaInstallableVisitCount.increment() @VisibleForTesting(otherwise = PRIVATE) @@ -809,17 +843,47 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = true ) - @VisibleForTesting(otherwise = PRIVATE) - internal val trackingProtectionOnboardingCount = counterPreference( - appContext.getPreferenceKey(R.string.pref_key_tracking_protection_onboarding), - maxCount = 1 + var shouldShowInactiveTabsOnboardingPopup by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_should_show_inactive_tabs_popup), + default = true ) - fun incrementTrackingProtectionOnboardingCount() { - trackingProtectionOnboardingShownThisSession = true - trackingProtectionOnboardingCount.increment() + /** + * Indicates if the auto-close dialog for inactive tabs has been dismissed before. + */ + var hasInactiveTabsAutoCloseDialogBeenDismissed by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_has_inactive_tabs_auto_close_dialog_dismissed), + default = false + ) + + /** + * Indicates if the auto-close dialog should be visible based on + * if the user has dismissed it before [hasInactiveTabsAutoCloseDialogBeenDismissed], + * if the minimum number of tabs has been accumulated [numbersOfTabs] + * and if the auto-close setting is already set to [closeTabsAfterOneMonth]. + */ + fun shouldShowInactiveTabsAutoCloseDialog(numbersOfTabs: Int): Boolean { + return !hasInactiveTabsAutoCloseDialogBeenDismissed && + numbersOfTabs >= INACTIVE_TAB_MINIMUM_TO_SHOW_AUTO_CLOSE_DIALOG && + !closeTabsAfterOneMonth } + /** + * Indicates if the jump back in CRF should be shown. + */ + var shouldShowJumpBackInCFR by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_should_show_jump_back_in_tabs_popup), + default = true + ) + + /** + * Should we display a feedback request to the user when he turns off the Inactive Tabs feature + */ + var shouldShowInactiveTabsTurnOffSurvey by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_should_show_inactive_tabs_turn_off_survey), + default = true + ) + fun getSitePermissionsPhoneFeatureAction( feature: PhoneFeature, default: Action = Action.ASK_TO_ALLOW @@ -1140,10 +1204,30 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false ) - var historyMetadataFeature by featureFlagPreference( + var historyMetadataUIFeature by lazyFeatureFlagPreference( appContext.getPreferenceKey(R.string.pref_key_history_metadata_feature), - default = FeatureFlags.historyMetadataFeature, - featureFlag = FeatureFlags.historyMetadataFeature || isHistoryMetadataEnabled + default = { appContext.components.analytics.features.homeScreen.isRecentExplorationsActive() }, + featureFlag = FeatureFlags.historyMetadataUIFeature || isHistoryMetadataEnabled + ) + + /** + * Indicates if the recent tabs functionality should be visible. + * Returns true if the [FeatureFlags.showRecentTabsFeature] and [R.string.pref_key_recent_tabs] are true. + */ + var showRecentTabsFeature by lazyFeatureFlagPreference( + appContext.getPreferenceKey(R.string.pref_key_recent_tabs), + featureFlag = FeatureFlags.showRecentTabsFeature, + default = { appContext.components.analytics.features.homeScreen.isRecentlyTabsActive() } + ) + + /** + * Indicates if the recent saved bookmarks functionality should be visible. + * Returns true if the [FeatureFlags.showRecentTabsFeature] and [R.string.pref_key_recent_bookmarks] are true. + */ + var showRecentBookmarksFeature by lazyFeatureFlagPreference( + appContext.getPreferenceKey(R.string.pref_key_recent_bookmarks), + default = { appContext.components.analytics.features.homeScreen.isRecentlySavedActive() }, + featureFlag = FeatureFlags.recentBookmarksFeature ) /** @@ -1170,4 +1254,10 @@ class Settings(private val appContext: Context) : PreferencesHolder { appContext.getPreferenceKey(R.string.pref_key_credit_cards_save_and_autofill_cards), default = true ) + + var showPocketRecommendationsFeature by lazyFeatureFlagPreference( + appContext.getPreferenceKey(R.string.pref_key_pocket_homescreen_recommendations), + featureFlag = FeatureFlags.isPocketRecommendationsFeatureEnabled(appContext), + default = { appContext.components.analytics.features.homeScreen.isPocketRecommendationsActive() }, + ) } diff --git a/app/src/main/java/org/mozilla/fenix/utils/ToolbarPopupWindow.kt b/app/src/main/java/org/mozilla/fenix/utils/ToolbarPopupWindow.kt index db2d0b5a9c..da0cadc58d 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/ToolbarPopupWindow.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/ToolbarPopupWindow.kt @@ -14,7 +14,6 @@ import android.widget.PopupWindow import androidx.annotation.VisibleForTesting import androidx.core.view.isVisible import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.browser_toolbar_popup_window.view.* import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.store.BrowserStore import org.mozilla.fenix.R @@ -23,6 +22,7 @@ import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import java.lang.ref.WeakReference import mozilla.components.browser.state.selector.findCustomTab +import org.mozilla.fenix.databinding.BrowserToolbarPopupWindowBinding object ToolbarPopupWindow { fun show( @@ -38,10 +38,9 @@ object ToolbarPopupWindow { val isCustomTabSession = customTabId != null - val customView = LayoutInflater.from(context) - .inflate(R.layout.browser_toolbar_popup_window, null) + val binding = BrowserToolbarPopupWindowBinding.inflate(LayoutInflater.from(context)) val popupWindow = PopupWindow( - customView, + binding.root, LinearLayout.LayoutParams.WRAP_CONTENT, context.resources.getDimensionPixelSize(R.dimen.context_menu_height), true @@ -53,13 +52,13 @@ object ToolbarPopupWindow { // See: https://github.com/mozilla-mobile/fenix/issues/10027 popupWindow.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - customView.copy.isVisible = copyVisible + binding.copy.isVisible = copyVisible - customView.paste.isVisible = !clipboard.text.isNullOrEmpty() && !isCustomTabSession - customView.paste_and_go.isVisible = + binding.paste.isVisible = !clipboard.text.isNullOrEmpty() && !isCustomTabSession + binding.pasteAndGo.isVisible = !clipboard.text.isNullOrEmpty() && !isCustomTabSession - customView.copy.setOnClickListener { + binding.copy.setOnClickListener { popupWindow.dismiss() clipboard.text = getUrlForClipboard( it.context.components.core.store, @@ -78,12 +77,12 @@ object ToolbarPopupWindow { context.components.analytics.metrics.track(Event.CopyUrlUsed) } - customView.paste.setOnClickListener { + binding.paste.setOnClickListener { popupWindow.dismiss() handlePaste(clipboard.text!!) } - customView.paste_and_go.setOnClickListener { + binding.pasteAndGo.setOnClickListener { popupWindow.dismiss() handlePasteAndGo(clipboard.text!!) } diff --git a/app/src/main/java/org/mozilla/fenix/utils/view/ViewHolder.kt b/app/src/main/java/org/mozilla/fenix/utils/view/ViewHolder.kt index 21a1eb6382..61901a8d1e 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/view/ViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/view/ViewHolder.kt @@ -6,12 +6,11 @@ package org.mozilla.fenix.utils.view import android.view.View import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.extensions.LayoutContainer /** * A base class for all recycler view holders supporting Android Extensions-style view access. * This allows views to be used without an `itemView.` prefix, and additionally caches them. */ abstract class ViewHolder( - override val containerView: View -) : RecyclerView.ViewHolder(containerView), LayoutContainer + val containerView: View +) : RecyclerView.ViewHolder(containerView) diff --git a/app/src/main/proto/selected_pocket_stories_categories.proto b/app/src/main/proto/selected_pocket_stories_categories.proto new file mode 100644 index 0000000000..b65bb34625 --- /dev/null +++ b/app/src/main/proto/selected_pocket_stories_categories.proto @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +syntax = "proto3"; + +package proto; + +option java_package = "org.mozilla.fenix.datastore"; +option java_multiple_files = true; + +// List of currently selected Pocket recommended stories categories. +message SelectedPocketStoriesCategories { + + // Details about a selected Pocket recommended stories category. + // See [org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory] + message SelectedPocketStoriesCategory { + // Name of this category. + string name = 1; + // Timestamp for when this category was selected. + int64 selectionTimestamp = 2; + } + + // Currently selected Pocket stories categories. + repeated SelectedPocketStoriesCategory values = 1; +} diff --git a/app/src/main/res/drawable-v24/shield_dark.xml b/app/src/main/res/drawable-v24/shield_dark.xml deleted file mode 100644 index 0e322f5694..0000000000 --- a/app/src/main/res/drawable-v24/shield_dark.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable-v24/shield_light.xml b/app/src/main/res/drawable-v24/shield_light.xml deleted file mode 100644 index 0e322f5694..0000000000 --- a/app/src/main/res/drawable-v24/shield_light.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_add_to_homescreen.xml b/app/src/main/res/drawable/ic_add_to_homescreen.xml deleted file mode 100644 index 2d07502bf4..0000000000 --- a/app/src/main/res/drawable/ic_add_to_homescreen.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_all_tabs.xml b/app/src/main/res/drawable/ic_all_tabs.xml new file mode 100644 index 0000000000..0818555ec3 --- /dev/null +++ b/app/src/main/res/drawable/ic_all_tabs.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_autoplay_disabled.xml b/app/src/main/res/drawable/ic_autoplay_disabled.xml index 4d069cf50e..c5f4e048bd 100644 --- a/app/src/main/res/drawable/ic_autoplay_disabled.xml +++ b/app/src/main/res/drawable/ic_autoplay_disabled.xml @@ -8,9 +8,9 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_camera_disabled.xml b/app/src/main/res/drawable/ic_camera_disabled.xml index 6802c51fa4..f0707e72cc 100644 --- a/app/src/main/res/drawable/ic_camera_disabled.xml +++ b/app/src/main/res/drawable/ic_camera_disabled.xml @@ -9,6 +9,6 @@ android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_clear.xml b/app/src/main/res/drawable/ic_clear.xml deleted file mode 100644 index 049b0b0318..0000000000 --- a/app/src/main/res/drawable/ic_clear.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml deleted file mode 100644 index e15b9ed935..0000000000 --- a/app/src/main/res/drawable/ic_help.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml deleted file mode 100644 index 2348db076a..0000000000 --- a/app/src/main/res/drawable/ic_home.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_link_disabled.xml b/app/src/main/res/drawable/ic_link_disabled.xml index b0084eee82..7168e7ccf8 100644 --- a/app/src/main/res/drawable/ic_link_disabled.xml +++ b/app/src/main/res/drawable/ic_link_disabled.xml @@ -9,12 +9,12 @@ android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_location_disabled.xml b/app/src/main/res/drawable/ic_location_disabled.xml index 9b0e5fb956..dee8edc59d 100644 --- a/app/src/main/res/drawable/ic_location_disabled.xml +++ b/app/src/main/res/drawable/ic_location_disabled.xml @@ -8,9 +8,9 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000000..8b2fe6870c --- /dev/null +++ b/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_microphone_disabled.xml b/app/src/main/res/drawable/ic_microphone_disabled.xml index 929cb80a09..c7c01c1370 100644 --- a/app/src/main/res/drawable/ic_microphone_disabled.xml +++ b/app/src/main/res/drawable/ic_microphone_disabled.xml @@ -8,6 +8,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_notifications_disabled.xml b/app/src/main/res/drawable/ic_notifications_disabled.xml index dcce0b521e..78abf7ae8a 100644 --- a/app/src/main/res/drawable/ic_notifications_disabled.xml +++ b/app/src/main/res/drawable/ic_notifications_disabled.xml @@ -9,8 +9,8 @@ android:viewportHeight="24"> + android:fillColor="?primaryText"/> + android:fillColor="?primaryText"/> diff --git a/app/src/main/res/drawable/ic_onboarding_private_browsing.xml b/app/src/main/res/drawable/ic_onboarding_private_browsing.xml deleted file mode 100644 index 8a0376e96c..0000000000 --- a/app/src/main/res/drawable/ic_onboarding_private_browsing.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_search_group_thumbnail.xml b/app/src/main/res/drawable/ic_search_group_thumbnail.xml new file mode 100644 index 0000000000..72166bd9b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_group_thumbnail.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml deleted file mode 100644 index 6c3315b26d..0000000000 --- a/app/src/main/res/drawable/ic_settings.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_storage_disabled.xml b/app/src/main/res/drawable/ic_storage_disabled.xml index e9608e8623..86f59d74c8 100644 --- a/app/src/main/res/drawable/ic_storage_disabled.xml +++ b/app/src/main/res/drawable/ic_storage_disabled.xml @@ -9,8 +9,8 @@ android:autoMirrored="true"> + android:fillColor="?primaryText"/> + android:fillColor="?primaryText"/> diff --git a/app/src/main/res/drawable/ic_tc.png b/app/src/main/res/drawable/ic_tc.png new file mode 100644 index 0000000000..0ef692f297 Binary files /dev/null and b/app/src/main/res/drawable/ic_tc.png differ diff --git a/app/src/main/res/drawable/ic_tracking_protection_disabled.xml b/app/src/main/res/drawable/ic_tracking_protection_disabled.xml index 54d9215add..779137e7fe 100644 --- a/app/src/main/res/drawable/ic_tracking_protection_disabled.xml +++ b/app/src/main/res/drawable/ic_tracking_protection_disabled.xml @@ -9,8 +9,8 @@ android:viewportHeight="24"> + android:fillColor="?primaryText"/> + android:fillColor="?primaryText"/> diff --git a/app/src/main/res/drawable/ic_whats_new_notification.xml b/app/src/main/res/drawable/ic_whats_new_notification.xml deleted file mode 100644 index 363fc588dd..0000000000 --- a/app/src/main/res/drawable/ic_whats_new_notification.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/inactive_tab_auto_close_border_background.xml b/app/src/main/res/drawable/inactive_tab_auto_close_border_background.xml new file mode 100644 index 0000000000..bc80384e33 --- /dev/null +++ b/app/src/main/res/drawable/inactive_tab_auto_close_border_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/mozac_ic_broken_lock.xml b/app/src/main/res/drawable/mozac_ic_broken_lock.xml new file mode 100644 index 0000000000..dc9ec5a9f4 --- /dev/null +++ b/app/src/main/res/drawable/mozac_ic_broken_lock.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/onboarding_popup_background.xml b/app/src/main/res/drawable/onboarding_popup_background.xml deleted file mode 100644 index 9c47ca2bc9..0000000000 --- a/app/src/main/res/drawable/onboarding_popup_background.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/onboarding_popup_shape.xml b/app/src/main/res/drawable/onboarding_popup_shape.xml new file mode 100644 index 0000000000..7a7b48bd03 --- /dev/null +++ b/app/src/main/res/drawable/onboarding_popup_shape.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pocket_vector.xml b/app/src/main/res/drawable/pocket_vector.xml new file mode 100644 index 0000000000..9c2ad19290 --- /dev/null +++ b/app/src/main/res/drawable/pocket_vector.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/shield_dark.xml b/app/src/main/res/drawable/shield_dark.xml deleted file mode 100644 index f97e24bdba..0000000000 --- a/app/src/main/res/drawable/shield_dark.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/shield_light.xml b/app/src/main/res/drawable/shield_light.xml deleted file mode 100644 index 34cbdc10e7..0000000000 --- a/app/src/main/res/drawable/shield_light.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index fb4246f4d8..5dc4bdefa9 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -17,7 +17,6 @@ android:layout_width="match_parent" android:layout_height="56dp" /> - + android:layout_marginHorizontal="@dimen/home_item_horizontal_margin" + android:background="@drawable/cfr_background_gradient"> - + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/collection_home_list_row.xml b/app/src/main/res/layout/collection_home_list_row.xml index 22957cc81f..61c711b739 100644 --- a/app/src/main/res/layout/collection_home_list_row.xml +++ b/app/src/main/res/layout/collection_home_list_row.xml @@ -7,6 +7,7 @@ android:id="@+id/item_collection" android:layout_width="match_parent" android:layout_height="48dp" + android:layout_marginHorizontal="@dimen/home_item_horizontal_margin" android:layout_marginTop="12dp" android:background="@drawable/card_list_row_background" android:clickable="true" diff --git a/app/src/main/res/layout/component_history_metadata_group.xml b/app/src/main/res/layout/component_history_metadata_group.xml new file mode 100644 index 0000000000..25b0f348ee --- /dev/null +++ b/app/src/main/res/layout/component_history_metadata_group.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/component_recent_bookmarks.xml b/app/src/main/res/layout/component_recent_bookmarks.xml index f567210589..c0830ab744 100644 --- a/app/src/main/res/layout/component_recent_bookmarks.xml +++ b/app/src/main/res/layout/component_recent_bookmarks.xml @@ -7,9 +7,14 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/home_item_horizontal_margin" + android:clipChildren="false" + android:clipToPadding="false" android:orientation="vertical"> - + - + android:orientation="vertical"> - - - - - + android:visibility="gone" + android:layout_margin="@dimen/exceptions_description_margin"> + + + + + + + + + + + diff --git a/app/src/main/res/layout/component_tabstray2.xml b/app/src/main/res/layout/component_tabstray2.xml index 3c4f8d5bb8..47615fad14 100644 --- a/app/src/main/res/layout/component_tabstray2.xml +++ b/app/src/main/res/layout/component_tabstray2.xml @@ -89,21 +89,18 @@ app:tabRippleColor="@android:color/transparent"> android:minWidth="448dp" android:layout_height="match_parent" android:layout_gravity="center_horizontal" - android:layout_marginBottom="8dp" android:clipChildren="false" android:clipToPadding="false" android:overScrollMode="never" diff --git a/app/src/main/res/layout/component_top_sites_pager.xml b/app/src/main/res/layout/component_top_sites_pager.xml index f6f66d7fd7..bacf84f25b 100644 --- a/app/src/main/res/layout/component_top_sites_pager.xml +++ b/app/src/main/res/layout/component_top_sites_pager.xml @@ -6,6 +6,7 @@ diff --git a/app/src/main/res/layout/component_tracking_protection_panel.xml b/app/src/main/res/layout/component_tracking_protection_panel.xml index b58da52d59..b49786a1fa 100644 --- a/app/src/main/res/layout/component_tracking_protection_panel.xml +++ b/app/src/main/res/layout/component_tracking_protection_panel.xml @@ -19,26 +19,29 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toTopOf="parent"> - + app:srcCompat="@drawable/mozac_ic_back" + app:tint="?attr/primaryText" /> - + @@ -223,6 +226,7 @@ android:layout_marginEnd="19dp" android:textColor="?attr/primaryText" android:textSize="16sp" + app:fontFamily="@font/metropolis_semibold" app:layout_constraintTop_toTopOf="parent" tools:text="@tools:sample/lorem" /> @@ -238,6 +242,34 @@ app:layout_constraintTop_toBottomOf="@id/category_title" tools:text="@tools:sample/lorem" /> + + + + - - + app:layout_constraintTop_toBottomOf="@id/smartblock_learn_more" /> - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/line_divider_details"> + + - + diff --git a/app/src/main/res/layout/connection_details_website_info.xml b/app/src/main/res/layout/connection_details_website_info.xml new file mode 100644 index 0000000000..b33f1eaa8a --- /dev/null +++ b/app/src/main/res/layout/connection_details_website_info.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/customize_home_list_item.xml b/app/src/main/res/layout/customize_home_list_item.xml new file mode 100644 index 0000000000..4a9dde779d --- /dev/null +++ b/app/src/main/res/layout/customize_home_list_item.xml @@ -0,0 +1,30 @@ + + + + + +