diff --git a/.github/ISSUE_TEMPLATE/release_checklist.md b/.github/ISSUE_TEMPLATE/release_checklist.md index 9f9e08b6e..6a54d5bca 100644 --- a/.github/ISSUE_TEMPLATE/release_checklist.md +++ b/.github/ISSUE_TEMPLATE/release_checklist.md @@ -26,26 +26,24 @@ There are two releases this covers: the current sprint that is going out to Beta ## Start of Sprint X.1 [Thursday, 1st week of sprint] - [ ] [Create an issue](https://github.com/mozilla-mobile/fenix/issues/new?template=release_checklist.md&title=Releng+for+) "Releng for v[release]" to track the current sprint. -## Sprint X.1 End [Wednesday, 2nd week] Cutting a Beta +## Sprint X.1 End [Wednesday, 2nd week] Cutting a Beta - [ ] Make a new Beta - - [ ] Create a branch off of master (DO NOT PUSH YET) for the *current* milestone of format `releases/v85.0.0`. After that, anything landing in master will be part of the next release. + - [ ] Create a branch off of master (DO NOT PUSH YET) for the *current* milestone of format `releases_v85.0.0`. After that, anything landing in master will be part of the next release. + - ⚠️ Please **do not** use `/` in branch names anymore: Taskcluster silently ignores them and doesn't output tasks at the right index. + - [ ] Bump `version.txt` to match the new version number + - [ ] Grant [mozilla-release-automation-bot](https://github.com/mozilla-release-automation-bot) write access to this branch. - [ ] On the new Beta branch, pin the AC version to the stable version ([example](https://github.com/mozilla-mobile/fenix/commit/e413da29f6a7a7d4a765817a9cd5687abbf27619)) with commit message "Issue #``: Pin to stable AC `` for release v85" - [ ] Update the title to include this AC version "Releng for v[release] with AC [version]" - Note: You will need code review to make changes to the release branch after this point, because it is a protected branch. - [ ] Push the branch. - - [ ] Create a GitHub pre-release [Release](https://github.com/mozilla-mobile/fenix/releases) with: - - [ ] Tag of the format `vX.X.X-beta.1` (v85.0.0-beta.1) - - [ ] The Target branch is the release branch (releases/v85.0.0) - - [ ] For the description of the release, look at the [Jira boards](https://jira.mozilla.com/secure/RapidBoard.jspa?rapidView=299&projectKey=FNX&view=reporting&chart=sprintRetrospective&sprint=883) for the X.1 and previous Y.2 sprints and list the major features that were added. This will help with the release notes later on. - - [ ] Click "Publish release". This will kick off a build of the branch. You can see it in the mouseover of the CI badge of the branch in the commits view. Builds are found under `signing-*` task. - - If you need to trigger a new RC build, you **MUST** draft and publish a new (pre-release) release (optionally deleting both the release and the tag). Editing an existing release and creating a new tag will **not** trigger a new build. + - [ ] Tell Release Management the first beta is ready to be shipped. They'll use https://shipit.mozilla-releng.net/new to kick off a new release. This replaces the process that involved GitHub releases. Now Github releases are automatically created by [mozilla-release-automation-bot](https://github.com/mozilla-release-automation-bot) - [ ] Send an email to QA at mozilla-mobile-qa@softvision.com with a link to the Taskcluster build (subdirectory of the [Fenix CI](https://firefox-ci-tc.services.mozilla.com/tasks/index/mobile.v2.fenix.beta)) ### Bugfix uplifts / Beta Product Integrity (Beta Release until PI green signoff) - [ ] If bugs are considered release blocker then find someone to fix them on master and the milestone branch (cherry-pick / uplift) - [ ] Add the uplift request to the appropriate row in the [Uplifts document](https://docs.google.com/spreadsheets/d/1qIvHpcQ3BqJtlzV5T4M1MhbWVxkNiG-ToeYnWEBW4-I/edit#gid=0). -- [ ] If needed tag a new beta version (e.g. v1.0-beta.2) and follow the submission checklist again. +- [ ] If needed, ship a new beta version (e.g. v1.0-beta.2) and follow the submission checklist again. - [ ] Once there is GREEN QA signoff, file a [release management bugzilla for rollout](https://bugzilla.mozilla.org/show_bug.cgi?id=1664366) - [ ] Check Sentry each day for issues on [Firefox Beta](https://sentry.prod.mozaws.net/operations/firefox-beta/) and if nothing concerning, bump release in the bugzilla (5%, 20%, 100%) @@ -59,18 +57,13 @@ There are two releases this covers: the current sprint that is going out to Beta ### Production Release Candidate [Tuesday, 3 weeks after X.1 Beta was cut] - [ ] In android-components: Create a dot release with the GeckoView Production release candidate. - [ ] Open a PR against the release branch (releases/v85.0.0) with the AC version bump "Pin to stable AC `` for release v85`. You will need code review. -- [ ] Create a GitHub pre-release [Release](https://github.com/mozilla-mobile/fenix/releases) with: - - [ ] Tag of the format `vX.X.X-rc.1` (v85.0.0-rc.1) - - [ ] The Target branch is the release branch (releases/v85.0.0) - - [ ] For the description, copy the beta description +- [ ] Modify `version.txt` to now follow the pattern: `vX.X.X-rc.1` (e.g.: v85.0.0-rc.1) +- [ ] Tell Release Management you would like to ship a new release. - [ ] Send an email to QA at mozilla-mobile-qa@softvision.com with a link to the Taskcluster build (subdirectory of the [Fenix CI](https://firefox-ci-tc.services.mozilla.com/tasks/index/mobile.v2.fenix.release)) ### Production Release [Release day, from [release calendar](https://docs.google.com/spreadsheets/d/1HotjliSCGOp2nTkfXrxv8qYcurNpkqLWBKbbId6ovTY/edit#gid=0)] -- [ ] Create a GitHub [Release](https://github.com/mozilla-mobile/fenix/releases) with: - - [ ] Tag of the format `vX.1.X` (v85.1.0) (increment the minor version for new cuts) - - [ ] The Target branch is the release branch (releases/v85.0.0) - - [ ] For the description, copy the beta description - - [ ] file Bugzilla ticket for [release manament](https://bugzilla.mozilla.org/show_bug.cgi?id=1672212) +- [ ] Modify `version.txt` to now follow the pattern: `vX.1.X` (e.g.: v85.1.0) +- [ ] File Bugzilla ticket for [release management](https://bugzilla.mozilla.org/show_bug.cgi?id=1672212) - [ ] Check Sentry for new crashes. File issues and triage. - [ ] Each day, bump the release rollout if nothing concerning (5%, 20%, 100%) diff --git a/.gitignore b/.gitignore index 8cce379dd..67abe8cc2 100644 --- a/.gitignore +++ b/.gitignore @@ -79,12 +79,12 @@ gen-external-apklibs # macOS .DS_Store -# Token files +# Secrets files, e.g. tokens .leanplum_token .adjust_token .sentry_token .mls_token - +.nimbus # Python Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 000000000..f4df54db1 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,59 @@ +pull_request_rules: + - name: Resolve conflict + conditions: + - conflict + actions: + comment: + message: This pull request has conflicts when rebasing. Could you fix it @{{author}}? 🙏 + - name: MickeyMoz - Auto Merge + conditions: + - author=MickeyMoz + - status-success=pr-complete + - files~=(Gecko.kt|AndroidComponents.kt) + actions: + review: + type: APPROVE + message: MickeyMoz 💪 + merge: + method: rebase + strict: smart + - name: L10N - Auto Merge + conditions: + - author=mozilla-l10n-automation-bot + - status-success=pr-complete + - files~=(strings.xml) + actions: + review: + type: APPROVE + message: LGTM 😎 + merge: + method: rebase + strict: smart + - name: Release automation + conditions: + - base~=releases/.* + - author=github-actions[bot] + # Listing checks manually beause we do not have a "push complete" check yet. + - check-success=build-android-test-debug + - check-success=build-debug + - check-success=build-nightly-simulation + - check-success=lint-compare-locales + - check-success=lint-detekt + - check-success=lint-ktlint + - check-success=lint-lint + - check-success=signing-android-test-debug + - check-success=signing-debug + - check-success=signing-nightly-simulation + - check-success=test-debug + # TODO Temporarily disabled - should be renamed to -build + #- check-success=ui-test-x86-debug + - files~=(AndroidComponents.kt) + actions: + review: + type: APPROVE + message: 🚢 + merge: + method: rebase + strict: smart + delete_head_branch: + force: false diff --git a/.taskcluster.yml b/.taskcluster.yml index 3441951e6..4ee1f0b8c 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -8,7 +8,7 @@ tasks: - $let: taskgraph: branch: taskgraph - revision: 2b2622598df02bde211d8cedb334b7b22fb883a4 + revision: a458418ef7cdd6778f1283926c6116966255bc24 trustDomain: mobile in: $let: diff --git a/app/build.gradle b/app/build.gradle index 8030c5d0e..9e51564f2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,6 +19,13 @@ import static org.gradle.api.tasks.testing.TestResult.ResultType android { compileSdkVersion Config.compileSdkVersion + + if (project.hasProperty("testBuildType")) { + // Allowing to configure the test build type via command line flag (./gradlew -PtestBuildType=beta ..) + // in order to run UI tests against other build variants than debug in automation. + testBuildType project.property("testBuildType") + } + defaultConfig { applicationId "io.github.forkmaintainers" minSdkVersion Config.minSdkVersion @@ -47,8 +54,10 @@ android { } def releaseTemplate = { - shrinkResources true - minifyEnabled true + // We allow disabling optimization by passing `-PdisableOptimization` to gradle. This is used + // in automation for UI testing non-debug builds. + shrinkResources !project.hasProperty("disableOptimization") + minifyEnabled !project.hasProperty("disableOptimization") proguardFiles 'proguard-android-optimize-3.5.0-modified.txt', 'proguard-rules.pro' matchingFallbacks = ['release'] // Use on the "release" build type in dependencies (AARs) @@ -361,6 +370,30 @@ android.applicationVariants.all { variant -> buildConfigField 'String', 'MLS_TOKEN', '""' println("X_X") } + +// ------------------------------------------------------------------------------------------------- +// Nimbus: Read endpoint from local.properties of a local file if it exists +// ------------------------------------------------------------------------------------------------- + + print("Nimbus endpoint: ") + + if (!isDebug) { + try { + def url = new File("${rootDir}/.nimbus").text.trim() + buildConfigField 'String', 'NIMBUS_ENDPOINT', '"' + url + '"' + println "(Added from .nimbus file)" + } catch (FileNotFoundException ignored) { + buildConfigField 'String', 'NIMBUS_ENDPOINT', 'null' + println("X_X") + } + } else if (gradle.hasProperty("localProperties.nimbus.remote-settings.url")) { + def url=gradle.getProperty("localProperties.nimbus.remote-settings.url") + buildConfigField 'String', 'NIMBUS_ENDPOINT', '"' + url + '"' + println "(Added from local.properties file)" + } else { + buildConfigField 'String', 'NIMBUS_ENDPOINT', 'null' + println("--") + } } androidExtensions { @@ -474,11 +507,11 @@ dependencies { implementation Deps.mozilla_feature_webcompat_reporter implementation Deps.mozilla_service_digitalassetlinks - implementation Deps.mozilla_service_experiments implementation Deps.mozilla_service_sync_logins implementation Deps.mozilla_service_firefox_accounts implementation Deps.mozilla_service_glean implementation Deps.mozilla_service_location + implementation Deps.mozilla_service_nimbus implementation Deps.mozilla_support_base implementation Deps.mozilla_support_images @@ -493,6 +526,7 @@ dependencies { implementation Deps.mozilla_ui_icons implementation Deps.mozilla_lib_publicsuffixlist implementation Deps.mozilla_ui_widgets + implementation Deps.mozilla_ui_tabcounter implementation Deps.mozilla_lib_crash implementation Deps.mozilla_lib_dataprotect @@ -549,6 +583,7 @@ dependencies { androidTestImplementation Deps.androidx_work_testing androidTestImplementation Deps.mockwebserver testImplementation Deps.mozilla_support_test + testImplementation Deps.mozilla_support_test_libstate testImplementation Deps.androidx_junit testImplementation Deps.androidx_work_testing testImplementation (Deps.robolectric) { diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index a5aab8c1d..5e2116b6a 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -48,17 +48,6 @@ column="6"/> - - - - - - - - + + diff --git a/app/metrics.yaml b/app/metrics.yaml index 8bdbdc044..aee2119aa 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -331,6 +331,47 @@ events: notification_emails: - fenix-core@mozilla.com expires: "2021-04-01" + synced_tab_opened: + type: event + description: > + An event that indicates that a synced tab was opened. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/15369 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16727 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-05-10" + recently_closed_tabs_opened: + type: event + description: | + An event that indicates that the user + has accessed recently closed tabs list. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/15366 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16739 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-05-10" + copy_url_tapped: + type: event + description: | + An event that indicates that a user has selected + copy option when long pressing on url bar. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/16827 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16915 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-05-10" onboarding: fxa_auto_signin: @@ -702,6 +743,82 @@ metrics: notification_emails: - fenix-core@mozilla.com expires: never + mobile_bookmarks_count: + type: counter + lifetime: application + description: | + A counter that indicates how many bookmarks a user has in the mobile + folder. This value will only be set if the user has at least *one* + bookmark. If they have 0, this ping will not get sent, resulting in + a null value. To disambiguate between a failed `mobile_bookmarks_count` + ping and 0 bookmarks, please see `has_mobile_bookmarks`. + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/16941 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16942 + - https://github.com/mozilla-mobile/fenix/pull/16942#issuecomment-742794701 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-08-01" + has_mobile_bookmarks: + type: boolean + lifetime: application + description: | + A boolean that indicates if the user has bookmarks in the mobile folder + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/16941 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16942 + - https://github.com/mozilla-mobile/fenix/pull/16942#issuecomment-742794701 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-08-01" + desktop_bookmarks_count: + type: counter + lifetime: application + description: | + A counter that indicates how many bookmarks a user has in the desktop + folder. This value will only be set if the user has at least *one* + bookmark. If they have 0, this ping will not get sent, resulting in a + null value. To disambiguate between a failed `desktop_bookmarks_count` + ping and 0 bookmarks, please see `has_desktop_bookmarks`. + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/16941 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16942 + - https://github.com/mozilla-mobile/fenix/pull/16942#issuecomment-742794701 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-08-01" + has_desktop_bookmarks: + type: boolean + lifetime: application + description: | + A boolean that indicates if the user has bookmarks in the desktop folder + send_in_pings: + - metrics + bugs: + - https://github.com/mozilla-mobile/fenix/issues/16941 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16942 + - https://github.com/mozilla-mobile/fenix/pull/16942#issuecomment-742794701 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-08-01" top_sites_count: type: counter lifetime: application @@ -2887,6 +3004,32 @@ media_state: notification_emails: - fenix-core@mozilla.com expires: "2021-08-01" + fullscreen: + type: event + description: | + Video set to fullscreen. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/15368 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16833 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-08-01" + picture_in_picture: + type: event + description: | + Video set to picture in picture mode. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/15368 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16833 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-08-01" logins: open_logins: @@ -3109,6 +3252,58 @@ download_notification: - fenix-core@mozilla.com expires: "2021-04-01" +downloads_misc: + download_added: + type: event + description: + A counter for how many times something is downloaded in the app. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/11578 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16730 + notification_emails: + - fenix-core@mozilla.com + expires: "2021-04-01" + +downloads_management: + downloads_screen_opened: + type: event + description: > + A counter for the number of times users access the "Downloads" folder + inside the app. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/15367 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16728 + notification_emails: + - fenix-core@mozilla.com + expires: "2021-04-01" + + item_opened: + type: event + description: > + A counter for how often a user tap to opens a download from inside the + "Downloads" folder. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/15367 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16728 + notification_emails: + - fenix-core@mozilla.com + expires: "2021-04-01" + + item_deleted: + type: event + description: > + A counter for how often a user deletes one / more downloads at a time. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/15367 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/16728 + notification_emails: + - fenix-core@mozilla.com + expires: "2021-04-01" + user_specified_search_engines: custom_engine_added: type: event diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index aa2ae32ae..3c44e42bf 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -119,4 +119,4 @@ # Keep Android Lifecycle methods # https://bugzilla.mozilla.org/show_bug.cgi?id=1596302 --keep class androidx.lifecycle.** { *; } \ No newline at end of file +-keep class androidx.lifecycle.** { *; } diff --git a/app/src/androidTest/java/org/mozilla/fenix/AppRequestInterceptor.kt b/app/src/androidTest/java/org/mozilla/fenix/AppRequestInterceptor.kt index c16ee4d63..59e2de9a5 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/AppRequestInterceptor.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/AppRequestInterceptor.kt @@ -5,18 +5,26 @@ package org.mozilla.fenix import android.content.Context +import androidx.navigation.NavController import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.request.RequestInterceptor import org.mozilla.fenix.ext.components import org.mozilla.fenix.ui.robots.appContext +import java.lang.ref.WeakReference /** * This class overrides the application's request interceptor to * deactivate the FxA web channel * which is not supported on the staging servers. */ - class AppRequestInterceptor(private val context: Context) : RequestInterceptor { + + private var navController: WeakReference? = null + + fun setNavigationController(navController: NavController) { + this.navController = WeakReference(navController) + } + override fun onLoadRequest( engineSession: EngineSession, uri: String, diff --git a/app/src/androidTest/java/org/mozilla/fenix/helpers/RecyclerViewIdlingResource.kt b/app/src/androidTest/java/org/mozilla/fenix/helpers/RecyclerViewIdlingResource.kt index 61de022cf..68b671b55 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/helpers/RecyclerViewIdlingResource.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/helpers/RecyclerViewIdlingResource.kt @@ -9,7 +9,7 @@ class RecyclerViewIdlingResource constructor(private val recycler: androidx.recy private var callback: ResourceCallback? = null override fun isIdleNow(): Boolean { - if (recycler.adapter != null && recycler.adapter!!.itemCount > minItemCount) { + if (recycler.adapter != null && recycler.adapter!!.itemCount >= minItemCount) { if (callback != null) { callback!!.onTransitionToIdle() } 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 2ae01aa50..dc7521062 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/helpers/TestHelper.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/helpers/TestHelper.kt @@ -32,6 +32,9 @@ import org.mozilla.fenix.helpers.ext.waitNotNull import org.mozilla.fenix.ui.robots.mDevice object TestHelper { + + val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName + fun scrollToElementByText(text: String): UiScrollable { val appView = UiScrollable(UiSelector().scrollable(true)) appView.scrollTextIntoView(text) 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 b011e6181..8be03fb46 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/BookmarksTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/BookmarksTest.kt @@ -74,7 +74,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) selectFolder("Desktop Bookmarks") @@ -112,7 +112,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) verifyBookmarkedURL(defaultWebPage.url.toString()) @@ -126,7 +126,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) clickAddFolderButton() @@ -159,7 +159,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) }.openThreeDotMenu(defaultWebPage.url) { }.clickEdit { @@ -185,7 +185,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) }.openThreeDotMenu(defaultWebPage.url) { }.clickCopy { @@ -202,7 +202,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) }.openThreeDotMenu(defaultWebPage.url) { }.clickShare { @@ -222,7 +222,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) }.openThreeDotMenu(defaultWebPage.url) { }.clickOpenInNewTab { @@ -241,7 +241,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) }.openThreeDotMenu(defaultWebPage.url) { }.clickOpenInPrivateTab { @@ -260,7 +260,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) }.openThreeDotMenu(defaultWebPage.url) { IdlingRegistry.getInstance().unregister(bookmarksListIdlingResource!!) @@ -279,13 +279,17 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) }.openThreeDotMenu(defaultWebPage.url) { + IdlingRegistry.getInstance().unregister(bookmarksListIdlingResource!!) }.clickDelete { verifyUndoDeleteSnackBarButton() clickUndoDeleteButton() verifySnackBarHidden() + bookmarksListIdlingResource = + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) + IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) verifyBookmarkedURL(defaultWebPage.url.toString()) } } @@ -299,7 +303,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) longTapSelectItem(defaultWebPage.url) @@ -329,7 +333,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) longTapSelectItem(defaultWebPage.url) @@ -352,7 +356,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) longTapSelectItem(defaultWebPage.url) @@ -377,7 +381,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 3) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) longTapSelectItem(firstWebPage.url) @@ -404,7 +408,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) longTapSelectItem(defaultWebPage.url) @@ -460,7 +464,7 @@ class BookmarksTest { }.openThreeDotMenu { }.openBookmarks { bookmarksListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.bookmark_list), 2) IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!) }.openThreeDotMenu(defaultWebPage.url) { IdlingRegistry.getInstance().unregister(bookmarksListIdlingResource!!) 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 ea6f88a16..8267e85be 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/HistoryTest.kt @@ -84,7 +84,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) verifyHistoryMenuView() verifyVisitedTimeTitle() @@ -104,7 +104,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) }.openThreeDotMenu { }.clickCopy { @@ -123,7 +123,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) }.openThreeDotMenu { }.clickShare { @@ -145,7 +145,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) }.openThreeDotMenu { }.clickOpenInNormalTab { @@ -166,7 +166,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) }.openThreeDotMenu { }.clickOpenInPrivateTab { @@ -187,7 +187,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) }.openThreeDotMenu { IdlingRegistry.getInstance().unregister(historyListIdlingResource!!) @@ -208,7 +208,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) clickDeleteHistoryButton() IdlingRegistry.getInstance().unregister(historyListIdlingResource!!) @@ -230,7 +230,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) longTapSelectItem(firstWebPage.url) } @@ -260,7 +260,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) longTapSelectItem(firstWebPage.url) openActionBarOverflowOrOptionsMenu(activityTestRule.activity) @@ -284,7 +284,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) longTapSelectItem(firstWebPage.url) openActionBarOverflowOrOptionsMenu(activityTestRule.activity) @@ -311,7 +311,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 2) IdlingRegistry.getInstance().register(historyListIdlingResource!!) longTapSelectItem(firstWebPage.url) longTapSelectItem(secondWebPage.url) @@ -339,7 +339,7 @@ class HistoryTest { }.openHistory { verifyHistoryListExists() historyListIdlingResource = - RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list)) + RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1) IdlingRegistry.getInstance().register(historyListIdlingResource!!) longTapSelectItem(firstWebPage.url) } 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 ebafc68ac..5345f38cb 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsBasicsTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsBasicsTest.kt @@ -10,7 +10,6 @@ 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.FenixApplication @@ -105,25 +104,6 @@ class SettingsBasicsTest { } } - @Ignore("This test works locally, fails on firebase. https://github.com/mozilla-mobile/fenix/issues/8174") - @Test - fun toggleSearchSuggestions() { - // Goes through the settings and changes the search suggestion toggle, then verifies it changes. - homeScreen { - }.openNavigationToolbar { - verifySearchSuggestionsAreMoreThan(1, "mozilla") - }.goBack { - }.openThreeDotMenu { - }.openSettings { - }.openSearchSubMenu { - disableShowSearchSuggestions() - }.goBack { - }.goBack { - }.openNavigationToolbar { - verifySearchSuggestionsAreEqualTo(0, "mozilla") - } - } - @Test fun toggleShowVisitedSitesAndBookmarks() { // Bookmarks a few websites, toggles the history and bookmarks setting to off, then verifies if the visited and bookmarked websites do not show in the suggestions. 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 c5afc8303..36c603a0d 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsPrivacyTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SettingsPrivacyTest.kt @@ -174,7 +174,7 @@ class SettingsPrivacyTest { verifyDefaultValueAutofillLogins() verifyDefaultValueExceptions() }.openSavedLogins { - verifySavedLoginsView() + verifySecurityPromptForLogins() tapSetupLater() // Verify that logins list is empty // Issue #7272 nothing is shown @@ -205,7 +205,7 @@ class SettingsPrivacyTest { verifyDefaultView() verifyDefaultValueSyncLogins() }.openSavedLogins { - verifySavedLoginsView() + verifySecurityPromptForLogins() tapSetupLater() // Verify that the login appears correctly verifySavedLoginFromPrompt() @@ -230,7 +230,7 @@ class SettingsPrivacyTest { verifyDefaultView() verifyDefaultValueSyncLogins() }.openSavedLogins { - verifySavedLoginsView() + verifySecurityPromptForLogins() tapSetupLater() // Verify that the login list is empty verifyNotSavedLoginFromPrompt() 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 55e13cf94..881855d37 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt @@ -4,7 +4,9 @@ package org.mozilla.fenix.ui -import androidx.core.net.toUri +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.IdlingRegistry import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import okhttp3.mockwebserver.MockWebServer @@ -12,9 +14,13 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mozilla.fenix.R import org.mozilla.fenix.helpers.AndroidAssetDispatcher import org.mozilla.fenix.helpers.HomeActivityTestRule +import org.mozilla.fenix.helpers.RecyclerViewIdlingResource import org.mozilla.fenix.helpers.TestAssetHelper +import org.mozilla.fenix.helpers.TestHelper +import org.mozilla.fenix.helpers.ViewVisibilityIdlingResource import org.mozilla.fenix.ui.robots.clickUrlbar import org.mozilla.fenix.ui.robots.homeScreen import org.mozilla.fenix.ui.robots.navigationToolbar @@ -27,6 +33,17 @@ import org.mozilla.fenix.ui.robots.navigationToolbar class SmokeTest { private val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) private lateinit var mockWebServer: MockWebServer + private var awesomeBar: ViewVisibilityIdlingResource? = null + private var searchSuggestionsIdlingResource: RecyclerViewIdlingResource? = null + + // This finds the dialog fragment child of the homeFragment, otherwise the awesomeBar would return null + private fun getAwesomebarView(): View? { + val homeFragment = activityTestRule.activity.supportFragmentManager.primaryNavigationFragment + val searchDialogFragment = homeFragment?.childFragmentManager?.fragments?.first { + it.javaClass.simpleName == "SearchDialogFragment" + } + return searchDialogFragment?.view?.findViewById(R.id.awesome_bar) + } @get:Rule val activityTestRule = HomeActivityTestRule() @@ -94,6 +111,15 @@ class SmokeTest { } } + @Test + fun startBrowsingButtonTest() { + homeScreen { + verifyStartBrowsingButton() + }.clickStartBrowsingButton { + verifySearchView() + } + } + @Test fun verifyBasicNavigationToolbarFunctionality() { val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) @@ -117,32 +143,71 @@ class SmokeTest { @Test fun verifyPageMainMenuItemsTest() { val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) - // Add this to check openInApp and youtube is a default app available in every Android emulator/device - val youtubeUrl = "www.youtube.com" navigationToolbar { }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { verifyThreeDotMainMenuItems() + } + } + + // Could be removed when more smoke tests from the History category are added + @Test + fun openMainMenuHistoryItemTest() { + homeScreen { + }.openThreeDotMenu { }.openHistory { verifyHistoryMenuView() - }.goBackToBrowser { + } + } + + // Could be removed when more smoke tests from the Bookmarks category are added + @Test + fun openMainMenuBookmarksItemTest() { + homeScreen { }.openThreeDotMenu { }.openBookmarks { verifyBookmarksMenuView() - }.goBackToBrowser { + } + } + + @Test + fun openMainMenuSyncedTabsItemTest() { + homeScreen { }.openThreeDotMenu { }.openSyncedTabs { verifySyncedTabsMenuHeader() - }.goBack { + } + } + + // Could be removed when more smoke tests from the Settings category are added + @Test + fun openMainMenuSettingsItemTest() { + homeScreen { }.openThreeDotMenu { }.openSettings { verifySettingsView() - }.goBackToBrowser { + } + } + + @Test + fun openMainMenuFindInPageTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.openFindInPage { verifyFindInPageSearchBarItems() - }.closeFindInPage { + } + } + + @Test + fun openMainMenuAddTopSiteTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.addToFirefoxHome { verifySnackBarText("Added to top sites!") @@ -150,32 +215,73 @@ class SmokeTest { }.openNewTab { }.dismissSearchBar { verifyExistingTopSitesTabs(defaultWebPage.title) - }.openTabDrawer { - }.openTab(defaultWebPage.title) { + } + } + + @Test + fun mainMenuAddToHomeScreenTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.openAddToHomeScreen { verifyShortcutNameField(defaultWebPage.title) clickAddShortcutButton() clickAddAutomaticallyButton() }.openHomeScreenShortcut(defaultWebPage.title) { + verifyPageContent(defaultWebPage.content) + } + } + + @Test + fun openMainMenuAddToCollectionTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.openSaveToCollection { verifyCollectionNameTextField() - }.exitSaveCollection { + } + } + + @Test + fun mainMenuBookmarkButtonTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.bookmarkPage { verifySnackBarText("Bookmark saved!") + } + } + + @Test + fun mainMenuShareButtonTest() { + val defaultWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + + navigationToolbar { + }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openThreeDotMenu { }.sharePage { verifyShareAppsLayout() - }.closeShareDialogReturnToPage { + } + } + + @Test + fun mainMenuRefreshButtonTest() { + val refreshWebPage = TestAssetHelper.getRefreshAsset(mockWebServer) + + navigationToolbar { + }.enterURLAndEnterToBrowser(refreshWebPage.url) { + mDevice.waitForIdle() }.openThreeDotMenu { + verifyThreeDotMenuExists() + verifyRefreshButton() }.refreshPage { - verifyUrl(defaultWebPage.url.toString()) - }.openNavigationToolbar { - }.enterURLAndEnterToBrowser(youtubeUrl.toUri()) { - }.openThreeDotMenu { - verifyOpenInAppButton() + verifyPageContent("REFRESHED") } } @@ -201,7 +307,6 @@ class SmokeTest { }.goBackToBrowser { clickEnhancedTrackingProtectionPanel() verifyEnhancedTrackingProtectionSwitch() - // Turning off TP Switch results in adding the WebPage to exception list clickEnhancedTrackingProtectionSwitchOffOn() } } @@ -214,7 +319,7 @@ class SmokeTest { homeScreen { }.openSearch { verifyKeyboardVisibility() - clickSearchEngineButton() + clickSearchEngineShortcutButton() verifySearchEngineList() changeDefaultSearchEngine("Amazon.com") verifySearchEngineIcon("Amazon.com") @@ -222,7 +327,7 @@ class SmokeTest { }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openTabDrawer { }.openNewTab { - clickSearchEngineButton() + clickSearchEngineShortcutButton() mDevice.waitForIdle() changeDefaultSearchEngine("Bing") verifySearchEngineIcon("Bing") @@ -230,7 +335,7 @@ class SmokeTest { }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openTabDrawer { }.openNewTab { - clickSearchEngineButton() + clickSearchEngineShortcutButton() mDevice.waitForIdle() changeDefaultSearchEngine("DuckDuckGo") verifySearchEngineIcon("DuckDuckGo") @@ -238,7 +343,7 @@ class SmokeTest { }.enterURLAndEnterToBrowser(defaultWebPage.url) { }.openTabDrawer { }.openNewTab { - clickSearchEngineButton() + clickSearchEngineShortcutButton() changeDefaultSearchEngine("Wikipedia") verifySearchEngineIcon("Wikipedia") }.goToSearchEngine { @@ -254,4 +359,135 @@ class SmokeTest { } } } + + @Test + fun addPredefinedSearchEngineTest() { + homeScreen { + }.openThreeDotMenu { + }.openSettings { + }.openSearchSubMenu { + openAddSearchEngineMenu() + verifyAddSearchEngineList() + addNewSearchEngine("YouTube") + verifyEngineListContains("YouTube") + }.goBack { + }.goBack { + }.openSearch { + verifyKeyboardVisibility() + clickSearchEngineShortcutButton() + verifyEnginesListShortcutContains("YouTube") + } + } + + @Test + fun toggleSearchSuggestions() { + // Goes through the settings and changes the search suggestion toggle, then verifies it changes. + 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 { + }.openThreeDotMenu { + }.openSettings { + }.openSearchSubMenu { + disableShowSearchSuggestions() + }.goBack { + }.goBack { + }.openNavigationToolbar { + typeSearchTerm("mozilla") + searchSuggestionsIdlingResource = + RecyclerViewIdlingResource(getAwesomebarView() as RecyclerView) + IdlingRegistry.getInstance().register(searchSuggestionsIdlingResource!!) + verifySearchSuggestionsAreEqualTo(0) + IdlingRegistry.getInstance().unregister(searchSuggestionsIdlingResource!!) + } + } + + @Test + fun swipeToSwitchTabTest() { + val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1) + val secondWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 2) + + navigationToolbar { + }.enterURLAndEnterToBrowser(firstWebPage.url) { + }.openTabDrawer { + }.openNewTab { + }.submitQuery(secondWebPage.url.toString()) { + swipeNavBarRight(secondWebPage.url.toString()) + verifyPageContent(firstWebPage.content) + swipeNavBarLeft(firstWebPage.url.toString()) + verifyPageContent(secondWebPage.content) + } + } + + @Test + fun updateSavedLoginTest() { + val saveLoginTest = + TestAssetHelper.getSaveLoginAsset(mockWebServer) + + navigationToolbar { + }.enterURLAndEnterToBrowser(saveLoginTest.url) { + verifySaveLoginPromptIsShown() + // Click Save to save the login + saveLoginFromPrompt("Save") + }.openNavigationToolbar { + }.enterURLAndEnterToBrowser(saveLoginTest.url) { + enterPassword("test") + verifyUpdateLoginPromptIsShown() + // Click Update to change the saved password + saveLoginFromPrompt("Update") + }.openThreeDotMenu { + }.openSettings { + TestHelper.scrollToElementByText("Logins and passwords") + }.openLoginsAndPasswordSubMenu { + }.openSavedLogins { + verifySecurityPromptForLogins() + tapSetupLater() + // Verify that the login appears correctly + verifySavedLoginFromPrompt() + viewSavedLoginDetails() + revealPassword() + verifyPasswordSaved("test") + } + } + + @Test + fun redirectToAppPermissionsSystemSettingsTest() { + homeScreen { + }.openThreeDotMenu { + }.openSettings { + }.openSettingsSubMenuSitePermissions { + }.openCamera { + verifyBlockedByAndroid() + }.goBack { + }.openLocation { + verifyBlockedByAndroid() + }.goBack { + }.openMicrophone { + verifyBlockedByAndroid() + clickGoToSettingsButton() + openAppSystemPermissionsSettings() + switchAppPermissionSystemSetting("Camera") + switchAppPermissionSystemSetting("Location") + switchAppPermissionSystemSetting("Microphone") + mDevice.pressBack() + mDevice.pressBack() + verifyUnblockedByAndroid() + }.goBack { + }.openLocation { + verifyUnblockedByAndroid() + }.goBack { + }.openCamera { + verifyUnblockedByAndroid() + } + } } 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 36b014071..2d2c8fda7 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 @@ -35,6 +35,7 @@ import org.junit.Assert.assertEquals 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 import org.mozilla.fenix.helpers.ext.waitNotNull @@ -216,7 +217,7 @@ class BookmarksRobot { } fun openThreeDotMenu(bookmarkTitle: String, interact: ThreeDotMenuBookmarksRobot.() -> Unit): ThreeDotMenuBookmarksRobot.Transition { - mDevice.waitNotNull(Until.findObject(res("org.mozilla.fenix.debug:id/overflow_menu"))) + mDevice.waitNotNull(Until.findObject(res("$packageName:id/overflow_menu"))) threeDotMenu(bookmarkTitle).click() ThreeDotMenuBookmarksRobot().interact() 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 dcf84c575..99be574f0 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 @@ -9,6 +9,7 @@ package org.mozilla.fenix.ui.robots import android.content.Context import android.content.Intent import android.net.Uri +import android.widget.EditText import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.action.ViewActions @@ -38,12 +39,15 @@ import org.junit.Assert.assertTrue import org.mozilla.fenix.R import org.mozilla.fenix.ext.components import org.mozilla.fenix.helpers.Constants.LONG_CLICK_DURATION -import org.mozilla.fenix.helpers.TestAssetHelper +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.click import org.mozilla.fenix.helpers.ext.waitNotNull class BrowserRobot { + private lateinit var sessionLoadedIdlingResource: SessionLoadedIdlingResource + fun verifyCurrentPrivateSession(context: Context) { val session = context.components.core.sessionManager.selectedSession assertTrue("Current session is private", session?.private!!) @@ -51,13 +55,17 @@ class BrowserRobot { fun verifyUrl(url: String) { val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + sessionLoadedIdlingResource = SessionLoadedIdlingResource() + mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/mozac_browser_toolbar_url_view")), - waitingTime + Until.findObject(By.res("$packageName:id/mozac_browser_toolbar_url_view")), + waitingTime ) - TestAssetHelper.waitingTime - onView(withId(R.id.mozac_browser_toolbar_url_view)) - .check(matches(withText(containsString(url.replace("http://", ""))))) + + runWithIdleRes(sessionLoadedIdlingResource) { + onView(withId(R.id.mozac_browser_toolbar_url_view)) + .check(matches(withText(containsString(url.replace("http://", ""))))) + } } fun verifyHelpUrl() { @@ -78,11 +86,16 @@ class BrowserRobot { */ fun verifyPageContent(expectedText: String) { + sessionLoadedIdlingResource = SessionLoadedIdlingResource() + mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/engineView")), + Until.findObject(By.res("$packageName:id/engineView")), waitingTime ) - assertTrue(mDevice.findObject(UiSelector().text(expectedText)).waitForExists(waitingTime)) + + runWithIdleRes(sessionLoadedIdlingResource) { + assertTrue(mDevice.findObject(UiSelector().textContains(expectedText)).waitForExists(waitingTime)) + } } fun verifyTabCounter(expectedText: String) { @@ -312,17 +325,36 @@ class BrowserRobot { } fun verifySaveLoginPromptIsShown() { - mDevice.waitNotNull(Until.findObjects(text("test@example.com")), waitingTime) + mDevice.findObject(UiSelector().text("test@example.com")).waitForExists(waitingTime) val submitButton = mDevice.findObject(By.res("submit")) submitButton.clickAndWait(Until.newWindow(), waitingTime) // Click save to save the login mDevice.waitNotNull(Until.findObjects(text("Save"))) } + fun verifyUpdateLoginPromptIsShown() { + val submitButton = mDevice.findObject(By.res("submit")) + submitButton.clickAndWait(Until.newWindow(), waitingTime) + + mDevice.waitNotNull(Until.findObjects(text("Update"))) + } + fun saveLoginFromPrompt(optionToSaveLogin: String) { mDevice.findObject(text(optionToSaveLogin)).click() } + fun enterPassword(password: String) { + val passwordField = mDevice.findObject( + UiSelector() + .resourceId("password") + .className(EditText::class.java) + ) + passwordField.waitForExists(waitingTime) + passwordField.setText(password) + // wait until the password is hidden + assertTrue(mDevice.findObject(UiSelector().text(password)).waitUntilGone(waitingTime)) + } + fun clickMediaPlayerPlayButton() { mediaPlayerPlayButton().waitForExists(waitingTime) mediaPlayerPlayButton().click() @@ -338,6 +370,28 @@ class BrowserRobot { assertTrue(pausedStateMessage.waitForExists(waitingTime)) } + fun swipeNavBarRight(tabUrl: String) { + // failing to swipe on Firebase sometimes, so it tries again + try { + navURLBar().perform(ViewActions.swipeRight()) + assertTrue(mDevice.findObject(UiSelector().text(tabUrl)).waitUntilGone(waitingTime)) + } catch (e: AssertionError) { + navURLBar().perform(ViewActions.swipeRight()) + assertTrue(mDevice.findObject(UiSelector().text(tabUrl)).waitUntilGone(waitingTime)) + } + } + + fun swipeNavBarLeft(tabUrl: String) { + // failing to swipe on Firebase sometimes, so it tries again + try { + navURLBar().perform(ViewActions.swipeLeft()) + assertTrue(mDevice.findObject(UiSelector().text(tabUrl)).waitUntilGone(waitingTime)) + } catch (e: AssertionError) { + navURLBar().perform(ViewActions.swipeLeft()) + assertTrue(mDevice.findObject(UiSelector().text(tabUrl)).waitUntilGone(waitingTime)) + } + } + class Transition { private val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) private fun threeDotButton() = onView( @@ -367,11 +421,8 @@ class BrowserRobot { fun openTabDrawer(interact: TabDrawerRobot.() -> Unit): TabDrawerRobot.Transition { mDevice.waitForIdle(waitingTime) tabsCounter().click() - - mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/tab_layout")), - waitingTime - ) + mDevice.waitNotNull(Until.findObject(By.res("$packageName:id/tab_layout")), + waitingTime) TabDrawerRobot().interact() return TabDrawerRobot.Transition() 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 48ccc4c2e..c771602a5 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 @@ -26,6 +26,8 @@ import org.mozilla.fenix.helpers.TestHelper import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull import org.mozilla.fenix.helpers.Constants.PackageName.GOOGLE_APPS_PHOTOS +import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime +import org.mozilla.fenix.helpers.TestHelper.packageName /** * Implementation of Robot Pattern for download UI handling. @@ -40,7 +42,6 @@ class DownloadRobot { fun verifyPhotosAppOpens() = assertPhotosOpens() class Transition { - fun clickDownload(interact: DownloadRobot.() -> Unit): Transition { clickDownloadButton().click() @@ -93,7 +94,7 @@ fun downloadRobot(interact: DownloadRobot.() -> Unit): DownloadRobot.Transition } private fun assertDownloadPrompt() { - mDevice.waitNotNull(Until.findObjects(By.res("org.mozilla.fenix.debug:id/download_button"))) + mDevice.waitNotNull(Until.findObjects(By.res("$packageName:id/download_button"))) } private fun assertDownloadNotificationPopup() { 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 3af407b80..aa6adc18a 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 @@ -15,6 +15,7 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import org.mozilla.fenix.R import org.mozilla.fenix.helpers.TestAssetHelper +import org.mozilla.fenix.helpers.TestHelper.packageName import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull import org.mozilla.fenix.helpers.isChecked @@ -89,7 +90,7 @@ fun enhancedTrackingProtection(interact: EnhancedTrackingProtectionRobot.() -> U private fun assertEnhancedTrackingProtectionNotice() { mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/onboarding_message")), + Until.findObject(By.res("$packageName:id/onboarding_message")), TestAssetHelper.waitingTime ) } 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 a68b19f32..428f0bfc6 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 @@ -11,6 +11,7 @@ import android.widget.EditText import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.ViewInteraction import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.longClick @@ -38,6 +39,7 @@ import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until.findObject import mozilla.components.support.ktx.android.content.appName +import mozilla.components.browser.state.state.searchEngines import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString import org.hamcrest.CoreMatchers.instanceOf @@ -45,9 +47,10 @@ import org.hamcrest.CoreMatchers.not import org.hamcrest.Matchers import org.junit.Assert import org.mozilla.fenix.R -import org.mozilla.fenix.components.Search +import org.mozilla.fenix.ext.components 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.TestHelper.scrollToElementByText import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull @@ -367,7 +370,7 @@ class HomeScreenRobot { tabsCounter().click() mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/tab_layout")), + Until.findObject(By.res("$packageName:id/tab_layout")), waitingTime ) @@ -393,6 +396,14 @@ class HomeScreenRobot { openThreeDotMenu { }.openSettings { }.goBack { } } + fun clickStartBrowsingButton(interact: SearchRobot.() -> Unit): SearchRobot.Transition { + scrollToElementByText("Start browsing") + startBrowsingButton().click() + + SearchRobot().interact() + return SearchRobot.Transition() + } + fun togglePrivateBrowsingMode() { onView(ViewMatchers.withResourceName("privateBrowsingButton")) .perform(click()) @@ -579,10 +590,11 @@ private fun verifySearchEngineIcon(searchEngineIcon: Bitmap, searchEngineName: S } private fun getSearchEngine(searchEngineName: String) = - Search(appContext).searchEngineManager.getDefaultSearchEngine(appContext, searchEngineName) + appContext.components.core.store.state.search.searchEngines.find { it.name == searchEngineName } private fun verifySearchEngineIcon(searchEngineName: String) { val ddgSearchEngine = getSearchEngine(searchEngineName) + ?: throw AssertionError("No search engine with name $searchEngineName") verifySearchEngineIcon(ddgSearchEngine.icon, ddgSearchEngine.name) } @@ -806,3 +818,8 @@ private fun tab(title: String) = withText(title) ) ) + +private fun startBrowsingButton(): ViewInteraction { + scrollToElementByText("Start browsing") + return onView(allOf(withText("Start browsing"))) +} diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/LibrarySubMenusMultipleSelectionToolbarRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/LibrarySubMenusMultipleSelectionToolbarRobot.kt index 483ad546f..10d39cca5 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/LibrarySubMenusMultipleSelectionToolbarRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/LibrarySubMenusMultipleSelectionToolbarRobot.kt @@ -23,6 +23,7 @@ import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull import org.hamcrest.Matchers.allOf import org.mozilla.fenix.helpers.TestAssetHelper +import org.mozilla.fenix.helpers.TestHelper.packageName /* * Implementation of Robot Pattern for the multiple selection toolbar of History and Bookmarks menus. @@ -99,7 +100,7 @@ class LibrarySubMenusMultipleSelectionToolbarRobot { fun clickOpenNewTab(interact: TabDrawerRobot.() -> Unit): TabDrawerRobot.Transition { openInNewTabButton().click() mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/tab_layout")), + Until.findObject(By.res("$packageName:id/tab_layout")), waitingTime ) @@ -110,7 +111,7 @@ class LibrarySubMenusMultipleSelectionToolbarRobot { fun clickOpenPrivateTab(interact: TabDrawerRobot.() -> Unit): TabDrawerRobot.Transition { openInPrivateTabButton().click() mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/tab_layout")), + Until.findObject(By.res("$packageName:id/tab_layout")), waitingTime ) 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 184bf7eaf..1a66130a9 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 @@ -26,13 +26,13 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until -import kotlinx.android.synthetic.main.fragment_search_dialog.view.* import org.hamcrest.CoreMatchers.anyOf import org.hamcrest.CoreMatchers.containsString import org.hamcrest.CoreMatchers.not 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 @@ -43,16 +43,18 @@ import org.mozilla.fenix.helpers.ext.waitNotNull */ class NavigationToolbarRobot { - fun verifySearchSuggestionsAreMoreThan(suggestionSize: Int, searchTerm: String) = - assertSuggestionsAreMoreThan(suggestionSize, searchTerm) + fun verifySearchSuggestionsAreMoreThan(suggestionSize: Int) = + assertSuggestionsAreMoreThan(suggestionSize) - fun verifySearchSuggestionsAreEqualTo(suggestionSize: Int, searchTerm: String) = - assertSuggestionsAreEqualTo(suggestionSize, searchTerm) + fun verifySearchSuggestionsAreEqualTo(suggestionSize: Int) = + assertSuggestionsAreEqualTo(suggestionSize) fun verifyNoHistoryBookmarks() = assertNoHistoryBookmarks() fun verifyTabButtonShortcutMenuItems() = assertTabButtonShortcutMenuItems() + fun typeSearchTerm(searchTerm: String) = awesomeBar().perform(typeText(searchTerm)) + class Transition { private lateinit var sessionLoadedIdlingResource: SessionLoadedIdlingResource @@ -60,12 +62,12 @@ class NavigationToolbarRobot { fun goBackToWebsite(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/toolbar")), + Until.findObject(By.res("$packageName:id/toolbar")), waitingTime ) urlBar().click() mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/mozac_browser_toolbar_edit_url_view")), + Until.findObject(By.res("$packageName:id/mozac_browser_toolbar_edit_url_view")), waitingTime ) clearAddressBar().click() @@ -82,13 +84,12 @@ class NavigationToolbarRobot { ): BrowserRobot.Transition { sessionLoadedIdlingResource = SessionLoadedIdlingResource() - mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/toolbar")), - waitingTime + mDevice.waitNotNull(Until.findObject(By.res("$packageName:id/toolbar")), + waitingTime ) urlBar().click() mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/mozac_browser_toolbar_edit_url_view")), + Until.findObject(By.res("$packageName:id/mozac_browser_toolbar_edit_url_view")), waitingTime ) @@ -109,10 +110,7 @@ class NavigationToolbarRobot { } fun openThreeDotMenu(interact: ThreeDotMenuMainRobot.() -> Unit): ThreeDotMenuMainRobot.Transition { - mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/mozac_browser_toolbar_menu")), - waitingTime - ) + mDevice.waitNotNull(Until.findObject(By.res("$packageName:id/mozac_browser_toolbar_menu")), waitingTime) threeDotButton().click() ThreeDotMenuMainRobot().interact() @@ -134,11 +132,7 @@ class NavigationToolbarRobot { interact: BrowserRobot.() -> Unit ): BrowserRobot.Transition { sessionLoadedIdlingResource = SessionLoadedIdlingResource() - mDevice.waitNotNull( - Until.findObject(By.res("org.mozilla.fenix.debug:id/toolbar")), - waitingTime - ) - + mDevice.waitNotNull(Until.findObject(By.res("$packageName:id/toolbar")), waitingTime) urlBar().click() awesomeBar().perform(replaceText(url.toString()), pressImeActionButton()) @@ -246,18 +240,12 @@ fun clickUrlbar(interact: SearchRobot.() -> Unit): SearchRobot.Transition { return SearchRobot.Transition() } -private fun assertSuggestionsAreEqualTo(suggestionSize: Int, searchTerm: String) { - mDevice.waitForIdle() - awesomeBar().perform(typeText(searchTerm)) - +private fun assertSuggestionsAreEqualTo(suggestionSize: Int) { mDevice.waitForIdle() onView(withId(R.id.awesome_bar)).check(suggestionsAreEqualTo(suggestionSize)) } -private fun assertSuggestionsAreMoreThan(suggestionSize: Int, searchTerm: String) { - mDevice.waitForIdle() - awesomeBar().perform(typeText(searchTerm)) - +private fun assertSuggestionsAreMoreThan(suggestionSize: Int) { mDevice.waitForIdle() onView(withId(R.id.awesome_bar)).check(suggestionsAreGreaterThan(suggestionSize)) } 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 22be73813..5f565b257 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,18 +6,19 @@ package org.mozilla.fenix.ui.robots -import android.widget.ToggleButton import androidx.recyclerview.widget.RecyclerView 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 @@ -37,6 +38,7 @@ import org.mozilla.fenix.R import org.mozilla.fenix.helpers.SessionLoadedIdlingResource 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 import org.mozilla.fenix.helpers.ext.waitNotNull @@ -62,15 +64,16 @@ class SearchRobot { } fun verifyDefaultSearchEngine(expectedText: String) = assertDefaultSearchEngine(expectedText) + fun verifyEnginesListShortcutContains(searchEngineName: String) = assertEngineListShortcutContains(searchEngineName) + fun changeDefaultSearchEngine(searchEngineName: String) = selectDefaultSearchEngine(searchEngineName) - fun clickSearchEngineButton() { - val searchEngineButton = mDevice.findObject(UiSelector() - .instance(1) - .className(ToggleButton::class.java)) - searchEngineButton.waitForExists(waitingTime) - searchEngineButton.click() + fun clickSearchEngineShortcutButton() { + val searchEnginesShortcutButton = mDevice.findObject(UiSelector() + .resourceId("$packageName:id/search_engines_shortcut_button")) + searchEnginesShortcutButton.waitForExists(waitingTime) + searchEnginesShortcutButton.click() } fun clickScanButton() { @@ -266,6 +269,12 @@ private fun assertSearchEngineList() { .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) } +private fun assertEngineListShortcutContains(searchEngineName: String) { + onView(withId(R.id.awesome_bar)) + .perform(swipeDown()) + .check(matches(hasDescendant(withText(searchEngineName)))) +} + private fun selectDefaultSearchEngine(searchEngine: String) { onView(withId(R.id.mozac_browser_toolbar_edit_icon)).click() onView(withText(searchEngine)) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDataCollectionRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDataCollectionRobot.kt index 8fd2d9259..431680d24 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDataCollectionRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuDataCollectionRobot.kt @@ -32,10 +32,13 @@ class SettingsSubMenuDataCollectionRobot { fun verifyMarketingDataSwitchDefault() = assertMarketingDataValueSwitchDefault() + fun verifyExperimentsSwitchDefault() = assertExperimentsSwitchDefault() + fun verifyDataCollectionSubMenuItems() { verifyDataCollectionOptions() verifyUsageAndTechnicalDataSwitchDefault() verifyMarketingDataSwitchDefault() + verifyExperimentsSwitchDefault() } class Transition { @@ -76,6 +79,9 @@ private fun assertDataCollectionOptions() { onView(withText(marketingDataText)) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + + onView(withText(R.string.preference_experiments_2)).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + onView(withText(R.string.preference_experiments_summary_2)).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) } private fun usageAndTechnicalDataButton() = onView(withText(R.string.preference_usage_data)) @@ -87,3 +93,8 @@ private fun marketingDataButton() = onView(withText(R.string.preferences_marketi private fun assertMarketingDataValueSwitchDefault() = marketingDataButton() .assertIsEnabled(isEnabled = true) + +private fun experimentsButton() = onView(withText(R.string.preference_experiments_2)) + +private fun assertExperimentsSwitchDefault() = experimentsButton() + .assertIsEnabled(isEnabled = true) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuLoginsAndPasswordsSavedLoginsRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuLoginsAndPasswordsSavedLoginsRobot.kt index 3c093ff5e..b4cf853e2 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuLoginsAndPasswordsSavedLoginsRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuLoginsAndPasswordsSavedLoginsRobot.kt @@ -10,11 +10,15 @@ 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.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.uiautomator.By import androidx.test.uiautomator.Until import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers.containsString +import org.mozilla.fenix.R import org.mozilla.fenix.helpers.TestAssetHelper +import org.mozilla.fenix.helpers.click import org.mozilla.fenix.helpers.ext.waitNotNull /** @@ -22,7 +26,7 @@ import org.mozilla.fenix.helpers.ext.waitNotNull */ class SettingsSubMenuLoginsAndPasswordsSavedLoginsRobot { - fun verifySavedLoginsView() = assertSavedLoginsView() + fun verifySecurityPromptForLogins() = assertSavedLoginsView() fun verifySavedLoginsAfterSync() { mDevice.waitNotNull( @@ -43,6 +47,13 @@ class SettingsSubMenuLoginsAndPasswordsSavedLoginsRobot { fun verifyLocalhostExceptionAdded() = onView(ViewMatchers.withText(containsString("localhost"))) .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + fun viewSavedLoginDetails() = onView(ViewMatchers.withText("test@example.com")).click() + + fun revealPassword() = onView(withId(R.id.revealPasswordButton)).click() + + fun verifyPasswordSaved(password: String) = + onView(withId(R.id.passwordText)).check(matches(withText(password))) + class Transition { fun goBack(interact: SettingsSubMenuLoginsAndPasswordRobot.() -> Unit): SettingsSubMenuLoginsAndPasswordRobot.Transition { goBackButton().perform(ViewActions.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 624f6b453..5ce0944af 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 @@ -125,9 +125,6 @@ private fun assertOpenLinksInPrivateTabOff() { } private fun assertPrivateBrowsingShortcutIcon() { - mDevice.wait( - Until.findObject(text("Private Firefox Preview")), - waitingTime - ) + mDevice.wait(Until.findObject(text("Private Firefox Preview")), waitingTime) assertTrue(mDevice.hasObject(text("Private Firefox Preview"))) } 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 13f9cd795..94e857a83 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 @@ -13,6 +13,7 @@ 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.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText @@ -20,6 +21,8 @@ import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import org.hamcrest.CoreMatchers +import org.mozilla.fenix.R +import org.mozilla.fenix.helpers.click /** * Implementation of Robot Pattern for the settings search sub menu. @@ -32,12 +35,26 @@ class SettingsSubMenuSearchRobot { fun verifyShowClipboardSuggestions() = assertShowClipboardSuggestions() fun verifySearchBrowsingHistory() = assertSearchBrowsingHistory() fun verifySearchBookmarks() = assertSearchBookmarks() + fun changeDefaultSearchEngine(searchEngineName: String) = - selectDefaultSearchEngine(searchEngineName) + selectSearchEngine(searchEngineName) fun disableShowSearchSuggestions() = toggleShowSearchSuggestions() fun enableShowSearchShortcuts() = toggleShowSearchShortcuts() + fun openAddSearchEngineMenu() = addSearchEngineButton().click() + + fun verifyAddSearchEngineList() = assertAddSearchEngineList() + + fun verifyEngineListContains(searchEngineName: String) = assertEngineListContains(searchEngineName) + + fun saveNewSearchEngine() = addSearchEngineSaveButton().click() + + fun addNewSearchEngine(searchEngineName: String) { + selectSearchEngine(searchEngineName) + saveNewSearchEngine() + } + class Transition { val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @@ -120,16 +137,12 @@ private fun assertSearchBookmarks() { .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) } -private fun selectDefaultSearchEngine(searchEngine: String) { +private fun selectSearchEngine(searchEngine: String) { onView(withText(searchEngine)) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) .perform(click()) } -private fun selectDuckDuckGoAsSearchEngine() { - selectDefaultSearchEngine("DuckDuckGo") -} - private fun toggleShowSearchSuggestions() { onView(withId(androidx.preference.R.id.recycler_view)).perform( RecyclerViewActions.scrollTo( @@ -154,3 +167,17 @@ private fun toggleShowSearchShortcuts() { private fun goBackButton() = onView(CoreMatchers.allOf(withContentDescription("Navigate up"))) + +private fun addSearchEngineButton() = onView(withText("Add search engine")) + +private fun assertAddSearchEngineList() { + onView(withText("Reddit")).check(matches(isDisplayed())) + onView(withText("YouTube")).check(matches(isDisplayed())) + onView(withText("Other")).check(matches(isDisplayed())) +} + +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)))) +} diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuSitePermissionsCommonRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuSitePermissionsCommonRobot.kt index e88b38ba0..dd129d479 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuSitePermissionsCommonRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuSitePermissionsCommonRobot.kt @@ -7,14 +7,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.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.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.Matchers.not import org.mozilla.fenix.R +import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime import org.mozilla.fenix.helpers.assertIsChecked import org.mozilla.fenix.helpers.click @@ -41,6 +45,8 @@ class SettingsSubMenuSitePermissionsCommonRobot { fun verifyBlockedByAndroid() = assertBlockedByAndroid() + fun verifyUnblockedByAndroid() = assertUnblockedByAndroid() + fun verifyToAllowIt() = assertToAllowIt() fun verifyGotoAndroidSettings() = assertGotoAndroidSettings() @@ -81,6 +87,22 @@ class SettingsSubMenuSitePermissionsCommonRobot { verifyCheckCommonRadioButtonDefault() } + fun clickGoToSettingsButton() { + goToSettingsButton().click() + mDevice.findObject(UiSelector().resourceId("com.android.settings:id/list")) + .waitForExists(waitingTime) + } + + fun openAppSystemPermissionsSettings() { + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + mDevice.findObject(UiSelector().textContains("Permissions")).click() + } + + fun switchAppPermissionSystemSetting(permissionCategory: String) { + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + mDevice.findObject(UiSelector().textContains(permissionCategory)).click() + } + class Transition { val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())!! @@ -131,6 +153,9 @@ private fun assertCheckCommonRadioButtonDefault() { private fun assertBlockedByAndroid() = onView(withText(R.string.phone_feature_blocked_by_android)) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun assertUnblockedByAndroid() = onView(withText(R.string.phone_feature_blocked_by_android)) + .check(matches(not(isDisplayed()))) + private fun assertToAllowIt() = onView(withText(R.string.phone_feature_blocked_intro)) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) @@ -143,8 +168,10 @@ private fun assertTapPermissions() = onView(withText("2. Tap Permissions")) private fun assertToggleNameToON(name: String) = onView(withText(name)) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) -private fun assertGoToSettingsButton() = onView(withId(R.id.settings_button)) - .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) +private fun assertGoToSettingsButton() = + goToSettingsButton().check(matches(withEffectiveVisibility(Visibility.VISIBLE))) private fun goBackButton() = onView(allOf(withContentDescription("Navigate up"))) + +private fun goToSettingsButton() = onView(withId(R.id.settings_button)) 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 03e6ec5bf..bbcd60285 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 @@ -115,7 +115,6 @@ class ThreeDotMenuMainRobot { fun verifyAddFirefoxHome() = assertAddToFirefoxHome() fun verifyAddToMobileHome() = assertAddToMobileHome() fun verifyDesktopSite() = assertDesktopSite() - fun verifyOpenInAppButton() = assertOpenInAppButton() fun verifyDownloadsButton() = assertDownloadsButton() fun verifyThreeDotMainMenuItems() { @@ -519,17 +518,6 @@ private fun assertDesktopSite() { desktopSiteButton().check(matches(isDisplayed())) } -private fun openInAppButton() = - onView(allOf(withText(R.string.browser_menu_open_app_link))) -private fun assertOpenInAppButton() { - onView(withId(R.id.mozac_browser_menu_recyclerView)) - .perform( - RecyclerViewActions.scrollTo( - hasDescendant(withText(R.string.browser_menu_open_app_link)) - ) - ).check(matches(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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c4d1e1cf..efb94c3c0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -232,6 +232,9 @@ + + window.history.back() ); + } +}); + +parseQuery(document.documentURI); diff --git a/app/src/main/assets/high_risk_error_pages.html b/app/src/main/assets/high_risk_error_pages.html index c74469ca7..c73012b14 100644 --- a/app/src/main/assets/high_risk_error_pages.html +++ b/app/src/main/assets/high_risk_error_pages.html @@ -8,6 +8,7 @@ + @@ -29,14 +30,9 @@ - + - - + diff --git a/app/src/main/assets/lowMediumErrorPages.js b/app/src/main/assets/lowMediumErrorPages.js new file mode 100644 index 000000000..3fc18a9ef --- /dev/null +++ b/app/src/main/assets/lowMediumErrorPages.js @@ -0,0 +1,116 @@ +/* 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/. */ + +/** + * Handles the parsing of the ErrorPages URI and then passes them to injectValues + */ +function parseQuery(queryString) { + if (queryString[0] === '?') { + queryString = queryString.substr(1); + } + const query = Object.fromEntries(new URLSearchParams(queryString).entries()); + injectValues(query); + updateShowSSL(query); +}; + +/** + * Updates the HTML elements based on the queryMap + */ +function injectValues(queryMap) { + // Go through each element and inject the values + document.title = queryMap.title; + document.getElementById('errorTitleText').innerHTML = queryMap.title; + document.getElementById('errorShortDesc').innerHTML = queryMap.description; + document.getElementById('errorTryAgain').innerHTML = queryMap.button; + document.getElementById('advancedButton').innerHTML = queryMap.badCertAdvanced; + document.getElementById('badCertTechnicalInfo').innerHTML = queryMap.badCertTechInfo; + document.getElementById('advancedPanelBackButton').innerHTML = queryMap.badCertGoBack; + document.getElementById('advancedPanelAcceptButton').innerHTML = queryMap.badCertAcceptTemporary; + + // If no image is passed in, remove the element so as not to leave an empty iframe + const errorImage = document.getElementById('errorImage'); + if (!queryMap.image) { + errorImage.remove(); + } else { + errorImage.src = "resource://android/assets/" + queryMap.image; + } +}; + +let advancedVisible = false; + +/** + * Used to show or hide the "advanced" button based on the validity of the SSL certificate + */ +function updateShowSSL(queryMap) { + /** @type {'true' | 'false'} */ + const showSSL = queryMap.showSSL; + if (typeof document.addCertException === 'undefined') { + document.getElementById('advancedButton').style.display='none'; + } else { + if (showSSL === 'true') { + document.getElementById('advancedButton').style.display='block'; + } else { + document.getElementById('advancedButton').style.display='none'; + } + } +}; + +/** + * Used to display information about the SSL certificate in `error_pages.html` + */ +function toggleAdvancedAndScroll() { + const advancedPanel = document.getElementById('badCertAdvancedPanel'); + if (advancedVisible) { + advancedPanel.style.display='none'; + } else { + advancedPanel.style.display='block'; + } + advancedVisible = !advancedVisible; + + const horizontalLine = document.getElementById("horizontalLine"); + const advancedPanelAcceptButton = document.getElementById( + "advancedPanelAcceptButton" + ); + const badCertAdvancedPanel = document.getElementById( + "badCertAdvancedPanel" + ); + + // We know that the button is being displayed + if (badCertAdvancedPanel.style.display === "block") { + horizontalLine.hidden = false; + advancedPanelAcceptButton.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + } else { + horizontalLine.hidden = true; + } +}; + +/** + * Used to bypass an SSL pages in `error_pages.html` + */ +async function acceptAndContinue(temporary) { + try { + await document.addCertException(temporary); + location.reload(); + } catch (error) { + console.error("Unexpected error: " + error); + } +}; + +document.addEventListener('DOMContentLoaded', function () { + if (window.history.length == 1) { + document.getElementById('advancedPanelBackButton').style.display = 'none'; + } else { + document.getElementById('advancedPanelBackButton').addEventListener('click', () => window.history.back()); + } + + document.getElementById('errorTryAgain').addEventListener('click', () => window.location.reload()); + document.getElementById('advancedButton').addEventListener('click', toggleAdvancedAndScroll); + document.getElementById('advancedPanelAcceptButton').addEventListener('click', () => acceptAndContinue(true)); +}); + +parseQuery(document.documentURI); diff --git a/app/src/main/assets/low_and_medium_risk_error_pages.html b/app/src/main/assets/low_and_medium_risk_error_pages.html index baa08deff..59e662987 100644 --- a/app/src/main/assets/low_and_medium_risk_error_pages.html +++ b/app/src/main/assets/low_and_medium_risk_error_pages.html @@ -8,6 +8,7 @@ + - + @@ -52,7 +52,6 @@ >
@@ -100,5 +98,5 @@ } - + diff --git a/app/src/main/java/org/mozilla/fenix/AppRequestInterceptor.kt b/app/src/main/java/org/mozilla/fenix/AppRequestInterceptor.kt index 915320890..063a73cf2 100644 --- a/app/src/main/java/org/mozilla/fenix/AppRequestInterceptor.kt +++ b/app/src/main/java/org/mozilla/fenix/AppRequestInterceptor.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix import android.content.Context import android.net.ConnectivityManager import androidx.core.content.getSystemService +import androidx.navigation.NavController import mozilla.components.browser.errorpages.ErrorPages import mozilla.components.browser.errorpages.ErrorType import mozilla.components.concept.engine.EngineSession @@ -14,8 +15,18 @@ import mozilla.components.concept.engine.request.RequestInterceptor import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.isOnline +import java.lang.ref.WeakReference + +class AppRequestInterceptor( + private val context: Context +) : RequestInterceptor { + + private var navController: WeakReference? = null + + fun setNavigationController(navController: NavController) { + this.navController = WeakReference(navController) + } -class AppRequestInterceptor(private val context: Context) : RequestInterceptor { override fun onLoadRequest( engineSession: EngineSession, uri: String, @@ -26,6 +37,11 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor { isDirectNavigation: Boolean, isSubframeRequest: Boolean ): RequestInterceptor.InterceptionResponse? { + + interceptAmoRequest(uri, isSameDomain, hasUserGesture)?.let { response -> + return response + } + return context.components.services.appLinksInterceptor .onLoadRequest( engineSession, @@ -56,7 +72,43 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor { htmlResource = riskLevel.htmlRes ) - return RequestInterceptor.ErrorResponse.Uri(errorPageUri) + return RequestInterceptor.ErrorResponse(errorPageUri) + } + + /** + * Checks if the provided [uri] is a request to install an add-on from addons.mozilla.org and + * redirects to Add-ons Manager to trigger installation if needed. + * + * @return [RequestInterceptor.InterceptionResponse.Deny] when installation was triggered and + * the original request can be skipped, otherwise null to continue loading the page. + */ + private fun interceptAmoRequest( + uri: String, + isSameDomain: Boolean, + hasUserGesture: Boolean + ): RequestInterceptor.InterceptionResponse? { + // First we execute a quick check to see if this is a request we're interested in i.e. a + // request triggered by the user and coming from AMO. + if (hasUserGesture && isSameDomain && uri.startsWith(AMO_BASE_URL)) { + + // Check if this is a request to install an add-on. + val matchResult = AMO_INSTALL_URL_REGEX.toRegex().matchEntire(uri) + if (matchResult != null) { + + // Navigate and trigger add-on installation. + matchResult.groupValues.getOrNull(1)?.let { addonId -> + navController?.get()?.navigate( + NavGraphDirections.actionGlobalAddonsManagementFragment(addonId) + ) + + // We've redirected to the add-ons management fragment, skip original request. + return RequestInterceptor.InterceptionResponse.Deny + } + } + } + + // In all other case we let the original request proceed. + return null } /** @@ -116,5 +168,7 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor { companion object { internal const val LOW_AND_MEDIUM_RISK_ERROR_PAGES = "low_and_medium_risk_error_pages.html" internal const val HIGH_RISK_ERROR_PAGES = "high_risk_error_pages.html" + internal const val AMO_BASE_URL = "https://addons.mozilla.org" + internal const val AMO_INSTALL_URL_REGEX = "$AMO_BASE_URL/android/downloads/file/([^\\s]+)/([^\\s]+\\.xpi)" } } diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index d456632fa..8e16726f5 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -27,12 +27,23 @@ object FeatureFlags { const val externalDownloadManager = true /** - * Enables swipe to delete in bookmarks + * Enables ETP cookie purging */ - val bookmarkSwipeToDelete = Config.channel.isNightlyOrDebug + val etpCookiePurging = Config.channel.isNightlyOrDebug /** - * Enables ETP cookie purging + * Enables the Nimbus experiments library, especially the settings toggle to opt-out of + * all experiments. */ - val etpCookiePurging = Config.channel.isNightlyOrDebug + val nimbusExperiments = Config.channel.isNightlyOrDebug + + /** + * Enables the new MediaSession API. + */ + val newMediaSessionApi = Config.channel.isNightlyOrDebug + + /** + * Enabled showing site permission indicators in the toolbars. + */ + val permissionIndicatorsToolbar = Config.channel.isNightlyOrDebug } diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt index 3178b3a34..baf88849a 100644 --- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt +++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -22,10 +22,10 @@ import kotlinx.coroutines.launch import mozilla.appservices.Megazord import mozilla.components.browser.session.Session import mozilla.components.browser.state.action.SystemAction +import mozilla.components.concept.base.crash.Breadcrumb import mozilla.components.concept.push.PushProcessor import mozilla.components.feature.addons.update.GlobalAddonDependencyProvider import mozilla.components.lib.crash.CrashReporter -import mozilla.components.service.experiments.Experiments import mozilla.components.service.glean.Glean import mozilla.components.service.glean.config.Configuration import mozilla.components.service.glean.net.ConceptFetchHttpUploader @@ -170,25 +170,6 @@ open class FenixApplication : LocaleAwareApplication(), Provider { registerActivityLifecycleCallbacks(PerformanceActivityLifecycleCallbacks(queue)) } - fun queueInitExperiments() { - @Suppress("ControlFlowWithEmptyBody") - if (settings().isExperimentationEnabled) { - queue.runIfReadyOrQueue { - Experiments.initialize( - applicationContext = applicationContext, - onExperimentsUpdated = null, - configuration = mozilla.components.service.experiments.Configuration( - httpClient = components.core.client, - kintoEndpoint = KINTO_ENDPOINT_PROD - ) - ) - } - } else { - // We should make a better way to opt out for when we have more experiments - // See https://github.com/mozilla-mobile/fenix/issues/6278 - } - } - fun queueInitStorageAndServices() { components.performance.visualCompletenessQueue.queue.runIfReadyOrQueue { GlobalScope.launch(Dispatchers.IO) { @@ -229,7 +210,6 @@ open class FenixApplication : LocaleAwareApplication(), Provider { // We init these items in the visual completeness queue to avoid them initing in the critical // startup path, before the UI finishes drawing (i.e. visual completeness). - queueInitExperiments() queueInitStorageAndServices() queueMetrics() queueReviewPrompt() @@ -317,6 +297,21 @@ open class FenixApplication : LocaleAwareApplication(), Provider { override fun onTrimMemory(level: Int) { super.onTrimMemory(level) + // Additional logging and breadcrumb to debug memory issues: + // https://github.com/mozilla-mobile/fenix/issues/12731 + + logger.info("onTrimMemory(), level=$level, main=${isMainProcess()}") + + components.analytics.crashReporter.recordCrashBreadcrumb(Breadcrumb( + category = "Memory", + message = "onTrimMemory()", + data = mapOf( + "level" to level.toString(), + "main" to isMainProcess().toString() + ), + level = Breadcrumb.Level.INFO + )) + runOnlyInMainProcess { components.core.icons.onTrimMemory(level) @@ -458,9 +453,5 @@ open class FenixApplication : LocaleAwareApplication(), Provider { } } - companion object { - private const val KINTO_ENDPOINT_PROD = "https://firefox.settings.services.mozilla.com/v1" - } - override fun getWorkManagerConfiguration() = Builder().setMinimumLoggingLevel(INFO).build() } diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index 67f662879..5ca572000 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -37,15 +37,19 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import mozilla.components.browser.search.SearchEngine +import mozilla.appservices.places.BookmarkRoot +import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.WebExtensionState 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.feature.contextmenu.DefaultSelectionActionDelegate import mozilla.components.feature.privatemode.notification.PrivateNotificationFeature import mozilla.components.feature.search.BrowserStoreSearchAdapter +import mozilla.components.feature.search.ext.legacy import mozilla.components.service.fxa.sync.SyncReason import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.ktx.android.arch.lifecycle.addObservers @@ -81,6 +85,7 @@ import org.mozilla.fenix.home.intent.OpenSpecificTabIntentProcessor import org.mozilla.fenix.home.intent.SpeechProcessingIntentProcessor 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.recentlyclosed.RecentlyClosedFragmentDirections import org.mozilla.fenix.perf.Performance @@ -139,7 +144,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { private val externalSourceIntentProcessors by lazy { listOf( - SpeechProcessingIntentProcessor(this, components.analytics.metrics), + SpeechProcessingIntentProcessor(this, components.core.store, components.analytics.metrics), StartSearchIntentProcessor(components.analytics.metrics), DeepLinkIntentProcessor(this, components.analytics.leanplumMetricsService), OpenBrowserIntentProcessor(this, ::getIntentSessionId), @@ -237,6 +242,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { startupTelemetryOnCreateCalled(intent.toSafeIntent(), savedInstanceState != null) + components.core.requestInterceptor.setNavigationController(navHost.navController) + StartupTimeline.onActivityCreateEndHome(this) // DO NOT MOVE ANYTHING BELOW HERE. } @@ -337,6 +344,20 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { // https://github.com/mozilla-mobile/android-components/issues/8679 settings().topSitesSize = components.core.topSitesStorage.cachedTopSites.size + lifecycleScope.launch(IO) { + components.core.bookmarksStorage.getTree(BookmarkRoot.Root.id, true)?.let { + val desktopRootNode = DesktopFolders( + applicationContext, + showMobileRoot = false + ).withOptionalDesktopFolders(it) + settings().desktopBookmarksSize = getBookmarkCount(desktopRootNode) + } + + components.core.bookmarksStorage.getTree(BookmarkRoot.Mobile.id, true)?.let { + settings().mobileBookmarksSize = getBookmarkCount(it) + } + } + super.onPause() // Diagnostic breadcrumb for "Display already aquired" crash: @@ -356,6 +377,25 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { BrowsersCache.resetAll() } + private fun getBookmarkCount(node: BookmarkNode): Int { + val children = node.children + return if (children == null) { + 0 + } else { + var count = 0 + + for (child in children) { + if (child.type == BookmarkNodeType.FOLDER) { + count += getBookmarkCount(child) + } else if (child.type == BookmarkNodeType.ITEM) { + count++ + } + } + + count + } + } + override fun onDestroy() { super.onDestroy() @@ -737,23 +777,24 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { } } else components.useCases.sessionUseCases.loadUrl - val searchUseCase: (String) -> Unit = { searchTerms -> + // In situations where we want to perform a search but have no search engine (e.g. the user + // has removed all of them, or we couldn't load any) we will pass searchTermOrURL to Gecko + // and let it try to load whatever was entered. + if ((!forceSearch && searchTermOrURL.isUrl()) || engine == null) { + loadUrlUseCase.invoke(searchTermOrURL.toNormalizedUrl(), flags) + } else { if (newTab) { components.useCases.searchUseCases.newTabSearch .invoke( - searchTerms, + searchTermOrURL, SessionState.Source.USER_ENTERED, true, mode.isPrivate, - searchEngine = engine + searchEngine = engine.legacy() ) - } else components.useCases.searchUseCases.defaultSearch.invoke(searchTerms, engine) - } - - if (!forceSearch && searchTermOrURL.isUrl()) { - loadUrlUseCase.invoke(searchTermOrURL.toNormalizedUrl(), flags) - } else { - searchUseCase.invoke(searchTermOrURL) + } else { + components.useCases.searchUseCases.defaultSearch.invoke(searchTermOrURL, engine.legacy()) + } } if (components.core.engine.profiler?.isProfilerActive() == true) { diff --git a/app/src/main/java/org/mozilla/fenix/TelemetryMiddleware.kt b/app/src/main/java/org/mozilla/fenix/TelemetryMiddleware.kt index 88904292f..c5c64df0a 100644 --- a/app/src/main/java/org/mozilla/fenix/TelemetryMiddleware.kt +++ b/app/src/main/java/org/mozilla/fenix/TelemetryMiddleware.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix import androidx.annotation.VisibleForTesting import mozilla.components.browser.state.action.BrowserAction import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.action.DownloadAction import mozilla.components.browser.state.action.TabListAction import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.selector.normalTabs @@ -48,7 +49,7 @@ class TelemetryMiddleware( } } - @Suppress("TooGenericExceptionCaught") + @Suppress("TooGenericExceptionCaught", "ComplexMethod") override fun invoke( context: MiddlewareContext, next: (BrowserAction) -> Unit, @@ -86,6 +87,9 @@ class TelemetryMiddleware( } } } + is DownloadAction.AddDownloadAction -> { + metrics.track(Event.DownloadAdded) + } } next(action) 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 4b414194c..5078e1002 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt @@ -15,11 +15,13 @@ import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import android.view.inputmethod.EditorInfo import androidx.appcompat.widget.SearchView +import androidx.annotation.VisibleForTesting import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs 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 @@ -37,6 +39,7 @@ import mozilla.components.feature.addons.ui.translateName import io.github.forkmaintainers.iceraven.components.PagedAddonInstallationDialogFragment 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.ext.components import org.mozilla.fenix.ext.getRootView @@ -54,6 +57,8 @@ import java.util.concurrent.CancellationException @Suppress("TooManyFunctions", "LargeClass") class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) { + private val args by navArgs() + /** * Whether or not an add-on installation is in progress. */ @@ -70,6 +75,15 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) return super.onCreateView(inflater, container, savedInstanceState) } + private var installExternalAddonComplete: Boolean + set(value) { + arguments?.putBoolean(BUNDLE_KEY_INSTALL_EXTERNAL_ADDON_COMPLETE, value) + } + get() { + return arguments?.getBoolean(BUNDLE_KEY_INSTALL_EXTERNAL_ADDON_COMPLETE, false) ?: false + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) bindRecyclerView(view) @@ -156,9 +170,13 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) val recyclerView = view.add_ons_list recyclerView.layoutManager = LinearLayoutManager(requireContext()) val shouldRefresh = adapter != null + + // If the fragment was launched to install an "external" add-on from AMO, we deactivate + // the cache to get the most up-to-date list of add-ons to match against. + val allowCache = args.installAddonId == null || installExternalAddonComplete lifecycleScope.launch(IO) { try { - addons = requireContext().components.addonManager.getAddons() + addons = requireContext().components.addonManager.getAddons(allowCache = allowCache) lifecycleScope.launch(Dispatchers.Main) { runIfFragmentIsAttached { if (!shouldRefresh) { @@ -177,6 +195,12 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) if (shouldRefresh) { adapter?.updateAddons(addons!!) } + + args.installAddonId?.let { addonIn -> + if (!installExternalAddonComplete) { + installExternalAddon(addons, addonIn) + } + } } } } catch (e: AddonManagerException) { @@ -195,8 +219,32 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) } } + @VisibleForTesting + internal fun installExternalAddon(supportedAddons: List, installAddonId: String) { + val addonToInstall = supportedAddons.find { it.downloadId == installAddonId } + if (addonToInstall == null) { + showErrorSnackBar(getString(R.string.addon_not_supported_error)) + } else { + if (addonToInstall.isInstalled()) { + showErrorSnackBar(getString(R.string.addon_already_installed)) + } else { + showPermissionDialog(addonToInstall) + } + } + installExternalAddonComplete = true + } + + @VisibleForTesting + internal fun showErrorSnackBar(text: String) { + runIfFragmentIsAttached { + view?.let { + showSnackBar(it, text, FenixSnackbar.LENGTH_LONG) + } + } + } + private fun createAddonStyle(context: Context): PagedAddonsManagerAdapter.Style { - return PagedAddonsManagerAdapter.Style( + return AddonsManagerAdapter.Style( sectionsTextColor = ThemeManager.resolveAttribute(R.attr.primaryText, context), addonNameTextColor = ThemeManager.resolveAttribute(R.attr.primaryText, context), addonSummaryTextColor = ThemeManager.resolveAttribute(R.attr.secondaryText, context), @@ -218,7 +266,8 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) as? PagedAddonInstallationDialogFragment != null } - private fun showPermissionDialog(addon: Addon) { + @VisibleForTesting + internal fun showPermissionDialog(addon: Addon) { if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) { val dialog = PermissionsDialogFragment.newInstance( addon = addon, @@ -352,5 +401,6 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) companion object { private const val PERMISSIONS_DIALOG_FRAGMENT_TAG = "ADDONS_PERMISSIONS_DIALOG_FRAGMENT" private const val INSTALLATION_DIALOG_FRAGMENT_TAG = "ADDONS_INSTALLATION_DIALOG_FRAGMENT" + private const val BUNDLE_KEY_INSTALL_EXTERNAL_ADDON_COMPLETE = "INSTALL_EXTERNAL_ADDON_COMPLETE" } } diff --git a/app/src/main/java/org/mozilla/fenix/addons/Extensions.kt b/app/src/main/java/org/mozilla/fenix/addons/Extensions.kt index 6c8a991f0..c0b135a60 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/Extensions.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/Extensions.kt @@ -14,10 +14,10 @@ import org.mozilla.fenix.components.FenixSnackbar * @param view A [View] used to determine a parent for the [FenixSnackbar]. * @param text The text to display in the [FenixSnackbar]. */ -internal fun showSnackBar(view: View, text: String) { +internal fun showSnackBar(view: View, text: String, duration: Int = FenixSnackbar.LENGTH_SHORT) { FenixSnackbar.make( view = view, - duration = FenixSnackbar.LENGTH_SHORT, + duration = duration, isDisplayedWithBrowserToolbar = true ) .setText(text) 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 2b46473c0..4df9101bf 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -58,7 +58,7 @@ import mozilla.components.feature.contextmenu.ContextMenuFeature import mozilla.components.feature.downloads.DownloadsFeature import mozilla.components.feature.downloads.manager.FetchDownloadManager import mozilla.components.feature.intent.ext.EXTRA_SESSION_ID -import mozilla.components.feature.media.fullscreen.MediaFullscreenOrientationFeature +import mozilla.components.feature.media.fullscreen.MediaSessionFullscreenFeature import mozilla.components.feature.privatemode.feature.SecureWindowFeature import mozilla.components.feature.prompts.PromptFeature import mozilla.components.feature.prompts.share.ShareDelegate @@ -86,6 +86,7 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.IntentReceiverActivity import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.OnBackLongPressedListener +import org.mozilla.fenix.addons.runIfFragmentIsAttached import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.readermode.DefaultReaderModeController @@ -118,10 +119,16 @@ import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings import org.mozilla.fenix.home.HomeScreenViewModel import org.mozilla.fenix.home.SharedViewModel +import org.mozilla.fenix.onboarding.FenixOnboarding +import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.wifi.SitePermissionsWifiIntegration import java.lang.ref.WeakReference +import mozilla.components.feature.media.fullscreen.MediaFullscreenOrientationFeature +import org.mozilla.fenix.FeatureFlags.newMediaSessionApi +import org.mozilla.fenix.settings.PhoneFeature +import org.mozilla.fenix.settings.quicksettings.QuickSettingsSheetDialogFragmentDirections /** * Base fragment extended by [BrowserFragment]. @@ -140,7 +147,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, protected val browserInteractor: BrowserToolbarViewInteractor get() = _browserInteractor!! - private var _browserToolbarView: BrowserToolbarView? = null + @VisibleForTesting + @Suppress("VariableNaming") + internal var _browserToolbarView: BrowserToolbarView? = null @VisibleForTesting internal val browserToolbarView: BrowserToolbarView get() = _browserToolbarView!! @@ -164,6 +173,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, private val secureWindowFeature = ViewBoundFeatureWrapper() private var fullScreenMediaFeature = ViewBoundFeatureWrapper() + private var fullScreenMediaSessionFeature = + ViewBoundFeatureWrapper() private val searchFeature = ViewBoundFeatureWrapper() private var pipFeature: PictureInPictureFeature? = null @@ -176,6 +187,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, private val sharedViewModel: SharedViewModel by activityViewModels() + @VisibleForTesting + internal val onboarding by lazy { FenixOnboarding(requireContext()) } + @CallSuper override fun onCreateView( inflater: LayoutInflater, @@ -218,6 +232,11 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, } observeTabSelection(requireComponents.core.store) + + if (!onboarding.userHasBeenOnboarded()) { + observeTabSource(requireComponents.core.store) + } + requireContext().accessibilityManager.addAccessibilityStateChangeListener(this) } @@ -255,6 +274,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, isPrivate = activity.browsingModeManager.mode.isPrivate ) val browserToolbarController = DefaultBrowserToolbarController( + store = store, activity = activity, navController = findNavController(), metrics = requireComponents.analytics.metrics, @@ -350,6 +370,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, showQuickSettingsDialog() } + browserToolbarView.view.display.setOnPermissionIndicatorClickedListener { + navigateToAutoplaySetting() + } + browserToolbarView.view.display.setOnTrackingProtectionClickedListener { context.metrics.track(Event.TrackingProtectionIconPressed) showTrackingProtectionPanel() @@ -380,14 +404,25 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, view = view ) - fullScreenMediaFeature.set( - feature = MediaFullscreenOrientationFeature( - requireActivity(), - context.components.core.store - ), - owner = this, - view = view - ) + if (newMediaSessionApi) { + fullScreenMediaSessionFeature.set( + feature = MediaSessionFullscreenFeature( + requireActivity(), + context.components.core.store + ), + owner = this, + view = view + ) + } else { + fullScreenMediaFeature.set( + feature = MediaFullscreenOrientationFeature( + requireActivity(), + context.components.core.store + ), + owner = this, + view = view + ) + } val downloadFeature = DownloadsFeature( context.applicationContext, @@ -438,6 +473,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, val dynamicDownloadDialog = DynamicDownloadDialog( container = view.browserLayout, downloadState = downloadState, + metrics = requireComponents.analytics.metrics, didFail = downloadJobStatus == DownloadState.Status.FAILED, tryAgain = downloadFeature::tryAgain, onCannotOpenFile = { @@ -561,7 +597,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, useCase.invoke(request.query) requireActivity().startActivity(openInFenixIntent) } else { - useCase.invoke(request.query, parentSession = parentSession) + useCase.invoke(request.query, parentSessionId = parentSession?.id) } }, owner = this, @@ -609,7 +645,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, context.settings().setSitePermissionSettingListener(viewLifecycleOwner) { // If the user connects to WIFI while on the BrowserFragment, this will update the // SitePermissionsRules (specifically autoplay) accordingly - assignSitePermissionsRules() + runIfFragmentIsAttached { + assignSitePermissionsRules() + } } assignSitePermissionsRules() @@ -633,8 +671,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, .collect { tab -> pipModeChanged(tab) } } - view.swipeRefresh.isEnabled = - FeatureFlags.pullToRefreshEnabled && context.settings().isPullToRefreshEnabledInBrowser + view.swipeRefresh.isEnabled = shouldPullToRefreshBeEnabled() + if (view.swipeRefresh.isEnabled) { val primaryTextColor = ThemeManager.resolveAttribute(R.attr.primaryText, context) @@ -679,6 +717,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, tab -> arrayOf(tab.content.url, tab.content.loadRequest) } .collect { + findInPageIntegration.onBackPressed() browserToolbarView.expand() } } @@ -749,6 +788,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, DynamicDownloadDialog( container = view.browserLayout, downloadState = savedDownloadState.first, + metrics = requireComponents.analytics.metrics, didFail = savedDownloadState.second, tryAgain = onTryAgain, onCannotOpenFile = onCannotOpenFile, @@ -760,6 +800,13 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, browserToolbarView.expand() } + @VisibleForTesting + internal fun shouldPullToRefreshBeEnabled(): Boolean { + return FeatureFlags.pullToRefreshEnabled && + requireContext().settings().isPullToRefreshEnabledInBrowser && + !(requireActivity() as HomeActivity).isImmersive + } + private fun initializeEngineView(toolbarHeight: Int) { val context = requireContext() @@ -842,6 +889,25 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, } } + @VisibleForTesting + @Suppress("ComplexCondition") + internal fun observeTabSource(store: BrowserStore) { + consumeFlow(store) { flow -> + flow.mapNotNull { state -> + state.selectedTab + } + .collect { + if (!onboarding.userHasBeenOnboarded() && + it.content.loadRequest?.triggeredByRedirect != true && + it.source !in intentSourcesList && + it.content.url !in onboardingLinksList + ) { + onboarding.finish() + } + } + } + } + private fun handleTabSelected(selectedTab: TabSessionState) { if (!this.isRemoving) { updateThemeForSession(selectedTab) @@ -1120,6 +1186,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, } final override fun onPictureInPictureModeChanged(enabled: Boolean) { + if (enabled) requireComponents.analytics.metrics.track(Event.MediaPictureInPictureState) pipFeature?.onPictureInPictureModeChanged(enabled) } @@ -1152,6 +1219,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, browserToolbarView.expand() // Without this, fullscreen has a margin at the top. engineView.setVerticalClipping(0) + + requireComponents.analytics.metrics.track(Event.MediaFullscreenState) } else { activity?.exitImmersiveModeIfNeeded() (activity as? HomeActivity)?.let { activity -> @@ -1207,6 +1276,17 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 1 private const val REQUEST_CODE_PROMPT_PERMISSIONS = 2 private const val REQUEST_CODE_APP_PERMISSIONS = 3 + + val onboardingLinksList: List = listOf( + SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE), + SupportUtils.getFirefoxAccountSumoUrl() + ) + + val intentSourcesList: List = listOf( + SessionState.Source.ACTION_SEARCH, + SessionState.Source.ACTION_SEND, + SessionState.Source.ACTION_VIEW + ) } override fun onAccessibilityStateChanged(enabled: Boolean) { @@ -1214,4 +1294,23 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, browserToolbarView.setScrollFlags(enabled) } } + + private fun navigateToAutoplaySetting() { + val directions = QuickSettingsSheetDialogFragmentDirections + .actionGlobalSitePermissionsManagePhoneFeature(PhoneFeature.AUTOPLAY_AUDIBLE) + findNavController().navigate(directions) + } + + // This method is called in response to native web extension messages from + // content scripts (e.g the reader view extension). By the time these + // messages are processed the fragment/view may no longer be attached. + internal fun safeInvalidateBrowserToolbarView() { + runIfFragmentIsAttached { + val toolbarView = _browserToolbarView + if (toolbarView != null) { + toolbarView.view.invalidateActions() + toolbarView.toolbarIntegration.invalidateMenu() + } + } + } } 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 383c91683..22ebead2d 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt @@ -5,9 +5,7 @@ package org.mozilla.fenix.browser import android.content.Context -import android.os.Bundle import android.os.StrictMode -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.content.res.AppCompatResources @@ -30,7 +28,6 @@ import mozilla.components.feature.tabs.WindowFeature import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.fenix.R -import org.mozilla.fenix.addons.runIfFragmentIsAttached import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.metrics.Event @@ -53,17 +50,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { private var readerModeAvailable = false private var openInAppOnboardingObserver: OpenInAppOnboardingObserver? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val view = super.onCreateView(inflater, container, savedInstanceState) - - startPostponedEnterTransition() - return view - } + private var pwaOnboardingObserver: PwaOnboardingObserver? = null @Suppress("LongMethod") override fun initializeUI(view: View): Session? { @@ -121,11 +108,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { readerModeAvailable = available readerModeAction.setSelected(active) - - runIfFragmentIsAttached { - browserToolbarView.view.invalidateActions() - browserToolbarView.toolbarIntegration.invalidateMenu() - } + safeInvalidateBrowserToolbarView() } }, owner = this, @@ -156,6 +139,9 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { ) { browserToolbarView.view } + + @Suppress("DEPRECATION") + // TODO Use browser store instead of session observer: https://github.com/mozilla-mobile/fenix/issues/16945 session?.register(toolbarSessionObserver, viewLifecycleOwner, autoPause = true) if (settings.shouldShowOpenInAppCfr && session != null) { @@ -166,6 +152,8 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { appLinksUseCases = context.components.useCases.appLinksUseCases, container = browserLayout as ViewGroup ) + @Suppress("DEPRECATION") + // TODO Use browser store instead of session observer: https://github.com/mozilla-mobile/fenix/issues/16949 session.register( openInAppOnboardingObserver!!, owner = this, @@ -174,15 +162,15 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { } if (!settings.userKnowsAboutPwas) { - session?.register( - PwaOnboardingObserver( - navController = findNavController(), - settings = settings, - webAppUseCases = context.components.useCases.webAppUseCases - ), - owner = this, - autoPause = true - ) + pwaOnboardingObserver = PwaOnboardingObserver( + store = context.components.core.store, + lifecycleOwner = this, + navController = findNavController(), + settings = settings, + webAppUseCases = context.components.useCases.webAppUseCases + ).also { + it.start() + } } subscribeToTabCollections() @@ -193,9 +181,13 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { // This observer initialized in onStart has a reference to fragment's view. // Prevent it leaking the view after the latter onDestroyView. if (openInAppOnboardingObserver != null) { + @Suppress("DEPRECATION") + // TODO Use browser store instead of session observer: https://github.com/mozilla-mobile/fenix/issues/16949 getSessionById()?.unregister(openInAppOnboardingObserver!!) openInAppOnboardingObserver = null } + + pwaOnboardingObserver?.stop() } private fun subscribeToTabCollections() { @@ -247,7 +239,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { } private val collectionStorageObserver = object : TabCollectionStorage.Observer { - override fun onCollectionCreated(title: String, sessions: List) { + override fun onCollectionCreated(title: String, sessions: List, id: Long?) { showTabSavedToCollectionSnackbar(sessions.size, true) } diff --git a/app/src/main/java/org/mozilla/fenix/browser/OpenInAppOnboardingObserver.kt b/app/src/main/java/org/mozilla/fenix/browser/OpenInAppOnboardingObserver.kt index 63b8c2635..0abb13a5d 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/OpenInAppOnboardingObserver.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/OpenInAppOnboardingObserver.kt @@ -6,6 +6,7 @@ package org.mozilla.fenix.browser import android.content.Context import android.view.ViewGroup +import androidx.annotation.VisibleForTesting import androidx.navigation.NavController import mozilla.components.browser.session.Session import mozilla.components.feature.app.links.AppLinksUseCases @@ -25,8 +26,10 @@ class OpenInAppOnboardingObserver( private val container: ViewGroup ) : Session.Observer { - private var sessionDomainForDisplayedBanner: String? = null - private var infoBanner: InfoBanner? = null + @VisibleForTesting + internal var sessionDomainForDisplayedBanner: String? = null + @VisibleForTesting + internal var infoBanner: InfoBanner? = null override fun onUrlChanged(session: Session, url: String) { sessionDomainForDisplayedBanner?.let { @@ -36,15 +39,14 @@ class OpenInAppOnboardingObserver( } } - @Suppress("ComplexCondition") override fun onLoadingStateChanged(session: Session, loading: Boolean) { + if (loading || settings.openLinksInExternalApp || !settings.shouldShowOpenInAppCfr) { + return + } + val appLink = appLinksUseCases.appLinkRedirect - if (!loading && - !settings.openLinksInExternalApp && - settings.shouldShowOpenInAppCfr && - appLink(session.url).hasExternalApp() - ) { + if (appLink(session.url).hasExternalApp()) { infoBanner = InfoBanner( context = context, message = context.getString(R.string.open_in_app_cfr_info_message), 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 c72182352..346cce7e5 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt @@ -13,8 +13,8 @@ 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.mozac_ui_tabcounter_layout.view.* 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 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 0f9288bbc..128aa8119 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Analytics.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Analytics.kt @@ -8,13 +8,20 @@ import android.app.Application import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.net.Uri +import android.os.StrictMode import mozilla.components.lib.crash.CrashReporter import mozilla.components.lib.crash.service.CrashReporterService import mozilla.components.lib.crash.service.GleanCrashReporterService import mozilla.components.lib.crash.service.MozillaSocorroService import mozilla.components.lib.crash.service.SentryService +import mozilla.components.service.nimbus.NimbusApi +import mozilla.components.service.nimbus.Nimbus as NimbusEnabled +import mozilla.components.service.nimbus.NimbusDisabled +import mozilla.components.service.nimbus.NimbusServerSettings import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.Config +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.ReleaseChannel @@ -22,6 +29,7 @@ import org.mozilla.fenix.components.metrics.AdjustMetricsService import org.mozilla.fenix.components.metrics.GleanMetricsService import org.mozilla.fenix.components.metrics.LeanplumMetricsService import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings import org.mozilla.fenix.perf.lazyMonitored import org.mozilla.fenix.utils.Mockable @@ -90,7 +98,7 @@ class Analytics( val metrics: MetricController by lazyMonitored { MetricController.create( listOf( - GleanMetricsService(context), + GleanMetricsService(context, lazy { context.components.core.store }), leanplumMetricsService, AdjustMetricsService(context as Application) ), @@ -98,6 +106,38 @@ class Analytics( isMarketingDataTelemetryEnabled = { context.settings().isMarketingTelemetryEnabled } ) } + + val experiments: NimbusApi by lazyMonitored { + if (FeatureFlags.nimbusExperiments) { + // Eventually we'll want to use `NimbusDisabled` when we have no NIMBUS_ENDPOINT. + // but we keep this here to not mix feature flags and how we configure Nimbus. + val url: String? = BuildConfig.NIMBUS_ENDPOINT + val serverSettings = if (!url.isNullOrBlank()) { + NimbusServerSettings(url = Uri.parse(url)) + } else { + null + } + + NimbusEnabled(context, serverSettings).apply { + // Global opt out state is stored in Nimbus, and shouldn't be toggled to `true` + // from the app unless the user does so from a UI control. + // However, the user may have opt-ed out of mako experiments already, so + // we should respect that setting here. + val enabled = + context.components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { + context.settings().isExperimentationEnabled + } + if (!enabled) { + globalUserParticipation = enabled + } + // Nimbus should look after downloading experiment definitions from remote settings + // on another thread, and making sure we don't hit the server each time we start. + updateExperiments() + } + } else { + NimbusDisabled() + } + } } fun isSentryEnabled() = !BuildConfig.SENTRY_TOKEN.isNullOrEmpty() diff --git a/app/src/main/java/org/mozilla/fenix/components/Components.kt b/app/src/main/java/org/mozilla/fenix/components/Components.kt index 0938090ba..ba77b9b93 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Components.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -53,14 +53,12 @@ class Components(private val context: Context) { } val services by lazyMonitored { Services(context, backgroundServices.accountManager) } val core by lazyMonitored { Core(context, analytics.crashReporter, strictMode) } - val search by lazyMonitored { Search(context) } val useCases by lazyMonitored { UseCases( context, core.engine, core.sessionManager, core.store, - search.searchEngineManager, core.webAppShortcutManager, core.topSitesStorage ) @@ -69,7 +67,9 @@ class Components(private val context: Context) { IntentProcessors( context, core.sessionManager, + core.store, useCases.sessionUseCases, + useCases.tabsUseCases, useCases.searchUseCases, core.relationChecker, core.customTabsStore, 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 7e7aee6ae..1161ae7fa 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Core.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Core.kt @@ -23,7 +23,6 @@ import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.engine.EngineMiddleware import mozilla.components.browser.session.storage.SessionStorage import mozilla.components.browser.session.undo.UndoMiddleware -import mozilla.components.browser.state.action.RecentlyClosedAction import mozilla.components.browser.state.action.RestoreCompleteAction import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.store.BrowserStore @@ -40,12 +39,13 @@ import mozilla.components.concept.fetch.Client import mozilla.components.feature.customtabs.store.CustomTabsServiceStore import mozilla.components.feature.downloads.DownloadMiddleware import mozilla.components.feature.logins.exceptions.LoginExceptionStorage -import mozilla.components.feature.media.RecordingDevicesNotificationFeature -import mozilla.components.feature.media.middleware.MediaMiddleware +import mozilla.components.feature.media.middleware.RecordingDevicesMiddleware import mozilla.components.feature.pwa.ManifestStorage import mozilla.components.feature.pwa.WebAppShortcutManager import mozilla.components.feature.readerview.ReaderViewMiddleware import mozilla.components.feature.recentlyclosed.RecentlyClosedMiddleware +import mozilla.components.feature.search.middleware.SearchMiddleware +import mozilla.components.feature.search.region.RegionMiddleware import mozilla.components.feature.session.HistoryDelegate import mozilla.components.feature.top.sites.DefaultTopSitesStorage import mozilla.components.feature.top.sites.PinnedSiteStorage @@ -57,19 +57,22 @@ import mozilla.components.lib.dataprotect.generateEncryptionKey import mozilla.components.service.digitalassetlinks.RelationChecker 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.sync.logins.SyncableLoginsStorage import mozilla.components.support.locale.LocaleManager import org.mozilla.fenix.AppRequestInterceptor +import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.Config import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.perf.StrictModeManager import org.mozilla.fenix.TelemetryMiddleware +import org.mozilla.fenix.components.search.SearchMigration import org.mozilla.fenix.downloads.DownloadService import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings import org.mozilla.fenix.perf.lazyMonitored -import org.mozilla.fenix.media.MediaService import org.mozilla.fenix.search.telemetry.ads.AdsTelemetry import org.mozilla.fenix.search.telemetry.incontent.InContentTelemetry import org.mozilla.fenix.settings.SupportUtils @@ -77,6 +80,11 @@ import org.mozilla.fenix.settings.advanced.getSelectedLocale import org.mozilla.fenix.utils.Mockable import org.mozilla.fenix.utils.getUndoDelay import java.util.concurrent.TimeUnit +import mozilla.components.feature.media.MediaSessionFeature +import mozilla.components.feature.media.middleware.MediaMiddleware +import org.mozilla.fenix.FeatureFlags.newMediaSessionApi +import org.mozilla.fenix.media.MediaService +import org.mozilla.fenix.media.MediaSessionService /** * Component group for all core browser functionality. @@ -94,7 +102,7 @@ class Core( */ val engine: Engine by lazyMonitored { val defaultSettings = DefaultSettings( - requestInterceptor = AppRequestInterceptor(context), + requestInterceptor = requestInterceptor, remoteDebuggingEnabled = context.settings().isRemoteDebuggingEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M, testingModeEnabled = false, @@ -135,6 +143,15 @@ class Core( } } + /** + * Passed to [engine] to intercept requests for app links, + * and various features triggered by page load requests. + * + * NB: This does not need to be lazy as it is initialized + * with the engine on startup. + */ + val requestInterceptor = AppRequestInterceptor(context) + /** * [Client] implementation to be used for code depending on `concept-fetch`` */ @@ -153,14 +170,21 @@ class Core( SessionStorage(context, engine = engine) } + private val locationService: LocationService by lazyMonitored { + if (Config.channel.isDebug || BuildConfig.MLS_TOKEN.isEmpty()) { + LocationService.default() + } else { + MozillaLocationService(context, client, BuildConfig.MLS_TOKEN) + } + } + /** * The [BrowserStore] holds the global [BrowserState]. */ val store by lazyMonitored { - BrowserStore( - middleware = listOf( + val middlewareList = + mutableListOf( RecentlyClosedMiddleware(context, RECENTLY_CLOSED_MAX, engine), - MediaMiddleware(context, MediaService::class.java), DownloadMiddleware(context, DownloadService::class.java), ReaderViewMiddleware(), TelemetryMiddleware( @@ -169,11 +193,23 @@ class Core( metrics ), ThumbnailsMiddleware(thumbnailStorage), - UndoMiddleware(::lookupSessionManager, context.getUndoDelay()) - ) + EngineMiddleware.create(engine, ::findSessionById) - ).also { - it.dispatch(RecentlyClosedAction.InitializeRecentlyClosedState) + UndoMiddleware(::lookupSessionManager, context.getUndoDelay()), + RegionMiddleware(context, locationService), + SearchMiddleware( + context, + additionalBundledSearchEngineIds = listOf("reddit", "youtube"), + migration = SearchMigration(context) + ), + RecordingDevicesMiddleware(context) + ) + + if (!newMediaSessionApi) { + middlewareList.add(MediaMiddleware(context, MediaService::class.java)) } + + BrowserStore( + middleware = middlewareList + EngineMiddleware.create(engine, ::findSessionById) + ) } private fun lookupSessionManager(): SessionManager { @@ -213,10 +249,6 @@ class Core( // Install the "cookies" WebExtension and tracks user interaction with SERPs. searchTelemetry.install(engine, store) - // Show an ongoing notification when recording devices (camera, microphone) are used by web content - RecordingDevicesNotificationFeature(context, sessionManager) - .enable() - // Restore the previous state. GlobalScope.launch(Dispatchers.Main) { withContext(Dispatchers.IO) { @@ -255,6 +287,10 @@ class Core( context, engine, icons, R.drawable.ic_status_logo, permissionStorage.permissionsStorage, HomeActivity::class.java ) + + if (newMediaSessionApi) { + MediaSessionFeature(context, MediaSessionService::class.java, store).start() + } } } diff --git a/app/src/main/java/org/mozilla/fenix/components/IntentProcessors.kt b/app/src/main/java/org/mozilla/fenix/components/IntentProcessors.kt index c28a0de53..dd92f560d 100644 --- a/app/src/main/java/org/mozilla/fenix/components/IntentProcessors.kt +++ b/app/src/main/java/org/mozilla/fenix/components/IntentProcessors.kt @@ -6,6 +6,7 @@ package org.mozilla.fenix.components import android.content.Context import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.feature.customtabs.CustomTabIntentProcessor import mozilla.components.feature.customtabs.store.CustomTabsServiceStore import mozilla.components.feature.intent.processing.TabIntentProcessor @@ -14,6 +15,7 @@ import mozilla.components.feature.pwa.intent.TrustedWebActivityIntentProcessor import mozilla.components.feature.pwa.intent.WebAppIntentProcessor import mozilla.components.feature.search.SearchUseCases import mozilla.components.feature.session.SessionUseCases +import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.service.digitalassetlinks.RelationChecker import mozilla.components.support.migration.MigrationIntentProcessor import mozilla.components.support.migration.state.MigrationStore @@ -30,7 +32,9 @@ import org.mozilla.fenix.utils.Mockable class IntentProcessors( private val context: Context, private val sessionManager: SessionManager, + private val store: BrowserStore, private val sessionUseCases: SessionUseCases, + private val tabsUseCases: TabsUseCases, private val searchUseCases: SearchUseCases, private val relationChecker: RelationChecker, private val customTabsStore: CustomTabsServiceStore, @@ -62,13 +66,12 @@ class IntentProcessors( val externalAppIntentProcessors by lazyMonitored { listOf( TrustedWebActivityIntentProcessor( - sessionManager = sessionManager, - loadUrlUseCase = sessionUseCases.loadUrl, + addNewTabUseCase = tabsUseCases.addTab, packageManager = context.packageManager, relationChecker = relationChecker, store = customTabsStore ), - WebAppIntentProcessor(sessionManager, sessionUseCases.loadUrl, manifestStorage), + WebAppIntentProcessor(store, tabsUseCases.addTab, sessionUseCases.loadUrl, manifestStorage), FennecWebAppIntentProcessor(context, sessionManager, sessionUseCases.loadUrl, manifestStorage) ) } diff --git a/app/src/main/java/org/mozilla/fenix/components/Search.kt b/app/src/main/java/org/mozilla/fenix/components/Search.kt deleted file mode 100644 index 9b752b20a..000000000 --- a/app/src/main/java/org/mozilla/fenix/components/Search.kt +++ /dev/null @@ -1,37 +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.components - -import android.content.Context -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import mozilla.components.browser.search.SearchEngineManager -import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider -import org.mozilla.fenix.perf.lazyMonitored -import org.mozilla.fenix.utils.Mockable - -/** - * Component group for all search engine integration related functionality. - */ -@Mockable -class Search(private val context: Context) { - val provider = FenixSearchEngineProvider(context) - - /** - * This component provides access to a centralized registry of search engines. - */ - val searchEngineManager by lazyMonitored { - SearchEngineManager( - coroutineContext = IO, - providers = listOf(provider) - ).apply { - registerForLocaleUpdates(context) - GlobalScope.launch { - defaultSearchEngine = provider.getDefaultEngine(context) - } - } - } -} 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 e04bac803..39da157c8 100644 --- a/app/src/main/java/org/mozilla/fenix/components/TabCollectionStorage.kt +++ b/app/src/main/java/org/mozilla/fenix/components/TabCollectionStorage.kt @@ -40,7 +40,7 @@ class TabCollectionStorage( /** * A collection has been created */ - fun onCollectionCreated(title: String, sessions: List) = Unit + fun onCollectionCreated(title: String, sessions: List, id: Long?) = Unit /** * Tab(s) have been added to collection @@ -63,8 +63,8 @@ class TabCollectionStorage( } suspend fun createCollection(title: String, sessions: List) = ioScope.launch { - collectionStorage.createCollection(title, sessions) - notifyObservers { onCollectionCreated(title, sessions) } + val id = collectionStorage.createCollection(title, sessions) + notifyObservers { onCollectionCreated(title, sessions, id) } }.join() suspend fun addTabsToCollection(tabCollection: TabCollection, sessions: List) = ioScope.launch { diff --git a/app/src/main/java/org/mozilla/fenix/components/UseCases.kt b/app/src/main/java/org/mozilla/fenix/components/UseCases.kt index 86b9bfabc..40250e646 100644 --- a/app/src/main/java/org/mozilla/fenix/components/UseCases.kt +++ b/app/src/main/java/org/mozilla/fenix/components/UseCases.kt @@ -5,7 +5,6 @@ package org.mozilla.fenix.components import android.content.Context -import mozilla.components.browser.search.SearchEngineManager import mozilla.components.browser.session.SessionManager import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.Engine @@ -15,7 +14,7 @@ import mozilla.components.feature.downloads.DownloadsUseCases import mozilla.components.feature.pwa.WebAppShortcutManager import mozilla.components.feature.pwa.WebAppUseCases import mozilla.components.feature.search.SearchUseCases -import mozilla.components.browser.search.ext.toDefaultSearchEngineProvider +import mozilla.components.feature.search.ext.toDefaultSearchEngineProvider import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.session.SettingsUseCases import mozilla.components.feature.session.TrackingProtectionUseCases @@ -36,7 +35,6 @@ class UseCases( private val engine: Engine, private val sessionManager: SessionManager, private val store: BrowserStore, - private val searchEngineManager: SearchEngineManager, private val shortcutManager: WebAppShortcutManager, private val topSitesStorage: TopSitesStorage ) { @@ -56,8 +54,8 @@ class UseCases( val searchUseCases by lazyMonitored { SearchUseCases( store, - searchEngineManager.toDefaultSearchEngineProvider(context), - sessionManager + store.toDefaultSearchEngineProvider(), + tabsUseCases ) } @@ -69,7 +67,7 @@ class UseCases( val appLinksUseCases by lazyMonitored { AppLinksUseCases(context.applicationContext) } val webAppUseCases by lazyMonitored { - WebAppUseCases(context, sessionManager, shortcutManager) + WebAppUseCases(context, store, shortcutManager) } val downloadUseCases by lazyMonitored { DownloadsUseCases(store) } 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 d25cf3770..cc00144b0 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 @@ -6,7 +6,7 @@ package org.mozilla.fenix.components.metrics import android.content.Context import mozilla.components.browser.errorpages.ErrorType -import mozilla.components.browser.search.SearchEngine +import mozilla.components.browser.state.search.SearchEngine import mozilla.components.feature.top.sites.TopSite import org.mozilla.fenix.GleanMetrics.Addons import org.mozilla.fenix.GleanMetrics.AppTheme @@ -106,6 +106,8 @@ sealed class Event { object MediaPlayState : Event() object MediaPauseState : Event() object MediaStopState : Event() + object MediaFullscreenState : Event() + object MediaPictureInPictureState : Event() object InAppNotificationDownloadOpen : Event() object InAppNotificationDownloadTryAgain : Event() object NotificationDownloadCancel : Event() @@ -113,6 +115,10 @@ sealed class Event { object NotificationDownloadPause : Event() object NotificationDownloadResume : Event() object NotificationDownloadTryAgain : Event() + object DownloadAdded : Event() + object DownloadsScreenOpened : Event() + object DownloadsItemOpened : Event() + object DownloadsItemDeleted : Event() object NotificationMediaPlay : Event() object NotificationMediaPause : Event() object TopSiteOpenDefault : Event() @@ -183,6 +189,12 @@ sealed class Event { object TabSettingsOpened : Event() + object CopyUrlUsed : Event() + + object SyncedTabOpened : Event() + + object RecentlyClosedTabsOpened : Event() + // Interaction events with extras data class TopSiteSwipeCarousel(val page: Int) : Event() { @@ -404,7 +416,7 @@ sealed class Event { // https://github.com/mozilla-mobile/fenix/issues/1607 // Sanitize identifiers for custom search engines. val identifier: String - get() = if (isCustom) "custom" else engine.identifier + get() = if (isCustom) "custom" else engine.id val searchEngine: SearchEngine get() = when (this) { 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 d5d620fc6..19c5bd0c0 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 @@ -5,6 +5,9 @@ package org.mozilla.fenix.components.metrics import android.content.Context +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.search.ext.legacy +import mozilla.components.feature.search.ext.waitForSelectedOrDefaultSearchEngine import mozilla.components.service.fxa.manager.SyncEnginesStorage import mozilla.components.service.glean.Glean import mozilla.components.service.glean.private.NoExtraKeys @@ -22,6 +25,8 @@ import org.mozilla.fenix.GleanMetrics.ContextualHintTrackingProtection import org.mozilla.fenix.GleanMetrics.CrashReporter import org.mozilla.fenix.GleanMetrics.CustomTab import org.mozilla.fenix.GleanMetrics.DownloadNotification +import org.mozilla.fenix.GleanMetrics.DownloadsMisc +import org.mozilla.fenix.GleanMetrics.DownloadsManagement import org.mozilla.fenix.GleanMetrics.ErrorPage import org.mozilla.fenix.GleanMetrics.Events import org.mozilla.fenix.GleanMetrics.FindInPage @@ -399,6 +404,12 @@ private val Event.wrapper: EventWrapper<*>? is Event.MediaStopState -> EventWrapper( { MediaState.stop.record(it) } ) + is Event.MediaFullscreenState -> EventWrapper( + { MediaState.fullscreen.record(it) } + ) + is Event.MediaPictureInPictureState -> EventWrapper( + { MediaState.pictureInPicture.record(it) } + ) is Event.InAppNotificationDownloadOpen -> EventWrapper( { DownloadNotification.inAppOpen.record(it) } ) @@ -420,6 +431,18 @@ private val Event.wrapper: EventWrapper<*>? is Event.NotificationDownloadTryAgain -> EventWrapper( { DownloadNotification.tryAgain.record(it) } ) + is Event.DownloadAdded -> EventWrapper( + { DownloadsMisc.downloadAdded.record(it) } + ) + is Event.DownloadsScreenOpened -> EventWrapper( + { DownloadsManagement.downloadsScreenOpened.record(it) } + ) + is Event.DownloadsItemOpened -> EventWrapper( + { DownloadsManagement.itemOpened.record(it) } + ) + is Event.DownloadsItemDeleted -> EventWrapper( + { DownloadsManagement.itemDeleted.record(it) } + ) is Event.NotificationMediaPlay -> EventWrapper( { MediaNotification.play.record(it) } ) @@ -656,6 +679,17 @@ private val Event.wrapper: EventWrapper<*>? { ProgressiveWebApp.background.record(it) }, { ProgressiveWebApp.backgroundKeys.valueOf(it) } ) + is Event.CopyUrlUsed -> EventWrapper( + { Events.copyUrlTapped.record(it) } + ) + + is Event.SyncedTabOpened -> EventWrapper( + { Events.syncedTabOpened.record(it) } + ) + + is Event.RecentlyClosedTabsOpened -> EventWrapper( + { Events.recentlyClosedTabsOpened.record(it) } + ) Event.MasterPasswordMigrationDisplayed -> EventWrapper( { MasterPassword.displayed.record(it) } @@ -683,6 +717,7 @@ private val Event.wrapper: EventWrapper<*>? class GleanMetricsService( private val context: Context, + private val store: Lazy, private val browsersCache: BrowsersCache = BrowsersCache, private val mozillaProductDetector: MozillaProductDetector = MozillaProductDetector ) : MetricsService { @@ -754,6 +789,18 @@ class GleanMetricsService( topSitesCount.add(topSitesSize) } + val desktopBookmarksSize = context.settings().desktopBookmarksSize + hasDesktopBookmarks.set(desktopBookmarksSize > 0) + if (desktopBookmarksSize > 0) { + desktopBookmarksCount.add(desktopBookmarksSize) + } + + val mobileBookmarksSize = context.settings().mobileBookmarksSize + hasMobileBookmarks.set(mobileBookmarksSize > 0) + if (mobileBookmarksSize > 0) { + mobileBookmarksCount.add(mobileBookmarksSize) + } + toolbarPosition.set( when (context.settings().toolbarPosition) { ToolbarPosition.BOTTOM -> Event.ToolbarPositionChanged.Position.BOTTOM.name @@ -765,20 +812,18 @@ class GleanMetricsService( closeTabSetting.set(context.settings().getTabTimeoutPingString()) } - SearchDefaultEngine.apply { - val defaultEngine = context - .components - .search - .searchEngineManager - .defaultSearchEngine ?: return@apply + store.value.waitForSelectedOrDefaultSearchEngine { searchEngine -> + if (searchEngine != null) { + SearchDefaultEngine.apply { + code.set(searchEngine.id) + name.set(searchEngine.name) + submissionUrl.set(searchEngine.legacy().buildSearchUrl("")) + } + } - code.set(defaultEngine.identifier) - name.set(defaultEngine.name) - submissionUrl.set(defaultEngine.buildSearchUrl("")) + activationPing.checkAndSend() + installationPing.checkAndSend() } - - activationPing.checkAndSend() - installationPing.checkAndSend() } private fun setPreferenceMetrics() { diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsUtils.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsUtils.kt index 2c9bf3eff..18b53961a 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsUtils.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsUtils.kt @@ -12,11 +12,12 @@ import com.google.android.gms.common.GooglePlayServicesNotAvailableException import com.google.android.gms.common.GooglePlayServicesRepairableException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import mozilla.components.browser.search.SearchEngine +import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.support.base.log.logger.Logger import org.mozilla.fenix.components.metrics.Event.PerformedSearch.SearchAccessPoint -import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore -import org.mozilla.fenix.ext.searchEngineManager +import org.mozilla.fenix.ext.components import java.io.IOException import java.security.NoSuchAlgorithmException import java.security.spec.InvalidKeySpecException @@ -26,11 +27,11 @@ import javax.crypto.spec.PBEKeySpec object MetricsUtils { fun createSearchEvent( engine: SearchEngine, - context: Context, + store: BrowserStore, searchAccessPoint: SearchAccessPoint ): Event.PerformedSearch? { - val isShortcut = engine != context.searchEngineManager.defaultSearchEngine - val isCustom = CustomSearchEngineStore.isCustomSearchEngine(context, engine.identifier) + val isShortcut = engine != store.state.search.selectedOrDefaultSearchEngine + val isCustom = engine.type == SearchEngine.Type.CUSTOM val engineSource = if (isShortcut) Event.PerformedSearch.EngineSource.Shortcut(engine, isCustom) diff --git a/app/src/main/java/org/mozilla/fenix/components/search/SearchMigration.kt b/app/src/main/java/org/mozilla/fenix/components/search/SearchMigration.kt new file mode 100644 index 000000000..9d421a82d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/search/SearchMigration.kt @@ -0,0 +1,70 @@ +/* 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.components.search + +import android.content.Context +import android.content.SharedPreferences +import mozilla.components.browser.search.SearchEngineParser +import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.feature.search.ext.migrate +import mozilla.components.feature.search.middleware.SearchMiddleware +import org.mozilla.fenix.ext.components +import org.xmlpull.v1.XmlPullParserException +import java.io.BufferedInputStream +import java.io.IOException + +private const val PREF_FILE_SEARCH_ENGINES = "custom-search-engines" + +private const val PREF_KEY_CUSTOM_SEARCH_ENGINES = "pref_custom_search_engines" +private const val PREF_KEY_MIGRATED = "pref_search_migrated" + +/** + * Helper class to migrate the search related data in Fenix to the "Android Components" implementation. + */ +internal class SearchMigration( + private val context: Context +) : SearchMiddleware.Migration { + + override fun getValuesToMigrate(): SearchMiddleware.Migration.MigrationValues? { + val preferences = context.getSharedPreferences(PREF_FILE_SEARCH_ENGINES, Context.MODE_PRIVATE) + if (preferences.getBoolean(PREF_KEY_MIGRATED, false)) { + return null + } + + val values = SearchMiddleware.Migration.MigrationValues( + customSearchEngines = loadCustomSearchEngines(preferences), + defaultSearchEngineName = context.components.settings.defaultSearchEngineName + ) + + preferences.edit() + .putBoolean(PREF_KEY_MIGRATED, true) + .apply() + + return values + } + + private fun loadCustomSearchEngines( + preferences: SharedPreferences + ): List { + val ids = preferences.getStringSet(PREF_KEY_CUSTOM_SEARCH_ENGINES, emptySet()) ?: emptySet() + + val parser = SearchEngineParser() + + return ids.mapNotNull { id -> + val xml = preferences.getString(id, null) + parser.loadSafely(id, xml?.byteInputStream()?.buffered()) + } + } +} + +private fun SearchEngineParser.loadSafely(id: String, stream: BufferedInputStream?): SearchEngine? { + return try { + stream?.let { load(id, it).migrate() } + } catch (e: IOException) { + null + } catch (e: XmlPullParserException) { + null + } +} diff --git a/app/src/main/java/org/mozilla/fenix/components/searchengine/CustomSearchEngineStore.kt b/app/src/main/java/org/mozilla/fenix/components/searchengine/CustomSearchEngineStore.kt deleted file mode 100644 index ef0c840e5..000000000 --- a/app/src/main/java/org/mozilla/fenix/components/searchengine/CustomSearchEngineStore.kt +++ /dev/null @@ -1,129 +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.components.searchengine - -import android.content.Context -import android.content.SharedPreferences -import mozilla.components.browser.icons.IconRequest -import mozilla.components.browser.search.SearchEngine -import mozilla.components.browser.search.SearchEngineParser -import mozilla.components.browser.search.provider.SearchEngineList -import mozilla.components.browser.search.provider.SearchEngineProvider -import mozilla.components.support.ktx.android.content.PreferencesHolder -import mozilla.components.support.ktx.android.content.stringSetPreference -import org.mozilla.fenix.ext.components - -/** - * SearchEngineProvider implementation to load user entered custom search engines. - */ -class CustomSearchEngineProvider : SearchEngineProvider { - override suspend fun loadSearchEngines(context: Context): SearchEngineList { - return SearchEngineList(CustomSearchEngineStore.loadCustomSearchEngines(context), null) - } -} - -/** - * Object to handle storing custom search engines - */ -object CustomSearchEngineStore { - class EngineNameAlreadyExists : Exception() - - /** - * Add a search engine to the store. - * @param context [Context] used for various Android interactions. - * @param engineName The name of the search engine - * @param searchQuery The templated search string for the search engine - * @throws EngineNameAlreadyExists if you try to add a search engine that already exists - */ - suspend fun addSearchEngine(context: Context, engineName: String, searchQuery: String) { - val storage = engineStorage(context) - if (storage.customSearchEngineIds.contains(engineName)) { throw EngineNameAlreadyExists() } - - val icon = context.components.core.icons.loadIcon(IconRequest(searchQuery)).await() - val searchEngineXml = SearchEngineWriter.buildSearchEngineXML(engineName, searchQuery, icon.bitmap) - val engines = storage.customSearchEngineIds.toMutableSet() - engines.add(engineName) - storage.customSearchEngineIds = engines - storage[engineName] = searchEngineXml - } - - /** - * Updates an existing search engine. - * To prevent duplicate search engines we want to remove the old engine before adding the new one - * @param context [Context] used for various Android interactions. - * @param oldEngineName the name of the engine you want to replace - * @param newEngineName the name of the engine you want to save - * @param searchQuery The templated search string for the search engine - */ - suspend fun updateSearchEngine( - context: Context, - oldEngineName: String, - newEngineName: String, - searchQuery: String - ) { - removeSearchEngine(context, oldEngineName) - addSearchEngine(context, newEngineName, searchQuery) - } - - /** - * Removes a search engine from the store - * @param context [Context] used for various Android interactions. - * @param engineId the id of the engine you want to remove - */ - fun removeSearchEngine(context: Context, engineId: String) { - val storage = engineStorage(context) - val customEngines = storage.customSearchEngineIds - storage.customSearchEngineIds = customEngines.filterNot { it == engineId }.toSet() - storage[engineId] = null - } - - /** - * Checks the store to see if it contains a search engine - * @param context [Context] used for various Android interactions. - * @param engineId The name of the engine to check - */ - fun isCustomSearchEngine(context: Context, engineId: String): Boolean { - val storage = engineStorage(context) - return storage.customSearchEngineIds.contains(engineId) - } - - /** - * Creates a list of [SearchEngine] from the store - * @param context [Context] used for various Android interactions. - */ - fun loadCustomSearchEngines(context: Context): List { - val storage = engineStorage(context) - val parser = SearchEngineParser() - val engines = storage.customSearchEngineIds - - return engines.mapNotNull { - val engineXml = storage[it] ?: return@mapNotNull null - val engineInputStream = engineXml.byteInputStream().buffered() - parser.load(it, engineInputStream) - } - } - - /** - * Creates a helper object to help interact with [SharedPreferences] - * @param context [Context] used for various Android interactions. - */ - private fun engineStorage(context: Context) = object : PreferencesHolder { - override val preferences: SharedPreferences - get() = context.getSharedPreferences(PREF_FILE_SEARCH_ENGINES, Context.MODE_PRIVATE) - - var customSearchEngineIds by stringSetPreference(PREF_KEY_CUSTOM_SEARCH_ENGINES, emptySet()) - - operator fun get(engineId: String): String? { - return preferences.getString(engineId, null) - } - - operator fun set(engineId: String, value: String?) { - preferences.edit().putString(engineId, value).apply() - } - } - - private const val PREF_KEY_CUSTOM_SEARCH_ENGINES = "pref_custom_search_engines" - const val PREF_FILE_SEARCH_ENGINES = "custom-search-engines" -} diff --git a/app/src/main/java/org/mozilla/fenix/components/searchengine/SearchEngineWriter.kt b/app/src/main/java/org/mozilla/fenix/components/searchengine/SearchEngineWriter.kt deleted file mode 100644 index 7366942e8..000000000 --- a/app/src/main/java/org/mozilla/fenix/components/searchengine/SearchEngineWriter.kt +++ /dev/null @@ -1,84 +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.components.searchengine - -import android.graphics.Bitmap -import android.util.Base64 -import android.util.Log -import org.w3c.dom.Document -import java.io.ByteArrayOutputStream -import java.io.StringWriter -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.parsers.ParserConfigurationException -import javax.xml.transform.OutputKeys -import javax.xml.transform.TransformerConfigurationException -import javax.xml.transform.TransformerException -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult - -private const val BITMAP_COMPRESS_QUALITY = 100 -private fun Bitmap.toBase64(): String { - val stream = ByteArrayOutputStream() - compress(Bitmap.CompressFormat.PNG, BITMAP_COMPRESS_QUALITY, stream) - val encodedImage = Base64.encodeToString(stream.toByteArray(), Base64.DEFAULT) - return "data:image/png;base64,$encodedImage" -} - -class SearchEngineWriter { - companion object { - private const val LOG_TAG = "SearchEngineWriter" - - fun buildSearchEngineXML(engineName: String, searchQuery: String, iconBitmap: Bitmap): String? { - try { - val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() - val rootElement = document!!.createElement("OpenSearchDescription") - rootElement.setAttribute("xmlns", "http://a9.com/-/spec/opensearch/1.1/") - document.appendChild(rootElement) - - val shortNameElement = document.createElement("ShortName") - shortNameElement.textContent = engineName - rootElement.appendChild(shortNameElement) - - val imageElement = document.createElement("Image") - imageElement.setAttribute("width", "16") - imageElement.setAttribute("height", "16") - imageElement.textContent = iconBitmap.toBase64() - rootElement.appendChild(imageElement) - - val descriptionElement = document.createElement("Description") - descriptionElement.textContent = engineName - rootElement.appendChild(descriptionElement) - - val urlElement = document.createElement("Url") - urlElement.setAttribute("type", "text/html") - - val templateSearchString = searchQuery.replace("%s", "{searchTerms}") - urlElement.setAttribute("template", templateSearchString) - rootElement.appendChild(urlElement) - - return xmlToString(document) - } catch (e: ParserConfigurationException) { - Log.e(LOG_TAG, "Couldn't create new Document for building search engine XML", e) - return null - } - } - - private fun xmlToString(doc: Document): String? { - val writer = StringWriter() - try { - val tf = TransformerFactory.newInstance().newTransformer() - tf.setOutputProperty(OutputKeys.ENCODING, "UTF-8") - tf.transform(DOMSource(doc), StreamResult(writer)) - } catch (e: TransformerConfigurationException) { - return null - } catch (e: TransformerException) { - return null - } - - return writer.toString() - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserInteractor.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserInteractor.kt index ba8f71b35..03d7971c8 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserInteractor.kt @@ -4,6 +4,8 @@ package org.mozilla.fenix.components.toolbar +import mozilla.components.ui.tabcounter.TabCounterMenu + open class BrowserInteractor( private val browserToolbarController: BrowserToolbarController, private val menuController: BrowserToolbarMenuController 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 d62ab00c2..079796a25 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 @@ -7,8 +7,11 @@ package org.mozilla.fenix.components.toolbar import androidx.navigation.NavController import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.EngineView import mozilla.components.support.ktx.kotlin.isUrl +import mozilla.components.ui.tabcounter.TabCounterMenu import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.BrowserAnimator.Companion.getToolbarNavOptions @@ -37,6 +40,7 @@ interface BrowserToolbarController { } class DefaultBrowserToolbarController( + private val store: BrowserStore, private val activity: HomeActivity, private val navController: NavController, private val metrics: MetricController, @@ -65,15 +69,15 @@ class DefaultBrowserToolbarController( override fun handleToolbarPasteAndGo(text: String) { if (text.isUrl()) { - sessionManager.selectedSession?.searchTerms = "" + store.updateSearchTermsOfSelectedSession("") activity.components.useCases.sessionUseCases.loadUrl.invoke(text) return } - sessionManager.selectedSession?.searchTerms = text + store.updateSearchTermsOfSelectedSession(text) activity.components.useCases.searchUseCases.defaultSearch.invoke( text, - session = sessionManager.selectedSession + sessionId = sessionManager.selectedSession?.id ) } @@ -105,11 +109,12 @@ class DefaultBrowserToolbarController( override fun handleTabCounterItemInteraction(item: TabCounterMenu.Item) { when (item) { is TabCounterMenu.Item.CloseTab -> { + metrics.track( + Event.TabCounterMenuItemTapped(Event.TabCounterMenuItemTapped.Item.CLOSE_TAB) + ) sessionManager.selectedSession?.let { // When closing the last tab we must show the undo snackbar in the home fragment if (sessionManager.sessionsOfType(it.private).count() == 1) { - // The tab tray always returns to normal mode so do that here too - activity.browsingModeManager.mode = BrowsingMode.Normal homeViewModel.sessionToDelete = it.id navController.navigate( BrowserFragmentDirections.actionGlobalHome() @@ -123,8 +128,24 @@ class DefaultBrowserToolbarController( } } is TabCounterMenu.Item.NewTab -> { - activity.browsingModeManager.mode = item.mode - navController.navigate(BrowserFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) + metrics.track( + Event.TabCounterMenuItemTapped(Event.TabCounterMenuItemTapped.Item.NEW_TAB) + ) + activity.browsingModeManager.mode = BrowsingMode.Normal + navController.navigate( + BrowserFragmentDirections.actionGlobalHome(focusOnAddressBar = true) + ) + } + is TabCounterMenu.Item.NewPrivateTab -> { + metrics.track( + Event.TabCounterMenuItemTapped( + Event.TabCounterMenuItemTapped.Item.NEW_PRIVATE_TAB + ) + ) + activity.browsingModeManager.mode = BrowsingMode.Private + navController.navigate( + BrowserFragmentDirections.actionGlobalHome(focusOnAddressBar = true) + ) } } } @@ -139,3 +160,14 @@ class DefaultBrowserToolbarController( internal const val TELEMETRY_BROWSER_IDENTIFIER = "browserMenu" } } + +private fun BrowserStore.updateSearchTermsOfSelectedSession( + searchTerms: String +) { + val selectedTabId = state.selectedTabId ?: return + + dispatch(ContentAction.UpdateSearchTermsAction( + selectedTabId, + searchTerms + )) +} diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMenuController.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMenuController.kt index 90f91763f..fae8e867a 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMenuController.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMenuController.kt @@ -236,10 +236,16 @@ class DefaultBrowserToolbarMenuController( sessionManager.select(customTabSession) // Switch to the actual browser which should now display our new selected session - activity.startActivity(openInFenixIntent) + activity.startActivity(openInFenixIntent.apply { + // We never want to launch the browser in the same task as the external app + // activity. So we force a new task here. IntentReceiverActivity will do the + // right thing and take care of routing to an already existing browser and avoid + // cloning a new one. + flags = flags or Intent.FLAG_ACTIVITY_NEW_TASK + }) - // Close this activity since it is no longer displaying any session - activity.finish() + // Close this activity (and the task) since it is no longer displaying any session + activity.finishAndRemoveTask() } ToolbarMenu.Item.Quit -> { // We need to show the snackbar while the browsing data is deleting (if "Delete 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 a69a91563..a513c52c7 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 @@ -29,6 +29,7 @@ import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.browser.toolbar.behavior.BrowserToolbarBottomBehavior import mozilla.components.browser.toolbar.display.DisplayToolbar import mozilla.components.support.utils.URLStringUtils +import mozilla.components.ui.tabcounter.TabCounterMenu import org.mozilla.fenix.R import org.mozilla.fenix.customtabs.CustomTabToolbarIntegration import org.mozilla.fenix.customtabs.CustomTabToolbarMenu @@ -155,7 +156,8 @@ class BrowserToolbarView( menu = primaryTextColor, hint = secondaryTextColor, separator = separatorColor, - trackingProtection = primaryTextColor + trackingProtection = primaryTextColor, + permissionHighlights = primaryTextColor ) display.hint = context.getString(R.string.search_hint) @@ -164,9 +166,9 @@ class BrowserToolbarView( val menuToolbar: ToolbarMenu if (isCustomTabSession) { menuToolbar = CustomTabToolbarMenu( - this, - sessionManager, - customTabSession?.id, + context = this, + store = components.core.store, + sessionId = customTabSession?.id, shouldReverseItems = toolbarPosition == ToolbarPosition.TOP, onItemTapped = { it.performHapticIfNeeded(view) @@ -183,7 +185,6 @@ class BrowserToolbarView( interactor.onBrowserToolbarMenuItemTapped(it) }, lifecycleOwner = lifecycleOwner, - sessionManager = sessionManager, store = components.core.store, bookmarksStorage = bookmarkStorage, isPinningSupported = isPinningSupported @@ -272,10 +273,6 @@ class BrowserToolbarView( } } - companion object { - private const val TOOLBAR_ELEVATION = 16 - } - @Suppress("ComplexCondition") private fun ToolbarMenu.Item.performHapticIfNeeded(view: View) { if (this is ToolbarMenu.Item.Reload && this.bypassCache || 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 93a4517d0..0fc1a3544 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 @@ -6,10 +6,14 @@ package org.mozilla.fenix.components.toolbar import android.content.Context import androidx.annotation.ColorRes +import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat.getColor import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import mozilla.components.browser.menu.BrowserMenuHighlight import mozilla.components.browser.menu.WebExtensionBrowserMenuBuilder @@ -19,13 +23,15 @@ import mozilla.components.browser.menu.item.BrowserMenuImageSwitch import mozilla.components.browser.menu.item.BrowserMenuImageText import mozilla.components.browser.menu.item.BrowserMenuItemToolbar import mozilla.components.browser.menu.item.WebExtensionPlaceholderMenuItem -import mozilla.components.browser.session.Session -import mozilla.components.browser.session.SessionManager import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.storage.BookmarksStorage import mozilla.components.feature.webcompat.reporter.WebCompatReporterFeature +import mozilla.components.lib.state.ext.flowScoped import mozilla.components.support.ktx.android.content.getColorFromAttr +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode @@ -36,7 +42,7 @@ import org.mozilla.fenix.theme.ThemeManager /** * Builds the toolbar object used with the 3-dot menu in the browser fragment. - * @param sessionManager Reference to the session manager that contains all tabs. + * @param store reference to the application's [BrowserStore]. * @param hasAccountProblem If true, there was a problem signing into the Firefox account. * @param shouldReverseItems If true, reverse the menu items. * @param onItemTapped Called when a menu item is tapped. @@ -44,9 +50,9 @@ import org.mozilla.fenix.theme.ThemeManager * @param bookmarksStorage Used to check if a page is bookmarked. */ @Suppress("LargeClass", "LongParameterList") +@ExperimentalCoroutinesApi class DefaultToolbarMenu( private val context: Context, - private val sessionManager: SessionManager, private val store: BrowserStore, hasAccountProblem: Boolean = false, shouldReverseItems: Boolean, @@ -59,8 +65,7 @@ class DefaultToolbarMenu( private var currentUrlIsBookmarked = false private var isBookmarkedJob: Job? = null - /** Gets the current browser session */ - private val session: Session? get() = sessionManager.selectedSession + private val selectedSession: TabSessionState? get() = store.state.selectedTab override val menuBuilder by lazy { WebExtensionBrowserMenuBuilder( @@ -81,7 +86,7 @@ class DefaultToolbarMenu( primaryContentDescription = context.getString(R.string.browser_menu_back), primaryImageTintResource = primaryTextColor(), isInPrimaryState = { - session?.canGoBack ?: true + selectedSession?.content?.canGoBack ?: true }, secondaryImageTintResource = ThemeManager.resolveAttribute(R.attr.disabled, context), disableInSecondaryState = true, @@ -95,7 +100,7 @@ class DefaultToolbarMenu( primaryContentDescription = context.getString(R.string.browser_menu_forward), primaryImageTintResource = primaryTextColor(), isInPrimaryState = { - session?.canGoForward ?: true + selectedSession?.content?.canGoForward ?: true }, secondaryImageTintResource = ThemeManager.resolveAttribute(R.attr.disabled, context), disableInSecondaryState = true, @@ -109,7 +114,7 @@ class DefaultToolbarMenu( primaryContentDescription = context.getString(R.string.browser_menu_refresh), primaryImageTintResource = primaryTextColor(), isInPrimaryState = { - session?.loading == false + selectedSession?.content?.loading == false }, secondaryImageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_stop, secondaryContentDescription = context.getString(R.string.browser_menu_stop), @@ -117,7 +122,7 @@ class DefaultToolbarMenu( disableInSecondaryState = false, longClickListener = { onItemTapped.invoke(ToolbarMenu.Item.Reload(bypassCache = true)) } ) { - if (session?.loading == true) { + if (selectedSession?.content?.loading == true) { onItemTapped.invoke(ToolbarMenu.Item.Stop) } else { onItemTapped.invoke(ToolbarMenu.Item.Reload(bypassCache = false)) @@ -157,19 +162,19 @@ class DefaultToolbarMenu( // Predicates that need to be repeatedly called as the session changes private fun canAddToHomescreen(): Boolean = - session != null && isPinningSupported && + selectedSession != null && isPinningSupported && !context.components.useCases.webAppUseCases.isInstallable() private fun canInstall(): Boolean = - session != null && isPinningSupported && + selectedSession != null && isPinningSupported && context.components.useCases.webAppUseCases.isInstallable() - private fun shouldShowOpenInApp(): Boolean = session?.let { session -> + private fun shouldShowOpenInApp(): Boolean = selectedSession?.let { session -> val appLink = context.components.useCases.appLinksUseCases.appLinkRedirect - appLink(session.url).hasExternalApp() + appLink(session.content.url).hasExternalApp() } ?: false - private fun shouldShowReaderAppearance(): Boolean = session?.let { + private fun shouldShowReaderAppearance(): Boolean = selectedSession?.let { store.state.findTab(it.id)?.readerState?.active } ?: false // End of predicates // @@ -234,7 +239,7 @@ class DefaultToolbarMenu( imageResource = R.drawable.ic_desktop, label = context.getString(R.string.browser_menu_desktop_site), initialState = { - session?.desktopMode ?: false + selectedSession?.content?.desktopMode ?: false } ) { checked -> onItemTapped.invoke(ToolbarMenu.Item.RequestDesktop(checked)) @@ -353,44 +358,28 @@ class DefaultToolbarMenu( } @ColorRes - private fun primaryTextColor() = ThemeManager.resolveAttribute(R.attr.primaryText, context) - - private var currentSessionObserver: Pair? = null - - private fun registerForIsBookmarkedUpdates() { - session?.let { - registerForUrlChanges(it) - } - - val sessionManagerObserver = object : SessionManager.Observer { - override fun onSessionSelected(session: Session) { - // Unregister any old session observer before registering a new session observer - currentSessionObserver?.let { - it.first.unregister(it.second) + @VisibleForTesting + internal fun primaryTextColor() = ThemeManager.resolveAttribute(R.attr.primaryText, context) + + @VisibleForTesting + internal fun registerForIsBookmarkedUpdates() { + store.flowScoped(lifecycleOwner) { flow -> + flow.mapNotNull { state -> state.selectedTab } + .ifAnyChanged { tab -> + arrayOf( + tab.id, + tab.content.url + ) + } + .collect { + currentUrlIsBookmarked = false + updateCurrentUrlIsBookmarked(it.content.url) } - currentUrlIsBookmarked = false - updateCurrentUrlIsBookmarked(session.url) - registerForUrlChanges(session) - } - } - - sessionManager.register(sessionManagerObserver, lifecycleOwner) - } - - private fun registerForUrlChanges(session: Session) { - val sessionObserver = object : Session.Observer { - override fun onUrlChanged(session: Session, url: String) { - currentUrlIsBookmarked = false - updateCurrentUrlIsBookmarked(url) - } } - - currentSessionObserver = Pair(session, sessionObserver) - updateCurrentUrlIsBookmarked(session.url) - session.register(sessionObserver, lifecycleOwner) } - private fun updateCurrentUrlIsBookmarked(newUrl: String) { + @VisibleForTesting + internal fun updateCurrentUrlIsBookmarked(newUrl: String) { isBookmarkedJob?.cancel() isBookmarkedJob = lifecycleOwner.lifecycleScope.launch { currentUrlIsBookmarked = bookmarksStorage diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/FenixTabCounterMenu.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/FenixTabCounterMenu.kt new file mode 100644 index 000000000..224f19340 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/FenixTabCounterMenu.kt @@ -0,0 +1,60 @@ +/* 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.components.toolbar + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.menu.candidate.DividerMenuCandidate +import mozilla.components.concept.menu.candidate.MenuCandidate +import mozilla.components.ui.tabcounter.TabCounterMenu +import org.mozilla.fenix.browser.browsingmode.BrowsingMode + +class FenixTabCounterMenu( + context: Context, + onItemTapped: (Item) -> Unit, + iconColor: Int? = null +) : TabCounterMenu(context, onItemTapped, iconColor) { + + @VisibleForTesting + internal fun menuItems(showOnly: BrowsingMode): List { + return when (showOnly) { + BrowsingMode.Normal -> listOf(newTabItem) + BrowsingMode.Private -> listOf(newPrivateTabItem) + } + } + + @VisibleForTesting + internal fun menuItems(toolbarPosition: ToolbarPosition): List { + val items = listOf( + newTabItem, + newPrivateTabItem, + DividerMenuCandidate(), + closeTabItem + ) + + return when (toolbarPosition) { + ToolbarPosition.BOTTOM -> items.reversed() + ToolbarPosition.TOP -> items + } + } + + /** + * Update the displayed menu items. + * @param showOnly Show only the new tab item corresponding to the given [BrowsingMode]. + */ + fun updateMenu(showOnly: BrowsingMode) { + val items = menuItems(showOnly) + + menuController.submitList(items) + } + + /** + * Update the displayed menu items. + * @param toolbarPosition Return a list that is ordered based on the given [ToolbarPosition]. + */ + fun updateMenu(toolbarPosition: ToolbarPosition) { + menuController.submitList(menuItems(toolbarPosition)) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounter.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounter.kt deleted file mode 100644 index 1585fb7c3..000000000 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounter.kt +++ /dev/null @@ -1,271 +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.components.toolbar - -import android.animation.AnimatorSet -import android.animation.ObjectAnimator -import android.content.Context -import android.graphics.Typeface -import android.util.AttributeSet -import android.util.TypedValue -import android.view.LayoutInflater -import android.widget.RelativeLayout -import androidx.core.view.updatePadding -import kotlinx.android.synthetic.main.mozac_ui_tabcounter_layout.view.* -import org.mozilla.fenix.R -import java.text.NumberFormat - -class TabCounter @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : RelativeLayout(context, attrs, defStyle) { - - private val animationSet: AnimatorSet - - init { - val inflater = LayoutInflater.from(context) - inflater.inflate(R.layout.mozac_ui_tabcounter_layout, this) - - // This is needed because without this counter box will be empty. - setCount(INTERNAL_COUNT) - - animationSet = createAnimatorSet() - } - - private fun updateContentDescription(count: Int) { - counter_root.contentDescription = if (count == 1) { - context?.getString(R.string.open_tab_tray_single) - } else { - String.format(context.getString(R.string.open_tab_tray_plural), count.toString()) - } - } - - fun setCountWithAnimation(count: Int) { - setCount(count) - - // No need to animate on these cases. - when { - INTERNAL_COUNT == 0 -> return // Initial state. - INTERNAL_COUNT == count -> return // There isn't any tab added or removed. - INTERNAL_COUNT > MAX_VISIBLE_TABS -> return // There are still over MAX_VISIBLE_TABS tabs open. - } - - // Cancel previous animations if necessary. - if (animationSet.isRunning) { - animationSet.cancel() - } - // Trigger animations. - animationSet.start() - } - - fun setCount(count: Int) { - updateContentDescription(count) - adjustTextSize(count) - counter_text.text = formatForDisplay(count) - INTERNAL_COUNT = count - } - - private fun createAnimatorSet(): AnimatorSet { - val animatorSet = AnimatorSet() - createBoxAnimatorSet(animatorSet) - createTextAnimatorSet(animatorSet) - return animatorSet - } - - private fun createBoxAnimatorSet(animatorSet: AnimatorSet) { - // The first animator, fadeout in 33 ms (49~51, 2 frames). - val fadeOut = ObjectAnimator.ofFloat( - counter_box, "alpha", - ANIM_BOX_FADEOUT_FROM, ANIM_BOX_FADEOUT_TO - ).setDuration(ANIM_BOX_FADEOUT_DURATION) - - // Move up on y-axis, from 0.0 to -5.3 in 50ms, with fadeOut (49~52, 3 frames). - val moveUp1 = ObjectAnimator.ofFloat( - counter_box, "translationY", - ANIM_BOX_MOVEUP1_TO, ANIM_BOX_MOVEUP1_FROM - ).setDuration(ANIM_BOX_MOVEUP1_DURATION) - - // Move down on y-axis, from -5.3 to -1.0 in 116ms, after moveUp1 (52~59, 7 frames). - val moveDown2 = ObjectAnimator.ofFloat( - counter_box, "translationY", - ANIM_BOX_MOVEDOWN2_FROM, ANIM_BOX_MOVEDOWN2_TO - ).setDuration(ANIM_BOX_MOVEDOWN2_DURATION) - - // FadeIn in 66ms, with moveDown2 (52~56, 4 frames). - val fadeIn = ObjectAnimator.ofFloat( - counter_box, "alpha", - ANIM_BOX_FADEIN_FROM, ANIM_BOX_FADEIN_TO - ).setDuration(ANIM_BOX_FADEIN_DURATION) - - // Move down on y-axis, from -1.0 to 2.7 in 116ms, after moveDown2 (59~66, 7 frames). - val moveDown3 = ObjectAnimator.ofFloat( - counter_box, "translationY", - ANIM_BOX_MOVEDOWN3_FROM, ANIM_BOX_MOVEDOWN3_TO - ).setDuration(ANIM_BOX_MOVEDOWN3_DURATION) - - // Move up on y-axis, from 2.7 to 0 in 133ms, after moveDown3 (66~74, 8 frames). - val moveUp4 = ObjectAnimator.ofFloat( - counter_box, "translationY", - ANIM_BOX_MOVEDOWN4_FROM, ANIM_BOX_MOVEDOWN4_TO - ).setDuration(ANIM_BOX_MOVEDOWN4_DURATION) - - // Scale up height from 2% to 105% in 100ms, after moveUp1 and delay 16ms (53~59, 6 frames). - val scaleUp1 = ObjectAnimator.ofFloat( - counter_box, "scaleY", - ANIM_BOX_SCALEUP1_FROM, ANIM_BOX_SCALEUP1_TO - ).setDuration(ANIM_BOX_SCALEUP1_DURATION) - scaleUp1.startDelay = ANIM_BOX_SCALEUP1_DELAY // delay 1 frame after moveUp1 - - // Scale down height from 105% to 99% in 116ms, after scaleUp1 (59~66, 7 frames). - val scaleDown2 = ObjectAnimator.ofFloat( - counter_box, "scaleY", - ANIM_BOX_SCALEDOWN2_FROM, ANIM_BOX_SCALEDOWN2_TO - ).setDuration(ANIM_BOX_SCALEDOWN2_DURATION) - - // Scale up height from 99% to 100% in 133ms, after scaleDown2 (66~74, 8 frames). - val scaleUp3 = ObjectAnimator.ofFloat( - counter_box, "scaleY", - ANIM_BOX_SCALEUP3_FROM, ANIM_BOX_SCALEUP3_TO - ).setDuration(ANIM_BOX_SCALEUP3_DURATION) - - animatorSet.play(fadeOut).with(moveUp1) - animatorSet.play(moveUp1).before(moveDown2) - animatorSet.play(moveDown2).with(fadeIn) - animatorSet.play(moveDown2).before(moveDown3) - animatorSet.play(moveDown3).before(moveUp4) - - animatorSet.play(moveUp1).before(scaleUp1) - animatorSet.play(scaleUp1).before(scaleDown2) - animatorSet.play(scaleDown2).before(scaleUp3) - } - - private fun createTextAnimatorSet(animatorSet: AnimatorSet) { - val firstAnimator = animatorSet.childAnimations[0] - - // Fadeout in 100ms, with firstAnimator (49~51, 2 frames). - val fadeOut = ObjectAnimator.ofFloat( - counter_text, "alpha", - ANIM_TEXT_FADEOUT_FROM, ANIM_TEXT_FADEOUT_TO - ).setDuration(ANIM_TEXT_FADEOUT_DURATION) - - // FadeIn in 66 ms, after fadeOut with delay 96ms (57~61, 4 frames). - val fadeIn = ObjectAnimator.ofFloat( - counter_text, "alpha", - ANIM_TEXT_FADEIN_FROM, ANIM_TEXT_FADEIN_TO - ).setDuration(ANIM_TEXT_FADEIN_DURATION) - fadeIn.startDelay = (ANIM_TEXT_FADEIN_DELAY) // delay 6 frames after fadeOut - - // Move down on y-axis, from 0 to 4.4 in 66ms, with fadeIn (57~61, 4 frames). - val moveDown = ObjectAnimator.ofFloat( - counter_text, "translationY", - ANIM_TEXT_MOVEDOWN_FROM, ANIM_TEXT_MOVEDOWN_TO - ).setDuration(ANIM_TEXT_MOVEDOWN_DURATION) - moveDown.startDelay = (ANIM_TEXT_MOVEDOWN_DELAY) // delay 6 frames after fadeOut - - // Move up on y-axis, from 0 to 4.4 in 66ms, after moveDown (61~69, 8 frames). - val moveUp = ObjectAnimator.ofFloat( - counter_text, "translationY", - ANIM_TEXT_MOVEUP_FROM, ANIM_TEXT_MOVEUP_TO - ).setDuration(ANIM_TEXT_MOVEUP_DURATION) - - animatorSet.play(firstAnimator).with(fadeOut) - animatorSet.play(fadeOut).before(fadeIn) - animatorSet.play(fadeIn).with(moveDown) - animatorSet.play(moveDown).before(moveUp) - } - - private fun formatForDisplay(count: Int): String { - return if (count > MAX_VISIBLE_TABS) { - counter_text.updatePadding(bottom = INFINITE_CHAR_PADDING_BOTTOM) - SO_MANY_TABS_OPEN - } else NumberFormat.getInstance().format(count.toLong()) - } - - private fun adjustTextSize(newCount: Int) { - val newRatio = if (newCount in TWO_DIGITS_TAB_COUNT_THRESHOLD..MAX_VISIBLE_TABS) { - TWO_DIGITS_SIZE_RATIO - } else { - ONE_DIGIT_SIZE_RATIO - } - - val counterBoxWidth = - context.resources.getDimensionPixelSize(R.dimen.tab_counter_box_width_height) - val textSize = newRatio * counterBoxWidth - counter_text.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) - counter_text.setTypeface(null, Typeface.BOLD) - counter_text.setPadding(0, 0, 0, 0) - } - - companion object { - internal var INTERNAL_COUNT = 0 - - internal const val MAX_VISIBLE_TABS = 99 - - internal const val SO_MANY_TABS_OPEN = "∞" - - internal const val INFINITE_CHAR_PADDING_BOTTOM = 6 - - internal const val ONE_DIGIT_SIZE_RATIO = 0.5f - internal const val TWO_DIGITS_SIZE_RATIO = 0.4f - internal const val TWO_DIGITS_TAB_COUNT_THRESHOLD = 10 - - // createBoxAnimatorSet - private const val ANIM_BOX_FADEOUT_FROM = 1.0f - private const val ANIM_BOX_FADEOUT_TO = 0.0f - private const val ANIM_BOX_FADEOUT_DURATION = 33L - - private const val ANIM_BOX_MOVEUP1_FROM = 0.0f - private const val ANIM_BOX_MOVEUP1_TO = -5.3f - private const val ANIM_BOX_MOVEUP1_DURATION = 50L - - private const val ANIM_BOX_MOVEDOWN2_FROM = -5.3f - private const val ANIM_BOX_MOVEDOWN2_TO = -1.0f - private const val ANIM_BOX_MOVEDOWN2_DURATION = 167L - - private const val ANIM_BOX_FADEIN_FROM = 0.01f - private const val ANIM_BOX_FADEIN_TO = 1.0f - private const val ANIM_BOX_FADEIN_DURATION = 66L - private const val ANIM_BOX_MOVEDOWN3_FROM = -1.0f - private const val ANIM_BOX_MOVEDOWN3_TO = 2.7f - private const val ANIM_BOX_MOVEDOWN3_DURATION = 116L - - private const val ANIM_BOX_MOVEDOWN4_FROM = 2.7f - private const val ANIM_BOX_MOVEDOWN4_TO = 0.0f - private const val ANIM_BOX_MOVEDOWN4_DURATION = 133L - - private const val ANIM_BOX_SCALEUP1_FROM = 0.02f - private const val ANIM_BOX_SCALEUP1_TO = 1.05f - private const val ANIM_BOX_SCALEUP1_DURATION = 100L - private const val ANIM_BOX_SCALEUP1_DELAY = 16L - - private const val ANIM_BOX_SCALEDOWN2_FROM = 1.05f - private const val ANIM_BOX_SCALEDOWN2_TO = 0.99f - private const val ANIM_BOX_SCALEDOWN2_DURATION = 116L - - private const val ANIM_BOX_SCALEUP3_FROM = 0.99f - private const val ANIM_BOX_SCALEUP3_TO = 1.00f - private const val ANIM_BOX_SCALEUP3_DURATION = 133L - - // createTextAnimatorSet - private const val ANIM_TEXT_FADEOUT_FROM = 1.0f - private const val ANIM_TEXT_FADEOUT_TO = 0.0f - private const val ANIM_TEXT_FADEOUT_DURATION = 33L - - private const val ANIM_TEXT_FADEIN_FROM = 0.01f - private const val ANIM_TEXT_FADEIN_TO = 1.0f - private const val ANIM_TEXT_FADEIN_DURATION = 66L - private const val ANIM_TEXT_FADEIN_DELAY = 16L * 6 - - private const val ANIM_TEXT_MOVEDOWN_FROM = 0.0f - private const val ANIM_TEXT_MOVEDOWN_TO = 4.4f - private const val ANIM_TEXT_MOVEDOWN_DURATION = 66L - private const val ANIM_TEXT_MOVEDOWN_DELAY = 16L * 6 - - private const val ANIM_TEXT_MOVEUP_FROM = 4.4f - private const val ANIM_TEXT_MOVEUP_TO = 0.0f - private const val ANIM_TEXT_MOVEUP_DURATION = 66L - } -} diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounterMenu.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounterMenu.kt deleted file mode 100644 index 6d6bebe5a..000000000 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounterMenu.kt +++ /dev/null @@ -1,121 +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.components.toolbar - -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.DividerMenuCandidate -import mozilla.components.concept.menu.candidate.DrawableMenuIcon -import mozilla.components.concept.menu.candidate.MenuCandidate -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 -import org.mozilla.fenix.browser.browsingmode.BrowsingMode -import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.components.metrics.MetricController - -class TabCounterMenu( - context: Context, - private val metrics: MetricController, - private val onItemTapped: (Item) -> Unit -) { - - sealed class Item { - object CloseTab : Item() - data class NewTab(val mode: BrowsingMode) : Item() - } - - val menuController: MenuController by lazy { BrowserMenuController() } - - private val newTabItem: TextMenuCandidate - private val newPrivateTabItem: TextMenuCandidate - private val closeTabItem: TextMenuCandidate - - init { - val primaryTextColor = context.getColorFromAttr(R.attr.primaryText) - val textStyle = TextStyle(color = primaryTextColor) - - newTabItem = TextMenuCandidate( - text = context.getString(R.string.browser_menu_new_tab), - start = DrawableMenuIcon( - context, - R.drawable.ic_new, - tint = primaryTextColor - ), - textStyle = textStyle - ) { - metrics.track(Event.TabCounterMenuItemTapped(Event.TabCounterMenuItemTapped.Item.NEW_TAB)) - onItemTapped(Item.NewTab(BrowsingMode.Normal)) - } - - newPrivateTabItem = TextMenuCandidate( - text = context.getString(R.string.home_screen_shortcut_open_new_private_tab_2), - start = DrawableMenuIcon( - context, - R.drawable.ic_private_browsing, - tint = primaryTextColor - ), - textStyle = textStyle - ) { - metrics.track(Event.TabCounterMenuItemTapped(Event.TabCounterMenuItemTapped.Item.NEW_PRIVATE_TAB)) - onItemTapped(Item.NewTab(BrowsingMode.Private)) - } - - closeTabItem = TextMenuCandidate( - text = context.getString(R.string.close_tab), - start = DrawableMenuIcon( - context, - R.drawable.ic_close, - tint = primaryTextColor - ), - textStyle = textStyle - ) { - metrics.track(Event.TabCounterMenuItemTapped(Event.TabCounterMenuItemTapped.Item.CLOSE_TAB)) - onItemTapped(Item.CloseTab) - } - } - - @VisibleForTesting - internal fun menuItems(showOnly: BrowsingMode): List { - return when (showOnly) { - BrowsingMode.Normal -> listOf(newTabItem) - BrowsingMode.Private -> listOf(newPrivateTabItem) - } - } - - @VisibleForTesting - internal fun menuItems(toolbarPosition: ToolbarPosition): List { - val items = listOf( - newTabItem, - newPrivateTabItem, - DividerMenuCandidate(), - closeTabItem - ) - - return when (toolbarPosition) { - ToolbarPosition.BOTTOM -> items.reversed() - ToolbarPosition.TOP -> items - } - } - - /** - * Update the displayed menu items. - * @param showOnly Show only the new tab item corresponding to the given [BrowsingMode]. - */ - fun updateMenu(showOnly: BrowsingMode) { - menuController.submitList(menuItems(showOnly)) - } - - /** - * Update the displayed menu items. - * @param toolbarPosition Return a list that is ordered based on the given [ToolbarPosition]. - */ - fun updateMenu(toolbarPosition: ToolbarPosition) { - menuController.submitList(menuItems(toolbarPosition)) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounterToolbarButton.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounterToolbarButton.kt deleted file mode 100644 index 6123eab56..000000000 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/TabCounterToolbarButton.kt +++ /dev/null @@ -1,85 +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.components.toolbar - -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.LifecycleOwner -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.map -import mozilla.components.browser.state.selector.getNormalOrPrivateTabs -import mozilla.components.browser.state.selector.selectedTab -import mozilla.components.browser.state.store.BrowserStore -import mozilla.components.concept.toolbar.Toolbar -import mozilla.components.lib.state.ext.flowScoped -import mozilla.components.support.ktx.android.content.res.resolveAttribute -import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged -import org.mozilla.fenix.ext.components -import java.lang.ref.WeakReference - -/** - * A [Toolbar.Action] implementation that shows a [TabCounter]. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class TabCounterToolbarButton( - private val lifecycleOwner: LifecycleOwner, - private val onItemTapped: (TabCounterMenu.Item) -> Unit = {}, - private val showTabs: () -> Unit -) : Toolbar.Action { - - private var reference: WeakReference = WeakReference(null) - - override fun createView(parent: ViewGroup): View { - val store = parent.context.components.core.store - val metrics = parent.context.components.analytics.metrics - val settings = parent.context.components.settings - - store.flowScoped(lifecycleOwner) { flow -> - flow.map { state -> state.getNormalOrPrivateTabs(isPrivate(store)).size } - .ifChanged() - .collect { tabs -> updateCount(tabs) } - } - - val menu = TabCounterMenu(parent.context, metrics, onItemTapped) - menu.updateMenu(settings.toolbarPosition) - - val view = TabCounter(parent.context).apply { - reference = WeakReference(this) - setOnClickListener { - showTabs.invoke() - } - - setOnLongClickListener { - menu.menuController.show(anchor = it) - true - } - - addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View?) { - setCount(store.state.getNormalOrPrivateTabs(isPrivate(store)).size) - } - - override fun onViewDetachedFromWindow(v: View?) { /* no-op */ } - }) - } - - // Set selectableItemBackgroundBorderless - view.setBackgroundResource(parent.context.theme.resolveAttribute( - android.R.attr.selectableItemBackgroundBorderless - )) - return view - } - - override fun bind(view: View) = Unit - - private fun updateCount(count: Int) { - reference.get()?.setCountWithAnimation(count) - } - - private fun isPrivate(store: BrowserStore): Boolean { - return store.state.selectedTab?.content?.private ?: false - } -} 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 66131222c..b07f53636 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 @@ -7,19 +7,24 @@ 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 import mozilla.components.browser.domains.autocomplete.DomainAutocompleteProvider +import mozilla.components.browser.state.selector.normalTabs +import mozilla.components.browser.state.selector.privateTabs import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.browser.toolbar.display.DisplayToolbar import mozilla.components.concept.engine.Engine import mozilla.components.concept.storage.HistoryStorage +import mozilla.components.feature.tabs.toolbar.TabCounterToolbarButton import mozilla.components.feature.toolbar.ToolbarAutocompleteFeature import mozilla.components.feature.toolbar.ToolbarFeature import mozilla.components.feature.toolbar.ToolbarPresenter import mozilla.components.lib.publicsuffixlist.PublicSuffixList import mozilla.components.support.base.feature.LifecycleAwareFeature import mozilla.components.support.ktx.android.view.hideKeyboard +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings @@ -35,9 +40,10 @@ abstract class ToolbarIntegration( renderStyle: ToolbarFeature.RenderStyle ) : LifecycleAwareFeature { + val store = context.components.core.store private val toolbarPresenter: ToolbarPresenter = ToolbarPresenter( toolbar, - context.components.core.store, + store, sessionId, ToolbarFeature.UrlRenderConfiguration( PublicSuffixList(context), @@ -106,7 +112,7 @@ class DefaultToolbarIntegration( Configuration.UI_MODE_NIGHT_YES -> { AppCompatResources.getDrawable(context, R.drawable.shield_dark) } - else -> null + else -> AppCompatResources.getDrawable(context, R.drawable.shield_light) } toolbar.display.indicators = @@ -123,6 +129,10 @@ class DefaultToolbarIntegration( ) } + if (FeatureFlags.permissionIndicatorsToolbar) { + toolbar.display.indicators += DisplayToolbar.Indicators.PERMISSION_HIGHLIGHTS + } + toolbar.display.displayIndicatorSeparator = context.settings().shouldUseTrackingProtection @@ -139,16 +149,40 @@ class DefaultToolbarIntegration( )!! ) - val tabsAction = TabCounterToolbarButton( - lifecycleOwner, + val tabCounterMenu = FenixTabCounterMenu( + context = context, onItemTapped = { interactor.onTabCounterMenuItemTapped(it) }, + iconColor = + if (isPrivate) { + ContextCompat.getColor(context, R.color.primary_text_private_theme) + } else { + null + } + ).also { + it.updateMenu(context.settings().toolbarPosition) + } + + val tabsAction = TabCounterToolbarButton( + lifecycleOwner = lifecycleOwner, showTabs = { toolbar.hideKeyboard() interactor.onTabCounterClicked() - } + }, + store = store, + menu = tabCounterMenu, + privateColor = ContextCompat.getColor(context, R.color.primary_text_private_theme) ) + + val tabCount = if (isPrivate) { + store.state.privateTabs.size + } else { + store.state.normalTabs.size + } + + tabsAction.updateCount(tabCount) + toolbar.addBrowserAction(tabsAction) val engineForSpeculativeConnects = if (!isPrivate) engine else null diff --git a/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabToolbarMenu.kt b/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabToolbarMenu.kt index dd9a3baea..8fe71a71e 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabToolbarMenu.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabToolbarMenu.kt @@ -17,8 +17,9 @@ import mozilla.components.browser.menu.item.BrowserMenuImageSwitch import mozilla.components.browser.menu.item.BrowserMenuImageText import mozilla.components.browser.menu.item.BrowserMenuItemToolbar import mozilla.components.browser.menu.item.SimpleBrowserMenuItem -import mozilla.components.browser.session.Session -import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore import org.mozilla.fenix.R import org.mozilla.fenix.components.toolbar.ToolbarMenu import org.mozilla.fenix.ext.components @@ -29,14 +30,14 @@ import java.util.Locale /** * Builds the toolbar object used with the 3-dot menu in the custom tab browser fragment. - * @param sessionManager Reference to the session manager that contains all tabs. + * @param store reference to the application's [BrowserStore]. * @param sessionId ID of the open custom tab session. * @param shouldReverseItems If true, reverse the menu items. * @param onItemTapped Called when a menu item is tapped. */ class CustomTabToolbarMenu( private val context: Context, - private val sessionManager: SessionManager, + private val store: BrowserStore, private val sessionId: String?, private val shouldReverseItems: Boolean, private val onItemTapped: (ToolbarMenu.Item) -> Unit = {} @@ -45,7 +46,7 @@ class CustomTabToolbarMenu( override val menuBuilder by lazy { BrowserMenuBuilder(menuItems) } /** Gets the current custom tab session */ - private val session: Session? get() = sessionId?.let { sessionManager.findSessionById(it) } + private val session: TabSessionState? get() = sessionId?.let { store.state.findTab(it) } private val appName = context.getString(R.string.app_name) override val menuToolbar by lazy { @@ -54,7 +55,7 @@ class CustomTabToolbarMenu( primaryContentDescription = context.getString(R.string.browser_menu_back), primaryImageTintResource = primaryTextColor(), isInPrimaryState = { - session?.canGoBack ?: true + session?.content?.canGoBack ?: true }, secondaryImageTintResource = ThemeManager.resolveAttribute( R.attr.disabled, @@ -71,7 +72,7 @@ class CustomTabToolbarMenu( primaryContentDescription = context.getString(R.string.browser_menu_forward), primaryImageTintResource = primaryTextColor(), isInPrimaryState = { - session?.canGoForward ?: true + session?.content?.canGoForward ?: true }, secondaryImageTintResource = ThemeManager.resolveAttribute( R.attr.disabled, @@ -88,7 +89,7 @@ class CustomTabToolbarMenu( primaryContentDescription = context.getString(R.string.browser_menu_refresh), primaryImageTintResource = primaryTextColor(), isInPrimaryState = { - session?.loading == false + session?.content?.loading == false }, secondaryImageResource = mozilla.components.ui.icons.R.drawable.mozac_ic_stop, secondaryContentDescription = context.getString(R.string.browser_menu_stop), @@ -96,7 +97,7 @@ class CustomTabToolbarMenu( disableInSecondaryState = false, longClickListener = { onItemTapped.invoke(ToolbarMenu.Item.Reload(bypassCache = true)) } ) { - if (session?.loading == true) { + if (session?.content?.loading == true) { onItemTapped.invoke(ToolbarMenu.Item.Stop) } else { onItemTapped.invoke(ToolbarMenu.Item.Reload(bypassCache = false)) @@ -108,7 +109,7 @@ class CustomTabToolbarMenu( private fun shouldShowOpenInApp(): Boolean = session?.let { session -> val appLink = context.components.useCases.appLinksUseCases.appLinkRedirect - appLink(session.url).hasExternalApp() + appLink(session.content.url).hasExternalApp() } ?: false private val menuItems by lazy { @@ -132,7 +133,7 @@ class CustomTabToolbarMenu( private val desktopMode = BrowserMenuImageSwitch( imageResource = R.drawable.ic_desktop, label = context.getString(R.string.browser_menu_desktop_site), - initialState = { session?.desktopMode ?: false } + initialState = { session?.content?.desktopMode ?: false } ) { checked -> onItemTapped.invoke(ToolbarMenu.Item.RequestDesktop(checked)) } 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 d34b038dd..475274472 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabsIntegration.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/CustomTabsIntegration.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix.customtabs import android.app.Activity import androidx.appcompat.content.res.AppCompatResources.getDrawable import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.toolbar.BrowserToolbar import mozilla.components.browser.toolbar.display.DisplayToolbar import mozilla.components.feature.customtabs.CustomTabsToolbarFeature @@ -18,6 +19,7 @@ import org.mozilla.fenix.ext.settings class CustomTabsIntegration( sessionManager: SessionManager, + store: BrowserStore, toolbar: BrowserToolbar, sessionId: String, activity: Activity, @@ -74,7 +76,7 @@ class CustomTabsIntegration( private val customTabToolbarMenu by lazy { CustomTabToolbarMenu( activity, - sessionManager, + store, sessionId, shouldReverseItems, onItemTapped = onItemTapped 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 5168c31e4..864a3692c 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt @@ -5,10 +5,13 @@ package org.mozilla.fenix.customtabs 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.session.runWithSession +import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.browser.state.state.SessionState import mozilla.components.concept.engine.manifest.WebAppManifestParser import mozilla.components.feature.intent.ext.getSessionId import mozilla.components.feature.pwa.ext.getWebAppManifest @@ -24,7 +27,20 @@ import java.security.InvalidParameterException * Activity that holds the [ExternalAppBrowserFragment] that is launched within an external app, * such as custom tabs and progressive web apps. */ +@Suppress("TooManyFunctions") open class ExternalAppBrowserActivity : HomeActivity() { + override fun onResume() { + super.onResume() + + if (!hasExternalTab()) { + // An ExternalAppBrowserActivity is always bound to a specific tab. If this tab doesn't + // exist anymore on resume then this activity has nothing to display anymore. Let's just + // finish it AND remove this task to avoid it hanging around in the recent apps screen. + // Without this the parent HomeActivity class may decide to show the browser UI and we + // end up with multiple browsers (causing "display already acquired" crashes). + finishAndRemoveTask() + } + } final override fun getBreadcrumbMessage(destination: NavDestination): String { val fragmentName = resources.getResourceEntryName(destination.id) @@ -86,8 +102,7 @@ open class ExternalAppBrowserActivity : HomeActivity() { // When this activity finishes, the process is staying around and the session still // exists then remove it now to free all its resources. Once this activity is finished // then there's no way to get back to it other than relaunching it. - val sessionId = getIntentSessionId(SafeIntent(intent)) - components.core.sessionManager.runWithSession(sessionId) { session -> + components.core.sessionManager.runWithSession(getExternalTabId()) { session -> // If the custom tag config has been removed we are opening this in normal browsing if (session.customTabConfig != null) { remove(session) @@ -96,4 +111,20 @@ open class ExternalAppBrowserActivity : HomeActivity() { } } } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun hasExternalTab(): Boolean { + return getExternalTab() != null + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getExternalTab(): SessionState? { + val id = getExternalTabId() ?: return null + return components.core.store.state.findCustomTab(id) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getExternalTabId(): String? { + return getIntentSessionId(SafeIntent(intent)) + } } 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 f5accb0e4..dc983ff12 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserFragment.kt @@ -65,6 +65,7 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler customTabsIntegration.set( feature = CustomTabsIntegration( sessionManager = requireComponents.core.sessionManager, + store = requireComponents.core.store, toolbar = toolbar, sessionId = customTabSessionId, activity = activity, @@ -125,7 +126,7 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler ), ManifestUpdateFeature( activity.applicationContext, - requireComponents.core.sessionManager, + requireComponents.core.store, requireComponents.core.webAppShortcutManager, requireComponents.core.webAppManifestStorage, customTabSessionId, @@ -135,7 +136,7 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler viewLifecycleOwner.lifecycle.addObserver( WebAppSiteControlsFeature( activity.applicationContext, - requireComponents.core.sessionManager, + requireComponents.core.store, requireComponents.useCases.sessionUseCases.reload, customTabSessionId, manifest, diff --git a/app/src/main/java/org/mozilla/fenix/customtabs/FennecWebAppIntentProcessor.kt b/app/src/main/java/org/mozilla/fenix/customtabs/FennecWebAppIntentProcessor.kt index 81afbc614..ee5c9f94f 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/FennecWebAppIntentProcessor.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/FennecWebAppIntentProcessor.kt @@ -69,7 +69,7 @@ class FennecWebAppIntentProcessor( webAppManifest?.toCustomTabConfig() ?: createFallbackCustomTabConfig() sessionManager.add(session) - loadUrlUseCase(url, session, EngineSession.LoadUrlFlags.external()) + loadUrlUseCase(url, session.id, EngineSession.LoadUrlFlags.external()) intent.putSessionId(session.id) diff --git a/app/src/main/java/org/mozilla/fenix/customtabs/WebAppSiteControlsBuilder.kt b/app/src/main/java/org/mozilla/fenix/customtabs/WebAppSiteControlsBuilder.kt index b0596654c..f592ea839 100644 --- a/app/src/main/java/org/mozilla/fenix/customtabs/WebAppSiteControlsBuilder.kt +++ b/app/src/main/java/org/mozilla/fenix/customtabs/WebAppSiteControlsBuilder.kt @@ -7,8 +7,8 @@ package org.mozilla.fenix.customtabs import android.app.Notification import android.content.Context import android.content.Intent -import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.state.CustomTabSessionState import mozilla.components.concept.engine.manifest.WebAppManifest import mozilla.components.feature.pwa.feature.SiteControlsBuilder import mozilla.components.feature.session.SessionUseCases @@ -36,6 +36,7 @@ class WebAppSiteControlsBuilder( override fun getFilter() = inner.getFilter() - override fun onReceiveBroadcast(context: Context, session: Session, intent: Intent) = - inner.onReceiveBroadcast(context, session, intent) + override fun onReceiveBroadcast(context: Context, tab: CustomTabSessionState, intent: Intent) { + inner.onReceiveBroadcast(context, tab, intent) + } } 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 be571cc84..db524f182 100644 --- a/app/src/main/java/org/mozilla/fenix/downloads/DynamicDownloadDialog.kt +++ b/app/src/main/java/org/mozilla/fenix/downloads/DynamicDownloadDialog.kt @@ -14,6 +14,7 @@ import mozilla.components.feature.downloads.AbstractFetchDownloadService import mozilla.components.feature.downloads.toMegabyteOrKilobyteString import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.settings @@ -26,6 +27,7 @@ import org.mozilla.fenix.ext.settings class DynamicDownloadDialog( private val container: ViewGroup, private val downloadState: DownloadState?, + private val metrics: MetricController, private val didFail: Boolean, private val tryAgain: (String) -> Unit, private val onCannotOpenFile: () -> Unit, @@ -99,6 +101,8 @@ class DynamicDownloadDialog( mozilla.components.feature.downloads.R.string.mozac_feature_downloads_button_open ) setOnClickListener { + metrics.track(Event.DownloadsItemOpened) + val fileWasOpened = AbstractFetchDownloadService.openFile( context = context, contentType = downloadState.contentType, diff --git a/app/src/main/java/org/mozilla/fenix/experiments/Experiments.kt b/app/src/main/java/org/mozilla/fenix/experiments/Experiments.kt new file mode 100644 index 000000000..5b5f67b34 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/experiments/Experiments.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.experiments + +class Experiments { + companion object { + const val A_A_NIMBUS_VALIDATION = "fenix-nimbus-validation" + const val BOOKMARK_ICON = "fenix-bookmark-list-icon" + } +} + +class ExperimentBranch { + companion object { + const val TREATMENT = "treatment" + const val CONTROL = "control" + const val A1 = "A1" + const val A2 = "A2" + } +} diff --git a/app/src/main/java/org/mozilla/fenix/ext/Context.kt b/app/src/main/java/org/mozilla/fenix/ext/Context.kt index d10637026..e12382da5 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/Context.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/Context.kt @@ -14,7 +14,6 @@ import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.annotation.StringRes -import mozilla.components.browser.search.SearchEngineManager import mozilla.components.support.locale.LocaleManager import org.mozilla.fenix.FenixApplication import org.mozilla.fenix.components.Components @@ -41,12 +40,6 @@ val Context.components: Components val Context.metrics: MetricController get() = this.components.analytics.metrics -/** - * Helper function to get the SearchEngineManager off of context. - */ -val Context.searchEngineManager: SearchEngineManager - get() = this.components.search.searchEngineManager - fun Context.asActivity() = (this as? ContextThemeWrapper)?.baseContext as? Activity ?: this as? Activity diff --git a/app/src/main/java/org/mozilla/fenix/ext/Nimbus.kt b/app/src/main/java/org/mozilla/fenix/ext/Nimbus.kt new file mode 100644 index 000000000..e1ab7fa39 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/ext/Nimbus.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.ext + +import mozilla.components.service.nimbus.NimbusApi +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.fenix.FeatureFlags + +/** + * Gets the branch of the given `experimentId` and transforms it with given closure. + * + * If we're enrolled in the experiment, the transform is passed the branch id/slug as a `String`. + * + * If we're not enrolled in the experiment, or the experiment is not valid then the transform + * is passed a `null`. + */ +@Suppress("TooGenericExceptionCaught") +fun NimbusApi.withExperiment(experimentId: String, transform: (String?) -> T): T { + val branch = if (FeatureFlags.nimbusExperiments) { + try { + getExperimentBranch(experimentId) + } catch (e: Throwable) { + Logger.error("Failed to getExperimentBranch($experimentId)", e) + null + } + } else { + null + } + return transform(branch) +} + +/** + * The degenerate case of `withExperiment(String, (String?) -> T))`, with an identity transform. + * + * Short-hand for `mozilla.components.service.nimbus.NimbusApi.getExperimentBranch`. + */ +fun NimbusApi.withExperiment(experimentId: String) = + this.withExperiment(experimentId, ::identity) + +private fun identity(value: T) = value 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 e0f198348..3096f907c 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -15,7 +15,9 @@ import android.view.Display.FLAG_SECURE import android.view.Gravity import android.view.LayoutInflater import android.view.View +import android.view.View.AccessibilityDelegate import android.view.ViewGroup +import android.view.accessibility.AccessibilityEvent import android.widget.Button import android.widget.LinearLayout import android.widget.PopupWindow @@ -39,25 +41,34 @@ import androidx.navigation.fragment.navArgs 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.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.android.synthetic.main.fragment_home.view.bottomBarShadow +import kotlinx.android.synthetic.main.fragment_home.view.bottom_bar +import kotlinx.android.synthetic.main.fragment_home.view.homeAppBar +import kotlinx.android.synthetic.main.fragment_home.view.menuButton +import kotlinx.android.synthetic.main.fragment_home.view.sessionControlRecyclerView +import kotlinx.android.synthetic.main.fragment_home.view.tab_button +import kotlinx.android.synthetic.main.fragment_home.view.toolbar +import kotlinx.android.synthetic.main.fragment_home.view.toolbarLayout +import kotlinx.android.synthetic.main.fragment_home.view.toolbar_wrapper +import kotlinx.android.synthetic.main.no_collections_message.view.add_tabs_to_collections_button import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.menu.view.MenuButton -import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager import mozilla.components.browser.state.selector.findTab -import mozilla.components.browser.state.selector.getNormalOrPrivateTabs 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.selectedOrDefaultSearchEngine import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AuthType @@ -65,9 +76,12 @@ import mozilla.components.concept.sync.OAuthAccount import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.top.sites.TopSitesConfig import mozilla.components.feature.top.sites.TopSitesFeature +import mozilla.components.lib.state.ext.consumeFlow import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.content.res.resolveAttribute +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import mozilla.components.ui.tabcounter.TabCounterMenu import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R @@ -81,7 +95,7 @@ import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.tips.FenixTipManager import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.components.tips.providers.MasterPasswordTipProvider -import org.mozilla.fenix.components.toolbar.TabCounterMenu +import org.mozilla.fenix.components.toolbar.FenixTabCounterMenu import org.mozilla.fenix.components.toolbar.ToolbarPosition import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.hideToolbar @@ -99,7 +113,6 @@ import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils.SumoTopic.HELP import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit import org.mozilla.fenix.theme.ThemeManager -import org.mozilla.fenix.utils.FragmentPreDrawManager import org.mozilla.fenix.utils.ToolbarPopupWindow import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.whatsnew.WhatsNew @@ -123,14 +136,6 @@ class HomeFragment : Fragment() { private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager private val collectionStorageObserver = object : TabCollectionStorage.Observer { - override fun onCollectionCreated(title: String, sessions: List) { - scrollAndAnimateCollection() - } - - override fun onTabsAdded(tabCollection: TabCollection, sessions: List) { - scrollAndAnimateCollection(tabCollection) - } - override fun onCollectionRenamed(tabCollection: TabCollection, title: String) { lifecycleScope.launch(Main) { view?.sessionControlRecyclerView?.adapter?.notifyDataSetChanged() @@ -156,13 +161,14 @@ class HomeFragment : Fragment() { get() = _sessionControlInteractor!! private var sessionControlView: SessionControlView? = null + private var appBarLayout: AppBarLayout? = null private lateinit var currentMode: CurrentMode private val topSitesFeature = ViewBoundFeatureWrapper() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - postponeEnterTransition() + bundleArgs = args.toBundle() lifecycleScope.launch(IO) { if (!onboarding.userHasBeenOnboarded()) { @@ -227,9 +233,11 @@ class HomeFragment : Fragment() { settings = components.settings, engine = components.core.engine, metrics = components.analytics.metrics, + store = store, sessionManager = sessionManager, tabCollectionStorage = components.core.tabCollectionStorage, addTabUseCase = components.useCases.tabsUseCases.addTab, + reloadUrlUseCase = components.useCases.sessionUseCases.reload, fragmentStore = homeFragmentStore, navController = findNavController(), viewLifecycleScope = viewLifecycleOwner.lifecycleScope, @@ -251,6 +259,8 @@ class HomeFragment : Fragment() { updateSessionControlView(view) + appBarLayout = view.homeAppBar + activity.themeManager.applyStatusBarTheme(activity) return view } @@ -327,53 +337,9 @@ class HomeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - FragmentPreDrawManager(this).execute { - homeViewModel.layoutManagerState?.also { parcelable -> - sessionControlView!!.view.layoutManager?.onRestoreInstanceState(parcelable) - } - homeViewModel.layoutManagerState = null - - // We have to delay so that the keyboard collapses and the view is resized before the - // animation from SearchFragment happens - delay(ANIMATION_DELAY) - } - - viewLifecycleOwner.lifecycleScope.launch(IO) { - // This is necessary due to a bug in viewLifecycleOwner. See: - // https://github.com/mozilla-mobile/android-components/blob/master/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt#L32-L56 - // TODO remove when viewLifecycleOwner is fixed - val context = context ?: return@launch - - val iconSize = - context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size) - - val searchEngine = context.components.search.provider.getDefaultEngine(context) - val searchIcon = BitmapDrawable(context.resources, searchEngine.icon) - searchIcon.setBounds(0, 0, iconSize, iconSize) - - withContext(Main) { - search_engine_icon?.setImageDrawable(searchIcon) - } - } - + observeSearchEngineChanges() createHomeMenu(requireContext(), WeakReference(view.menuButton)) - val tabCounterMenu = TabCounterMenu( - view.context, - metrics = view.context.components.analytics.metrics - ) { - if (it is TabCounterMenu.Item.NewTab) { - (activity as HomeActivity).browsingModeManager.mode = it.mode - } - } - val inverseBrowsingMode = when ((activity as HomeActivity).browsingModeManager.mode) { - BrowsingMode.Normal -> BrowsingMode.Private - BrowsingMode.Private -> BrowsingMode.Normal - } - tabCounterMenu.updateMenu(showOnly = inverseBrowsingMode) - view.tab_button.setOnLongClickListener { - tabCounterMenu.menuController.show(anchor = it) - true - } + createTabCounterMenu(view) view.menuButton.setColorFilter( ContextCompat.getColor( @@ -385,7 +351,6 @@ class HomeFragment : Fragment() { view.toolbar.compoundDrawablePadding = view.resources.getDimensionPixelSize(R.dimen.search_bar_search_engine_icon_padding) view.toolbar_wrapper.setOnClickListener { - hideOnboardingIfNeeded() navigateToSearch() requireComponents.analytics.metrics.track(Event.SearchBarTapped(Event.SearchBarTapped.Source.HOME)) } @@ -443,6 +408,64 @@ class HomeFragment : Fragment() { 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 -> + flow.map { state -> state.search.selectedOrDefaultSearchEngine } + .ifChanged() + .collect { searchEngine -> + if (searchEngine != null) { + val iconSize = + requireContext().resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size) + val searchIcon = BitmapDrawable(requireContext().resources, searchEngine.icon) + searchIcon.setBounds(0, 0, iconSize, iconSize) + search_engine_icon?.setImageDrawable(searchIcon) + } else { + search_engine_icon.setImageDrawable(null) + } + } + } + } + + private fun createTabCounterMenu(view: View) { + val browsingModeManager = (activity as HomeActivity).browsingModeManager + val mode = browsingModeManager.mode + + val onItemTapped: (TabCounterMenu.Item) -> Unit = { + if (it is TabCounterMenu.Item.NewTab) { + browsingModeManager.mode = BrowsingMode.Normal + } else if (it is TabCounterMenu.Item.NewPrivateTab) { + browsingModeManager.mode = BrowsingMode.Private + } + } + + val tabCounterMenu = FenixTabCounterMenu( + view.context, + onItemTapped, + iconColor = if (mode == BrowsingMode.Private) { + ContextCompat.getColor(requireContext(), R.color.primary_text_private_theme) + } else { + null + } + ) + + val inverseBrowsingMode = when (mode) { + BrowsingMode.Normal -> BrowsingMode.Private + BrowsingMode.Private -> BrowsingMode.Normal + } + + tabCounterMenu.updateMenu(showOnly = inverseBrowsingMode) + view.tab_button.setOnLongClickListener { + tabCounterMenu.menuController.show(anchor = it) + true } } @@ -499,14 +522,17 @@ class HomeFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() + _sessionControlInteractor = null sessionControlView = null + appBarLayout = null bundleArgs.clear() requireActivity().window.clearFlags(FLAG_SECURE) } override fun onStart() { super.onStart() + subscribeToTabCollections() val context = requireContext() @@ -614,12 +640,6 @@ class HomeFragment : Fragment() { }.show() } - override fun onStop() { - super.onStop() - homeViewModel.layoutManagerState = - sessionControlView!!.view.layoutManager?.onSaveInstanceState() - } - override fun onResume() { super.onResume() if (browsingModeManager.mode == BrowsingMode.Private) { @@ -698,6 +718,20 @@ class HomeFragment : Fragment() { } private fun navigateToSearch() { + // Dismisses the search dialog when the home content is scrolled + val recyclerView = sessionControlView!!.view + val listener = object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING) { + findNavController().navigateUp() + recyclerView.removeOnScrollListener(this) + } + } + } + + recyclerView.addOnScrollListener(listener) + val directions = HomeFragmentDirections.actionGlobalSearchDialog( sessionId = null @@ -815,31 +849,23 @@ class HomeFragment : Fragment() { requireComponents.core.tabCollectionStorage.register(collectionStorageObserver, this) } + /** + * This method will find and scroll to the row of the specified collection Id. + * */ private fun scrollAndAnimateCollection( - changedCollection: TabCollection? = null + collectionIdToSelect: Long = -1 ) { - if (view != null) { + if (view != null && collectionIdToSelect >= 0) { viewLifecycleOwner.lifecycleScope.launch { val recyclerView = sessionControlView!!.view delay(ANIM_SCROLL_DELAY) - val tabsSize = store.state - .getNormalOrPrivateTabs(browsingModeManager.mode.isPrivate) - .size - - var indexOfCollection = tabsSize + NON_TAB_ITEM_NUM - changedCollection?.let { changedCollection -> - requireComponents.core.tabCollectionStorage.cachedTabCollections - .filterIndexed { index, tabCollection -> - if (tabCollection.id == changedCollection.id) { - indexOfCollection = tabsSize + NON_TAB_ITEM_NUM + index - return@filterIndexed true - } - false - } - } + val indexOfCollection = + NON_COLLECTION_ITEM_NUM + findIndexOfSpecificCollection(collectionIdToSelect) + val lastVisiblePosition = (recyclerView.layoutManager as? LinearLayoutManager)?.findLastCompletelyVisibleItemPosition() ?: 0 + if (lastVisiblePosition < indexOfCollection) { val onScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged( @@ -848,6 +874,7 @@ class HomeFragment : Fragment() { ) { super.onScrollStateChanged(recyclerView, newState) if (newState == SCROLL_STATE_IDLE) { + appBarLayout?.setExpanded(false) animateCollection(indexOfCollection) recyclerView.removeOnScrollListener(this) } @@ -856,12 +883,34 @@ class HomeFragment : Fragment() { recyclerView.addOnScrollListener(onScrollListener) recyclerView.smoothScrollToPosition(indexOfCollection) } else { + appBarLayout?.setExpanded(false) animateCollection(indexOfCollection) } } } } + /** + * Returns index of the collection with the specified id. + * */ + private fun findIndexOfSpecificCollection( + changedCollectionId: Long + ): Int { + var result = 0 + requireComponents.core.tabCollectionStorage.cachedTabCollections + .filterIndexed { index, tabCollection -> + if (tabCollection.id == changedCollectionId) { + result = index + return@filterIndexed true + } + false + } + return result + } + + /** + * Will highlight the border of the collection with the specified index. + * */ private fun animateCollection(indexOfCollection: Int) { viewLifecycleOwner.lifecycleScope.launch { val viewHolder = @@ -889,22 +938,41 @@ class HomeFragment : Fragment() { ?.setDuration(FADE_ANIM_DURATION) ?.setListener(listener)?.start() }.invokeOnCompletion { - showSavedSnackbar() + val a11yEnabled = context?.settings()?.accessibilityServicesEnabled ?: false + if (a11yEnabled) { + focusCollectionForTalkBack(indexOfCollection) + } } } - private fun showSavedSnackbar() { + /** + * Will focus the collection with [indexOfCollection] for accessibility services. + * */ + private fun focusCollectionForTalkBack(indexOfCollection: Int) { viewLifecycleOwner.lifecycleScope.launch { - delay(ANIM_SNACKBAR_DELAY) - view?.let { view -> - FenixSnackbar.make( - view = view, - duration = Snackbar.LENGTH_LONG, - isDisplayedWithBrowserToolbar = false - ) - .setText(view.context.getString(R.string.create_collection_tabs_saved_new_collection)) - .setAnchorView(snackbarAnchorView) - .show() + var focusedForAccessibility = false + view?.let { mainView -> + mainView.accessibilityDelegate = object : AccessibilityDelegate() { + override fun onRequestSendAccessibilityEvent( + host: ViewGroup, + child: View, + event: AccessibilityEvent + ): Boolean { + if (!focusedForAccessibility && + event.eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED + ) { + sessionControlView?.view?.findViewHolderForAdapterPosition( + indexOfCollection + )?.itemView?.let { viewToFocus -> + focusedForAccessibility = true + viewToFocus.requestFocus() + viewToFocus.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) + return false + } + } + return super.onRequestSendAccessibilityEvent(host, child, event) + } + } } } } @@ -930,8 +998,11 @@ class HomeFragment : Fragment() { ) } + // TODO use [FenixTabCounterToolbarButton] instead of [TabCounter]: + // https://github.com/mozilla-mobile/fenix/issues/16792 private fun updateTabCounter(browserState: BrowserState) { val tabCount = if (browsingModeManager.mode.isPrivate) { + view?.tab_button?.setColor(ContextCompat.getColor(requireContext(), R.color.primary_text_private_theme)) browserState.privateTabs.size } else { browserState.normalTabs.size @@ -950,13 +1021,18 @@ class HomeFragment : Fragment() { const val ALL_PRIVATE_TABS = "all_private" private const val FOCUS_ON_ADDRESS_BAR = "focusOnAddressBar" + private const val FOCUS_ON_COLLECTION = "focusOnCollection" private const val ANIMATION_DELAY = 100L - private const val NON_TAB_ITEM_NUM = 3 + /** + * Represents the number of items in [sessionControlView] that are NOT part of + * the list of collections. At the moment these are topSites pager, collections header. + * */ + private const val NON_COLLECTION_ITEM_NUM = 2 + private const val ANIM_SCROLL_DELAY = 100L private const val ANIM_ON_SCREEN_DELAY = 200L private const val FADE_ANIM_DURATION = 150L - private const val ANIM_SNACKBAR_DELAY = 100L private const val CFR_WIDTH_DIVIDER = 1.7 private const val CFR_Y_OFFSET = -20 } 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 3e172e8f8..027e6cb26 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt @@ -22,8 +22,11 @@ import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.OAuthAccount import mozilla.components.support.ktx.android.content.getColorFromAttr import org.mozilla.fenix.R +import org.mozilla.fenix.experiments.ExperimentBranch +import org.mozilla.fenix.experiments.Experiments import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.ext.withExperiment import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.whatsnew.WhatsNew @@ -96,17 +99,38 @@ class HomeMenu( onItemTapped.invoke(Item.WhatsNew) } + val experiments = context.components.analytics.experiments + val bookmarksIcon = experiments.withExperiment(Experiments.BOOKMARK_ICON) { + when (it) { + ExperimentBranch.TREATMENT -> R.drawable.ic_bookmark_list + else -> R.drawable.ic_bookmark_filled + } + } val bookmarksItem = BrowserMenuImageText( context.getString(R.string.library_bookmarks), - R.drawable.ic_bookmark_filled, + bookmarksIcon, primaryTextColor ) { onItemTapped.invoke(Item.Bookmarks) } + // We want to validate that the Nimbus experiments library is working, from the android UI + // all the way back to the data science backend. We're not testing the user's preference + // or response, we're end-to-end testing the experiments platform. + // So here, we're running multiple identical branches with the same treatment, and if the + // user isn't targeted, then we get still get the same treatment. + // The `let` block is degenerate here, but left here so as to document the form of how experiments + // are implemented here. + val historyIcon = experiments.withExperiment(Experiments.A_A_NIMBUS_VALIDATION) { + when (it) { + ExperimentBranch.A1 -> R.drawable.ic_history + ExperimentBranch.A2 -> R.drawable.ic_history + else -> R.drawable.ic_history + } + } val historyItem = BrowserMenuImageText( context.getString(R.string.library_history), - R.drawable.ic_history, + historyIcon, primaryTextColor ) { onItemTapped.invoke(Item.History) diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeScreenViewModel.kt b/app/src/main/java/org/mozilla/fenix/home/HomeScreenViewModel.kt index 3c0b80149..8fa03b7d7 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeScreenViewModel.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeScreenViewModel.kt @@ -4,7 +4,6 @@ package org.mozilla.fenix.home -import android.os.Parcelable import androidx.lifecycle.ViewModel class HomeScreenViewModel : ViewModel() { @@ -13,8 +12,6 @@ class HomeScreenViewModel : ViewModel() { */ var sessionToDelete: String? = null - var layoutManagerState: Parcelable? = null - /** * Used to remember if we need to scroll to top of the homeFragment's recycleView (top sites) see #8561 * */ diff --git a/app/src/main/java/org/mozilla/fenix/home/intent/FennecBookmarkShortcutsIntentProcessor.kt b/app/src/main/java/org/mozilla/fenix/home/intent/FennecBookmarkShortcutsIntentProcessor.kt index cf3625956..cf37943e9 100644 --- a/app/src/main/java/org/mozilla/fenix/home/intent/FennecBookmarkShortcutsIntentProcessor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/intent/FennecBookmarkShortcutsIntentProcessor.kt @@ -44,7 +44,7 @@ class FennecBookmarkShortcutsIntentProcessor( val session = Session(url, private = false, source = SessionState.Source.HOME_SCREEN) sessionManager.add(session, selected = true) - loadUrlUseCase(url, session, EngineSession.LoadUrlFlags.external()) + loadUrlUseCase(url, session.id, EngineSession.LoadUrlFlags.external()) intent.action = ACTION_VIEW intent.putSessionId(session.id) diff --git a/app/src/main/java/org/mozilla/fenix/home/intent/OpenSpecificTabIntentProcessor.kt b/app/src/main/java/org/mozilla/fenix/home/intent/OpenSpecificTabIntentProcessor.kt index 618274272..4e91e5ef4 100644 --- a/app/src/main/java/org/mozilla/fenix/home/intent/OpenSpecificTabIntentProcessor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/intent/OpenSpecificTabIntentProcessor.kt @@ -7,24 +7,26 @@ package org.mozilla.fenix.home.intent import android.content.Intent import androidx.navigation.NavController import mozilla.components.feature.media.service.AbstractMediaService +import mozilla.components.feature.media.service.AbstractMediaSessionService import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.FeatureFlags.newMediaSessionApi import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.ext.components /** * When the media notification is clicked we need to switch to the tab where the audio/video is * playing. This intent has the following informations: - * action - [AbstractMediaService.Companion.ACTION_SWITCH_TAB] - * extra string for the tab id - [AbstractMediaService.Companion.EXTRA_TAB_ID] + * action - [AbstractMediaSessionService.Companion.ACTION_SWITCH_TAB] + * extra string for the tab id - [AbstractMediaSessionService.Companion.EXTRA_TAB_ID] */ class OpenSpecificTabIntentProcessor( private val activity: HomeActivity ) : HomeIntentProcessor { override fun process(intent: Intent, navController: NavController, out: Intent): Boolean { - if (intent.action == AbstractMediaService.Companion.ACTION_SWITCH_TAB) { + if (intent.action == getAction()) { val sessionManager = activity.components.core.sessionManager - val sessionId = intent.extras?.getString(AbstractMediaService.Companion.EXTRA_TAB_ID) + val sessionId = intent.extras?.getString(getTabId()) val session = sessionId?.let { sessionManager.findSessionById(it) } if (session != null) { sessionManager.select(session) @@ -36,3 +38,19 @@ class OpenSpecificTabIntentProcessor( return false } } + +private fun getAction(): String { + return if (newMediaSessionApi) { + AbstractMediaSessionService.Companion.ACTION_SWITCH_TAB + } else { + AbstractMediaService.Companion.ACTION_SWITCH_TAB + } +} + +private fun getTabId(): String { + return if (newMediaSessionApi) { + AbstractMediaSessionService.Companion.EXTRA_TAB_ID + } else { + AbstractMediaService.Companion.EXTRA_TAB_ID + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/intent/SpeechProcessingIntentProcessor.kt b/app/src/main/java/org/mozilla/fenix/home/intent/SpeechProcessingIntentProcessor.kt index be6fe57de..ace0aeacc 100644 --- a/app/src/main/java/org/mozilla/fenix/home/intent/SpeechProcessingIntentProcessor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/intent/SpeechProcessingIntentProcessor.kt @@ -7,6 +7,9 @@ package org.mozilla.fenix.home.intent import android.content.Intent import android.os.StrictMode import androidx.navigation.NavController +import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.search.ext.waitForSelectedOrDefaultSearchEngine import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.components.metrics.Event @@ -21,30 +24,48 @@ import org.mozilla.fenix.widget.VoiceSearchActivity.Companion.SPEECH_PROCESSING */ class SpeechProcessingIntentProcessor( private val activity: HomeActivity, + private val store: BrowserStore, private val metrics: MetricController ) : HomeIntentProcessor { override fun process(intent: Intent, navController: NavController, out: Intent): Boolean { - return if (intent.extras?.getBoolean(HomeActivity.OPEN_TO_BROWSER_AND_LOAD) == true) { - out.putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, false) - activity.components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { - val searchEvent = MetricsUtils.createSearchEvent( - activity.components.search.provider.getDefaultEngine(activity), - activity, - Event.PerformedSearch.SearchAccessPoint.WIDGET + if ( + !intent.hasExtra(SPEECH_PROCESSING) || + intent.extras?.getBoolean(HomeActivity.OPEN_TO_BROWSER_AND_LOAD) != true + ) { + return false + } + + out.putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, false) + + store.waitForSelectedOrDefaultSearchEngine { searchEngine -> + if (searchEngine != null) { + launchToBrowser( + searchEngine, + intent.getStringExtra(SPEECH_PROCESSING).orEmpty() ) - searchEvent?.let { metrics.track(it) } } + } - activity.openToBrowserAndLoad( - searchTermOrURL = intent.getStringExtra(SPEECH_PROCESSING).orEmpty(), - newTab = true, - from = BrowserDirection.FromGlobal, - forceSearch = true + return true + } + + private fun launchToBrowser(searchEngine: SearchEngine, text: String) { + activity.components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { + val searchEvent = MetricsUtils.createSearchEvent( + searchEngine, + store, + Event.PerformedSearch.SearchAccessPoint.WIDGET ) - true - } else { - false + searchEvent?.let { metrics.track(it) } } + + activity.openToBrowserAndLoad( + searchTermOrURL = text, + newTab = true, + from = BrowserDirection.FromGlobal, + engine = searchEngine, + forceSearch = true + ) } } diff --git a/app/src/main/java/org/mozilla/fenix/home/intent/StartSearchIntentProcessor.kt b/app/src/main/java/org/mozilla/fenix/home/intent/StartSearchIntentProcessor.kt index e6f079c8b..2cf18554c 100644 --- a/app/src/main/java/org/mozilla/fenix/home/intent/StartSearchIntentProcessor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/intent/StartSearchIntentProcessor.kt @@ -6,8 +6,10 @@ package org.mozilla.fenix.home.intent import android.content.Intent import androidx.navigation.NavController +import androidx.navigation.navOptions import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.NavGraphDirections +import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.ext.nav @@ -22,25 +24,25 @@ class StartSearchIntentProcessor( override fun process(intent: Intent, navController: NavController, out: Intent): Boolean { val event = intent.extras?.getString(HomeActivity.OPEN_TO_SEARCH) - var source: Event.PerformedSearch.SearchAccessPoint? = null return if (event != null) { - when (event) { + val source = when (event) { SEARCH_WIDGET -> { metrics.track(Event.SearchWidgetNewTabPressed) - source = Event.PerformedSearch.SearchAccessPoint.WIDGET + Event.PerformedSearch.SearchAccessPoint.WIDGET } STATIC_SHORTCUT_NEW_TAB -> { metrics.track(Event.PrivateBrowsingStaticShortcutTab) - source = Event.PerformedSearch.SearchAccessPoint.SHORTCUT + Event.PerformedSearch.SearchAccessPoint.SHORTCUT } STATIC_SHORTCUT_NEW_PRIVATE_TAB -> { metrics.track(Event.PrivateBrowsingStaticShortcutPrivateTab) - source = Event.PerformedSearch.SearchAccessPoint.SHORTCUT + Event.PerformedSearch.SearchAccessPoint.SHORTCUT } PRIVATE_BROWSING_PINNED_SHORTCUT -> { metrics.track(Event.PrivateBrowsingPinnedShortcutPrivateTab) - source = Event.PerformedSearch.SearchAccessPoint.SHORTCUT + Event.PerformedSearch.SearchAccessPoint.SHORTCUT } + else -> null } out.removeExtra(HomeActivity.OPEN_TO_SEARCH) @@ -51,7 +53,12 @@ class StartSearchIntentProcessor( searchAccessPoint = it ) } - directions?.let { navController.nav(null, it) } + directions?.let { + val options = navOptions { + popUpTo = R.id.homeFragment + } + navController.nav(null, it, options) + } true } else { false 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 9bc885577..a7c1ac97b 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 @@ -12,8 +12,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.prompt.ShareData +import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.ext.restore import mozilla.components.feature.tabs.TabsUseCases @@ -34,7 +37,6 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.sessionsOfType -import org.mozilla.fenix.ext.settings import org.mozilla.fenix.home.HomeFragment import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentDirections @@ -158,6 +160,11 @@ interface SessionControlController { * @see [CollectionInteractor.onRemoveCollectionsPlaceholder] */ fun handleRemoveCollectionsPlaceholder() + + /** + * @see [CollectionInteractor.onCollectionMenuOpened] and [TopSiteInteractor.onTopSiteMenuOpened] + */ + fun handleMenuOpened() } @Suppress("TooManyFunctions", "LargeClass") @@ -167,8 +174,10 @@ class DefaultSessionControlController( private val engine: Engine, private val metrics: MetricController, private val sessionManager: SessionManager, + private val store: BrowserStore, private val tabCollectionStorage: TabCollectionStorage, private val addTabUseCase: TabsUseCases.AddNewTabUseCase, + private val reloadUrlUseCase: SessionUseCases.ReloadUrlUseCase, private val fragmentStore: HomeFragmentStore, private val navController: NavController, private val viewLifecycleScope: CoroutineScope, @@ -193,13 +202,19 @@ class DefaultSessionControlController( ) } + override fun handleMenuOpened() { + dismissSearchDialogIfDisplayed() + } + override fun handleCollectionOpenTabClicked(tab: ComponentTab) { + dismissSearchDialogIfDisplayed() sessionManager.restore( activity, engine, tab, onTabRestored = { activity.openToBrowser(BrowserDirection.FromHome) + reloadUrlUseCase.invoke(sessionManager.selectedSession) }, onFailure = { activity.openToBrowserAndLoad( @@ -256,6 +271,7 @@ class DefaultSessionControlController( } override fun handleCollectionShareTabsClicked(collection: TabCollection) { + dismissSearchDialogIfDisplayed() showShareFragment( collection.title, collection.tabs.map { ShareData(url = it.url, title = it.title) } @@ -282,6 +298,7 @@ class DefaultSessionControlController( } override fun handlePrivateBrowsingLearnMoreClicked() { + dismissSearchDialogIfDisplayed() activity.openToBrowserAndLoad( searchTermOrURL = SupportUtils.getGenericSumoURLForTopic (SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS), @@ -293,9 +310,9 @@ class DefaultSessionControlController( override fun handleRenameTopSiteClicked(topSite: TopSite) { activity.let { val customLayout = - LayoutInflater.from(it).inflate(R.layout.top_sites_rename_dialog, null) + LayoutInflater.from(it).inflate(R.layout.top_sites_rename_dialog, null) val topSiteLabelEditText: EditText = - customLayout.findViewById(R.id.top_site_title) + customLayout.findViewById(R.id.top_site_title) topSiteLabelEditText.setText(topSite.title) AlertDialog.Builder(it).apply { @@ -344,6 +361,7 @@ class DefaultSessionControlController( } override fun handleSelectTopSite(url: String, type: TopSite.Type) { + dismissSearchDialogIfDisplayed() metrics.track(Event.TopSiteOpenInNewTab) when (type) { TopSite.Type.DEFAULT -> metrics.track(Event.TopSiteOpenDefault) @@ -362,6 +380,12 @@ class DefaultSessionControlController( activity.openToBrowser(BrowserDirection.FromHome) } + private fun dismissSearchDialogIfDisplayed() { + if (navController.currentDestination?.id == R.id.searchDialogFragment) { + navController.navigateUp() + } + } + override fun handleStartBrowsingClicked() { hideOnboarding() } @@ -444,21 +468,23 @@ class DefaultSessionControlController( } override fun handlePasteAndGo(clipboardText: String) { + val searchEngine = store.state.search.selectedOrDefaultSearchEngine + activity.openToBrowserAndLoad( searchTermOrURL = clipboardText, newTab = true, from = BrowserDirection.FromHome, - engine = activity.components.search.provider.getDefaultEngine(activity) + engine = searchEngine ) - val event = if (clipboardText.isUrl()) { + val event = if (clipboardText.isUrl() || searchEngine == null) { Event.EnteredUrl(false) } else { val searchAccessPoint = Event.PerformedSearch.SearchAccessPoint.ACTION searchAccessPoint.let { sap -> MetricsUtils.createSearchEvent( - activity.components.search.provider.getDefaultEngine(activity), - activity, + searchEngine, + store, sap ) } 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 6237920ae..33bdd0f31 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 @@ -23,6 +23,7 @@ interface TabSessionInteractor { /** * Interface for collection related actions in the [SessionControlInteractor]. */ +@SuppressWarnings("TooManyFunctions") interface CollectionInteractor { /** * Shows the Collection Creation fragment for selecting the tabs to add to the given tab @@ -98,6 +99,11 @@ interface CollectionInteractor { * User has removed the collections placeholder from home. */ fun onRemoveCollectionsPlaceholder() + + /** + * User has opened collection 3 dot menu. + */ + fun onCollectionMenuOpened() } interface ToolbarInteractor { @@ -177,6 +183,11 @@ interface TopSiteInteractor { * @param type The type of the top site. */ fun onSelectTopSite(url: String, type: TopSite.Type) + + /** + * Called when top site menu is opened. + */ + fun onTopSiteMenuOpened() } /** @@ -276,4 +287,12 @@ class SessionControlInteractor( override fun onRemoveCollectionsPlaceholder() { controller.handleRemoveCollectionsPlaceholder() } + + override fun onCollectionMenuOpened() { + controller.handleMenuOpened() + } + + override fun onTopSiteMenuOpened() { + controller.handleMenuOpened() + } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/CollectionViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/CollectionViewHolder.kt index c643f43d9..6e8e3ea9c 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/CollectionViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/CollectionViewHolder.kt @@ -47,6 +47,7 @@ class CollectionViewHolder( } collection_overflow_button.setOnClickListener { + interactor.onCollectionMenuOpened() collectionMenu.menuBuilder .build(view.context) .show(anchor = it) 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 efc572aec..48c4ee3e1 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 @@ -36,6 +36,7 @@ class TopSiteItemViewHolder( } top_site_item.setOnLongClickListener { + interactor.onTopSiteMenuOpened() it.context.components.analytics.metrics.track(Event.TopSiteLongPress(topSite.type)) val topSiteMenu = TopSiteItemMenu(view.context, topSite.type != FRECENT) { item -> diff --git a/app/src/main/java/org/mozilla/fenix/library/LibraryPageFragment.kt b/app/src/main/java/org/mozilla/fenix/library/LibraryPageFragment.kt index 9c93b9c8b..5b8c0660d 100644 --- a/app/src/main/java/org/mozilla/fenix/library/LibraryPageFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/LibraryPageFragment.kt @@ -12,7 +12,6 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.setToolbarColors abstract class LibraryPageFragment : Fragment() { @@ -36,7 +35,6 @@ abstract class LibraryPageFragment : Fragment() { } (activity as HomeActivity).browsingModeManager.mode = BrowsingMode.fromBoolean(private) - hideToolbar() } override fun onDetach() { diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt index 842d1878c..0b80684e4 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkController.kt @@ -45,8 +45,6 @@ interface BookmarkController { fun handleBookmarkFolderDeletion(nodes: Set) fun handleRequestSync() fun handleBackPressed() - fun handleStartSwipingItem() - fun handleStopSwipingItem() } @Suppress("TooManyFunctions") @@ -171,14 +169,6 @@ class DefaultBookmarkController( } } - override fun handleStartSwipingItem() { - store.dispatch(BookmarkFragmentAction.SwipeRefreshAvailabilityChanged(false)) - } - - override fun handleStopSwipingItem() { - store.dispatch(BookmarkFragmentAction.SwipeRefreshAvailabilityChanged(true)) - } - private fun openInNewTab( searchTermOrURL: String, newTab: Boolean, diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt index 3a1a4f252..4f5e757fc 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt @@ -120,12 +120,4 @@ class BookmarkFragmentInteractor( override fun onRequestSync() { bookmarksController.handleRequestSync() } - - override fun onStartSwipingItem() { - bookmarksController.handleStartSwipingItem() - } - - override fun onStopSwipingItem() { - bookmarksController.handleStopSwipingItem() - } } diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStore.kt index a14a5c023..da5a14ae2 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStore.kt @@ -24,14 +24,12 @@ class BookmarkFragmentStore( * @property guidBackstack A set of guids for bookmark nodes we have visited. Used to traverse back * up the tree after a sync. * @property isLoading true if bookmarks are still being loaded from disk - * @property isSwipeToRefreshEnabled true if swipe to refresh should be enabled */ data class BookmarkFragmentState( val tree: BookmarkNode?, val mode: Mode = Mode.Normal(), val guidBackstack: List = emptyList(), - val isLoading: Boolean = true, - val isSwipeToRefreshEnabled: Boolean = true + val isLoading: Boolean = true ) : State { sealed class Mode : SelectionHolder { override val selectedItems = emptySet() @@ -52,7 +50,6 @@ sealed class BookmarkFragmentAction : Action { object DeselectAll : BookmarkFragmentAction() object StartSync : BookmarkFragmentAction() object FinishSync : BookmarkFragmentAction() - data class SwipeRefreshAvailabilityChanged(val enabled: Boolean) : BookmarkFragmentAction() } /** @@ -88,13 +85,11 @@ private fun bookmarkFragmentStateReducer( tree = action.tree, mode = mode, guidBackstack = backstack, - isLoading = false, - isSwipeToRefreshEnabled = mode !is BookmarkFragmentState.Mode.Selecting + isLoading = false ) } is BookmarkFragmentAction.Select -> state.copy( - mode = BookmarkFragmentState.Mode.Selecting(state.mode.selectedItems + action.item), - isSwipeToRefreshEnabled = false + mode = BookmarkFragmentState.Mode.Selecting(state.mode.selectedItems + action.item) ) is BookmarkFragmentAction.Deselect -> { val items = state.mode.selectedItems - action.item @@ -104,8 +99,7 @@ private fun bookmarkFragmentStateReducer( BookmarkFragmentState.Mode.Selecting(items) } state.copy( - mode = mode, - isSwipeToRefreshEnabled = mode !is BookmarkFragmentState.Mode.Selecting + mode = mode ) } is BookmarkFragmentAction.DeselectAll -> @@ -114,21 +108,15 @@ private fun bookmarkFragmentStateReducer( BookmarkFragmentState.Mode.Syncing } else { BookmarkFragmentState.Mode.Normal() - }, - isSwipeToRefreshEnabled = true + } ) is BookmarkFragmentAction.StartSync -> state.copy( - mode = BookmarkFragmentState.Mode.Syncing, - isSwipeToRefreshEnabled = true + mode = BookmarkFragmentState.Mode.Syncing ) is BookmarkFragmentAction.FinishSync -> state.copy( mode = BookmarkFragmentState.Mode.Normal( showMenu = shouldShowMenu(state.tree?.guid) - ), - isSwipeToRefreshEnabled = true - ) - is BookmarkFragmentAction.SwipeRefreshAvailabilityChanged -> state.copy( - isSwipeToRefreshEnabled = action.enabled && state.mode !is BookmarkFragmentState.Mode.Selecting + ) ) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkTouchHelper.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkTouchHelper.kt deleted file mode 100644 index 5edf2c26f..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkTouchHelper.kt +++ /dev/null @@ -1,146 +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.bookmarks - -import android.graphics.Canvas -import android.graphics.Rect -import android.graphics.drawable.Drawable -import androidx.appcompat.content.res.AppCompatResources -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import mozilla.components.concept.storage.BookmarkNodeType -import mozilla.components.support.ktx.android.content.getColorFromAttr -import mozilla.components.support.ktx.android.content.getDrawableWithTint -import mozilla.components.support.ktx.android.util.dpToPx -import org.mozilla.fenix.R -import org.mozilla.fenix.home.sessioncontrol.SwipeToDeleteCallback -import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkNodeViewHolder -import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkSeparatorViewHolder - -class BookmarkTouchHelper(interactor: BookmarkViewInteractor) : - ItemTouchHelper(BookmarkTouchCallback(interactor)) - -class BookmarkTouchCallback( - private val interactor: BookmarkViewInteractor -) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { - - override fun getSwipeDirs( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int { - // Swiping separators is currently not supported. - if (viewHolder is BookmarkSeparatorViewHolder) { - return 0 - } - val item = (viewHolder as BookmarkNodeViewHolder).item - return if (item?.inRoots() == true) { - 0 - } else { - super.getSwipeDirs(recyclerView, viewHolder) - } - } - - /** - * Delete the bookmark when swiped. - */ - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val item = (viewHolder as BookmarkNodeViewHolder).item - item?.let { - interactor.onDelete(setOf(it)) - // We need to notify the adapter of a change if we swipe a folder to prevent - // visual bugs when cancelling deletion of a folder - if (item.type == BookmarkNodeType.FOLDER) { - viewHolder.bindingAdapter?.notifyItemChanged(viewHolder.bindingAdapterPosition) - } - } - } - - override fun onChildDraw( - c: Canvas, - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - dX: Float, - dY: Float, - actionState: Int, - isCurrentlyActive: Boolean - ) { - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) - val icon = recyclerView.context.getDrawableWithTint( - R.drawable.ic_delete, - recyclerView.context.getColorFromAttr(R.attr.destructive) - )!! - val background = AppCompatResources.getDrawable( - recyclerView.context, - R.drawable.swipe_delete_background - )!! - val margin = - SwipeToDeleteCallback.MARGIN.dpToPx(recyclerView.resources.displayMetrics) - val cellHeight = viewHolder.itemView.bottom - viewHolder.itemView.top - val iconTop = viewHolder.itemView.top + (cellHeight - icon.intrinsicHeight) / 2 - val iconBottom = iconTop + icon.intrinsicHeight - - when { - dX > 0 -> { // Swiping to the right - val backgroundBounds = Rect( - viewHolder.itemView.left, viewHolder.itemView.top, - (viewHolder.itemView.left + dX).toInt() + SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET, - viewHolder.itemView.bottom - ) - val iconLeft = viewHolder.itemView.left + margin - val iconRight = viewHolder.itemView.left + margin + icon.intrinsicWidth - val iconBounds = Rect(iconLeft, iconTop, iconRight, iconBottom) - - setBounds(background, backgroundBounds, icon, iconBounds) - draw(background, icon, c) - } - dX < 0 -> { // Swiping to the left - val backgroundBounds = Rect( - (viewHolder.itemView.right + dX).toInt() - SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET, - viewHolder.itemView.top, viewHolder.itemView.right, viewHolder.itemView.bottom - ) - val iconLeft = viewHolder.itemView.right - margin - icon.intrinsicWidth - val iconRight = viewHolder.itemView.right - margin - val iconBounds = Rect(iconLeft, iconTop, iconRight, iconBottom) - - setBounds(background, backgroundBounds, icon, iconBounds) - draw(background, icon, c) - } - else -> { // View not swiped - val bounds = Rect(0, 0, 0, 0) - setBounds(background, bounds, icon, bounds) - } - } - } - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean = false - - override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - super.onSelectedChanged(viewHolder, actionState) - if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { - interactor.onStartSwipingItem() - } else { - interactor.onStopSwipingItem() - } - } - - private fun setBounds( - background: Drawable, - backgroundBounds: Rect, - icon: Drawable, - iconBounds: Rect - ) { - background.bounds = backgroundBounds - icon.bounds = iconBounds - } - - private fun draw(background: Drawable, icon: Drawable, c: Canvas) { - background.draw(c) - icon.draw(c) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt index 60ff69d4c..5887a871d 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt @@ -13,7 +13,6 @@ import kotlinx.android.synthetic.main.component_bookmark.view.* import mozilla.appservices.places.BookmarkRoot import mozilla.components.concept.storage.BookmarkNode import mozilla.components.support.base.feature.UserInteractionHandler -import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R import org.mozilla.fenix.library.LibraryPageView @@ -99,16 +98,6 @@ interface BookmarkViewInteractor : SelectionInteractor { * */ fun onRequestSync() - - /** - * Handles the start of a swipe on a bookmark. - */ - fun onStartSwipingItem() - - /** - * Handles the end of a swipe on a bookmark. - */ - fun onStopSwipingItem() } class BookmarkView( @@ -135,10 +124,6 @@ class BookmarkView( view.swipe_refresh.setOnRefreshListener { interactor.onRequestSync() } - - if (FeatureFlags.bookmarkSwipeToDelete) { - BookmarkTouchHelper(interactor).attachToRecyclerView(view.bookmark_list) - } } fun update(state: BookmarkFragmentState) { @@ -166,7 +151,8 @@ class BookmarkView( } } view.bookmarks_progress_bar.isVisible = state.isLoading - view.swipe_refresh.isEnabled = state.isSwipeToRefreshEnabled + view.swipe_refresh.isEnabled = + state.mode is BookmarkFragmentState.Mode.Normal || state.mode is BookmarkFragmentState.Mode.Syncing view.swipe_refresh.isRefreshing = state.mode is BookmarkFragmentState.Mode.Syncing } diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadAdapter.kt index 747512daa..043053049 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadAdapter.kt @@ -36,7 +36,7 @@ class DownloadAdapter( override fun onBindViewHolder(holder: DownloadsListItemViewHolder, position: Int) { val current = downloads[position] val isPendingDeletion = pendingDeletionIds.contains(current.id) - holder.bind(downloads[position], position == 0, mode, isPendingDeletion) + holder.bind(downloads[position], mode, isPendingDeletion) } fun updateDownloads(downloads: List) { diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragment.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragment.kt index a3fd6302c..d05b3bbc4 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragment.kt @@ -32,6 +32,8 @@ import org.mozilla.fenix.R import org.mozilla.fenix.addons.showSnackBar import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.filterNotExistsOnDisk import org.mozilla.fenix.ext.requireComponents @@ -45,6 +47,7 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan private lateinit var downloadStore: DownloadFragmentStore private lateinit var downloadView: DownloadView private lateinit var downloadInteractor: DownloadInteractor + private lateinit var metrics: MetricController private var undoScope: CoroutineScope? = null private var pendingDownloadDeletionJob: (suspend () -> Unit)? = null @@ -109,9 +112,13 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setHasOptionsMenu(true) + + metrics = requireComponents.analytics.metrics + metrics.track(Event.DownloadsScreenOpened) } private fun displayDeleteAll() { + metrics.track(Event.DownloadsItemDeleted) activity?.let { activity -> AlertDialog.Builder(activity).apply { setMessage(R.string.download_delete_all_dialog) @@ -128,7 +135,7 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan launch(Dispatchers.Main) { showSnackBar( requireView(), - getString(R.string.download_delete_multiple_items_snackbar) + getString(R.string.download_delete_multiple_items_snackbar_1) ) } } @@ -140,6 +147,8 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan } private fun deleteDownloadItems(items: Set) { + metrics.track(Event.DownloadsItemDeleted) + updatePendingDownloadToDelete(items) undoScope = CoroutineScope(IO) undoScope?.allowUndo( @@ -175,7 +184,7 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan inflater.inflate(menuRes, menu) menu.findItem(R.id.delete_downloads_multi_select)?.title = - SpannableString(getString(R.string.bookmark_menu_delete_button)).apply { + SpannableString(getString(R.string.download_delete_item_1)).apply { setTextColor(requireContext(), R.attr.destructive) } } @@ -191,16 +200,23 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan downloadStore.dispatch(DownloadFragmentAction.ExitEditMode) true } + + R.id.select_all_downloads_multi_select -> { + for (items in downloadStore.state.items) { + downloadInteractor.select(items) + } + true + } else -> super.onOptionsItemSelected(item) } private fun getMultiSelectSnackBarMessage(downloadItems: Set): String { return if (downloadItems.size > 1) { - getString(R.string.download_delete_multiple_items_snackbar) + getString(R.string.download_delete_multiple_items_snackbar_1) } else { String.format( requireContext().getString( - R.string.history_delete_single_item_snackbar + R.string.download_delete_single_item_snackbar ), downloadItems.first().fileName ) } @@ -226,6 +242,8 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan filePath = item.filePath ) } + + metrics.track(Event.DownloadsItemOpened) } private fun getDeleteDownloadItemsOperation(items: Set): (suspend () -> Unit) { 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 03f50c2a5..82ad310b1 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 @@ -114,7 +114,7 @@ class DownloadView( download_list.isVisible = userHasDownloads download_empty_view.isVisible = !userHasDownloads if (!userHasDownloads) { - download_empty_view.announceForAccessibility(context.getString(R.string.download_empty_message)) + download_empty_view.announceForAccessibility(context.getString(R.string.download_empty_message_1)) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/viewholders/DownloadsListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/viewholders/DownloadsListItemViewHolder.kt index 0a305e82f..46e76067d 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/viewholders/DownloadsListItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/viewholders/DownloadsListItemViewHolder.kt @@ -42,7 +42,6 @@ class DownloadsListItemViewHolder( fun bind( item: DownloadItem, - showDeleteButton: Boolean, mode: DownloadFragmentState.Mode, isPendingDeletion: Boolean = false ) { @@ -54,15 +53,21 @@ class DownloadsListItemViewHolder( itemView.download_layout.titleView.text = item.fileName itemView.download_layout.urlView.text = item.size.toLong().toMegabyteOrKilobyteString() - toggleTopContent(showDeleteButton, mode == DownloadFragmentState.Mode.Normal) + toggleTopContent(false, mode == DownloadFragmentState.Mode.Normal) itemView.download_layout.setSelectionInteractor(item, selectionHolder, downloadInteractor) itemView.download_layout.changeSelected(item in selectionHolder.selectedItems) itemView.favicon.setImageResource(item.getIcon()) + itemView.overflow_menu.setImageResource(R.drawable.ic_delete) + itemView.overflow_menu.showAndEnable() + itemView.overflow_menu.setOnClickListener { + downloadInteractor.onDeleteSome(setOf(item)) + } + this.item = item } 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 069afb1df..96ef57698 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 @@ -15,6 +15,8 @@ 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 { @@ -43,7 +45,8 @@ class DefaultHistoryController( private val displayDeleteAll: () -> Unit, private val invalidateOptionsMenu: () -> Unit, private val deleteHistoryItems: (Set) -> Unit, - private val syncHistory: suspend () -> Unit + private val syncHistory: suspend () -> Unit, + private val metrics: MetricController ) : HistoryController { override fun handleOpen(item: HistoryItem, mode: BrowsingMode?) { openToBrowser(item, mode) @@ -111,5 +114,6 @@ class DefaultHistoryController( HistoryFragmentDirections.actionGlobalRecentlyClosed(), NavOptions.Builder().setPopUpTo(R.id.recentlyClosedFragment, true).build() ) + metrics.track(Event.RecentlyClosedTabsOpened) } } 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 7f2d49db6..26ebe09eb 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 @@ -92,8 +92,9 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl ::displayDeleteAllDialog, ::invalidateOptionsMenu, ::deleteHistoryItems, - ::syncHistory - ) + ::syncHistory, + requireComponents.analytics.metrics + ) historyInteractor = HistoryInteractor( historyController ) diff --git a/app/src/main/java/org/mozilla/fenix/media/MediaSessionService.kt b/app/src/main/java/org/mozilla/fenix/media/MediaSessionService.kt new file mode 100644 index 000000000..0c08393e1 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/media/MediaSessionService.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.media + +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.media.service.AbstractMediaSessionService +import org.mozilla.fenix.ext.components + +/** + * [AbstractMediaSessionService] implementation for injecting [BrowserStore] singleton. + */ +class MediaSessionService : AbstractMediaSessionService() { + override val store: BrowserStore by lazy { components.core.store } +} diff --git a/app/src/main/java/org/mozilla/fenix/onboarding/FenixOnboarding.kt b/app/src/main/java/org/mozilla/fenix/onboarding/FenixOnboarding.kt index 0af973195..c9cfff8d9 100644 --- a/app/src/main/java/org/mozilla/fenix/onboarding/FenixOnboarding.kt +++ b/app/src/main/java/org/mozilla/fenix/onboarding/FenixOnboarding.kt @@ -6,6 +6,7 @@ package org.mozilla.fenix.onboarding import android.content.Context import android.content.SharedPreferences +import android.os.StrictMode import androidx.annotation.VisibleForTesting import mozilla.components.support.ktx.android.content.PreferencesHolder import mozilla.components.support.ktx.android.content.intPreference @@ -15,10 +16,13 @@ import org.mozilla.fenix.ext.components class FenixOnboarding(context: Context) : PreferencesHolder { private val metrics = context.components.analytics.metrics - override val preferences: SharedPreferences = context.getSharedPreferences( - PREF_NAME_ONBOARDING_KEY, - Context.MODE_PRIVATE - ) + private val strictMode = context.components.strictMode + override val preferences: SharedPreferences = strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { + context.getSharedPreferences( + PREF_NAME_ONBOARDING_KEY, + Context.MODE_PRIVATE + ) + } private var onboardedVersion by intPreference(LAST_VERSION_ONBOARDING_KEY, default = 0) @@ -27,7 +31,11 @@ class FenixOnboarding(context: Context) : PreferencesHolder { metrics.track(Event.DismissedOnboarding) } - fun userHasBeenOnboarded() = onboardedVersion == CURRENT_ONBOARDING_VERSION + fun userHasBeenOnboarded(): Boolean { + return strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { + onboardedVersion == CURRENT_ONBOARDING_VERSION + } + } companion object { /** diff --git a/app/src/main/java/org/mozilla/fenix/push/FirebasePushService.kt b/app/src/main/java/org/mozilla/fenix/push/FirebasePushService.kt index 2c060a2b4..7e03039f4 100644 --- a/app/src/main/java/org/mozilla/fenix/push/FirebasePushService.kt +++ b/app/src/main/java/org/mozilla/fenix/push/FirebasePushService.kt @@ -54,7 +54,7 @@ class FirebasePushService : LeanplumPushFirebaseMessagingService(), super.onNewToken(newToken) } - override fun onMessageReceived(remoteMessage: RemoteMessage?) { + override fun onMessageReceived(remoteMessage: RemoteMessage) { AutoPushService.onMessageReceived(remoteMessage) super.onMessageReceived(remoteMessage) } diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchDialogController.kt b/app/src/main/java/org/mozilla/fenix/search/SearchDialogController.kt index c6ffbf66b..f259b9b51 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchDialogController.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchDialogController.kt @@ -12,9 +12,10 @@ import android.text.SpannableString import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.navigation.NavController -import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.support.ktx.kotlin.isUrl import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity @@ -22,7 +23,6 @@ import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricsUtils -import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore import org.mozilla.fenix.crashes.CrashListActivity import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.settings.SupportUtils @@ -50,12 +50,14 @@ interface SearchController { class SearchDialogController( private val activity: HomeActivity, private val sessionManager: SessionManager, - private val store: SearchFragmentStore, + private val store: BrowserStore, + private val fragmentStore: SearchFragmentStore, private val navController: NavController, private val settings: Settings, private val metrics: MetricController, private val dismissDialog: () -> Unit, - private val clearToolbarFocus: () -> Unit + private val clearToolbarFocus: () -> Unit, + private val focusToolbar: () -> Unit ) : SearchController { override fun handleUrlCommitted(url: String) { @@ -81,25 +83,29 @@ class SearchDialogController( } private fun openSearchOrUrl(url: String) { + clearToolbarFocus() + + val searchEngine = fragmentStore.state.searchEngineSource.searchEngine + activity.openToBrowserAndLoad( searchTermOrURL = url, - newTab = store.state.tabId == null, + newTab = fragmentStore.state.tabId == null, from = BrowserDirection.FromSearchDialog, - engine = store.state.searchEngineSource.searchEngine + engine = searchEngine ) - val event = if (url.isUrl()) { + val event = if (url.isUrl() || searchEngine == null) { Event.EnteredUrl(false) } else { - val searchAccessPoint = when (store.state.searchAccessPoint) { + val searchAccessPoint = when (fragmentStore.state.searchAccessPoint) { Event.PerformedSearch.SearchAccessPoint.NONE -> Event.PerformedSearch.SearchAccessPoint.ACTION - else -> store.state.searchAccessPoint + else -> fragmentStore.state.searchAccessPoint } searchAccessPoint?.let { sap -> MetricsUtils.createSearchEvent( - store.state.searchEngineSource.searchEngine, - activity, + searchEngine, + store, sap ) } @@ -114,17 +120,17 @@ class SearchDialogController( override fun handleTextChanged(text: String) { // Display the search shortcuts on each entry of the search fragment (see #5308) - val textMatchesCurrentUrl = store.state.url == text - val textMatchesCurrentSearch = store.state.searchTerms == text + val textMatchesCurrentUrl = fragmentStore.state.url == text + val textMatchesCurrentSearch = fragmentStore.state.searchTerms == text - store.dispatch(SearchFragmentAction.UpdateQuery(text)) - store.dispatch( + fragmentStore.dispatch(SearchFragmentAction.UpdateQuery(text)) + fragmentStore.dispatch( SearchFragmentAction.ShowSearchShortcutEnginePicker( (textMatchesCurrentUrl || textMatchesCurrentSearch || text.isEmpty()) && settings.shouldShowSearchShortcuts ) ) - store.dispatch( + fragmentStore.dispatch( SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt( text.isNotEmpty() && activity.browsingModeManager.mode.isPrivate && @@ -139,7 +145,7 @@ class SearchDialogController( activity.openToBrowserAndLoad( searchTermOrURL = url, - newTab = store.state.tabId == null, + newTab = fragmentStore.state.tabId == null, from = BrowserDirection.FromSearchDialog ) @@ -149,39 +155,42 @@ class SearchDialogController( override fun handleSearchTermsTapped(searchTerms: String) { clearToolbarFocus() + val searchEngine = fragmentStore.state.searchEngineSource.searchEngine + activity.openToBrowserAndLoad( searchTermOrURL = searchTerms, - newTab = store.state.tabId == null, + newTab = fragmentStore.state.tabId == null, from = BrowserDirection.FromSearchDialog, - engine = store.state.searchEngineSource.searchEngine, + engine = searchEngine, forceSearch = true ) - val searchAccessPoint = when (store.state.searchAccessPoint) { + val searchAccessPoint = when (fragmentStore.state.searchAccessPoint) { Event.PerformedSearch.SearchAccessPoint.NONE -> Event.PerformedSearch.SearchAccessPoint.SUGGESTION - else -> store.state.searchAccessPoint + else -> fragmentStore.state.searchAccessPoint } - val event = searchAccessPoint?.let { sap -> + if (searchAccessPoint != null && searchEngine != null) { MetricsUtils.createSearchEvent( - store.state.searchEngineSource.searchEngine, - activity, - sap - ) + searchEngine, + store, + searchAccessPoint + )?.apply { + metrics.track(this) + } } - event?.let { metrics.track(it) } } override fun handleSearchShortcutEngineSelected(searchEngine: SearchEngine) { - store.dispatch(SearchFragmentAction.SearchShortcutEngineSelected(searchEngine)) - val isCustom = - CustomSearchEngineStore.isCustomSearchEngine(activity, searchEngine.identifier) + focusToolbar() + fragmentStore.dispatch(SearchFragmentAction.SearchShortcutEngineSelected(searchEngine)) + val isCustom = searchEngine.type == SearchEngine.Type.CUSTOM metrics.track(Event.SearchShortcutSelected(searchEngine, isCustom)) } override fun handleSearchShortcutsButtonClicked() { - val isOpen = store.state.showSearchShortcuts - store.dispatch(SearchFragmentAction.ShowSearchShortcutEnginePicker(!isOpen)) + val isOpen = fragmentStore.state.showSearchShortcuts + fragmentStore.dispatch(SearchFragmentAction.ShowSearchShortcutEnginePicker(!isOpen)) } override fun handleClickSearchEngineSettings() { 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 b670be096..8ef26cde8 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchDialogFragment.kt @@ -10,10 +10,11 @@ import android.app.Dialog import android.content.Context import android.content.DialogInterface import android.content.Intent +import android.graphics.Color import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable import android.os.Build import android.os.Bundle -import android.os.StrictMode import android.speech.RecognizerIntent import android.text.style.StyleSpan import android.view.LayoutInflater @@ -22,6 +23,7 @@ import android.view.ViewGroup import android.view.ViewStub import android.view.WindowManager import android.view.accessibility.AccessibilityEvent +import android.view.inputmethod.InputMethodManager import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.content.res.AppCompatResources @@ -37,10 +39,13 @@ 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.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.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper @@ -49,28 +54,26 @@ import mozilla.components.support.ktx.android.content.hasCamera import mozilla.components.support.ktx.android.content.isPermissionGranted import mozilla.components.support.ktx.android.content.res.getSpanned import mozilla.components.support.ktx.android.view.hideKeyboard +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import mozilla.components.ui.autocomplete.InlineAutocompleteEditText 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.searchengine.CustomSearchEngineStore -import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider import org.mozilla.fenix.components.toolbar.ToolbarPosition import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.isKeyboardVisible import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings import org.mozilla.fenix.search.awesomebar.AwesomeBarView import org.mozilla.fenix.search.toolbar.ToolbarView import org.mozilla.fenix.settings.SupportUtils -import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener import org.mozilla.fenix.widget.VoiceSearchActivity typealias SearchDialogFragmentStore = SearchFragmentStore @SuppressWarnings("LargeClass", "TooManyFunctions") class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { + private var voiceSearchButtonAlreadyAdded: Boolean = false private lateinit var interactor: SearchDialogInteractor private lateinit var store: SearchDialogFragmentStore private lateinit var toolbarView: ToolbarView @@ -79,8 +82,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { private val qrFeature = ViewBoundFeatureWrapper() private val speechIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) - - private var keyboardVisible: Boolean = false + private var dialogHandledAction = false override fun onStart() { super.onStart() @@ -88,7 +90,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { // To prevent GeckoView from resizing we're going to change the softInputMode to not adjust // the size of the window. requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) - if (keyboardVisible) { + // Refocus the toolbar editing and show keyboard if the QR fragment isn't showing + if (childFragmentManager.findFragmentByTag(QR_FRAGMENT_TAG) == null) { toolbarView.view.edit.focus() } } @@ -98,7 +101,6 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { // https://github.com/mozilla-mobile/fenix/issues/14279 // Let's reset back to the default behavior after we're done searching requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) - keyboardVisible = toolbarView.view.isKeyboardVisible() } override fun onCreate(savedInstanceState: Bundle?) { @@ -140,15 +142,21 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { SearchDialogController( activity = activity, sessionManager = requireComponents.core.sessionManager, - store = store, + store = requireComponents.core.store, + fragmentStore = store, navController = findNavController(), settings = requireContext().settings(), metrics = requireComponents.analytics.metrics, - dismissDialog = { dismissAllowingStateLoss() }, + dismissDialog = { + dialogHandledAction = true + dismissAllowingStateLoss() + }, clearToolbarFocus = { - toolbarView.view.hideKeyboardAndSave() + dialogHandledAction = true + toolbarView.view.hideKeyboard() toolbarView.view.clearFocus() - } + }, + focusToolbar = { toolbarView.view.edit.focus() } ) ) @@ -159,19 +167,19 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { isPrivate, view.toolbar, requireComponents.core.engine - ).also(::addSearchButton) + ) + + val awesomeBar = view.awesome_bar + awesomeBar.customizeForBottomToolbar = requireContext().settings().shouldUseBottomToolbar awesomeBarView = AwesomeBarView( activity, interactor, - view.awesome_bar + awesomeBar ) - setShortcutsChangedListener(CustomSearchEngineStore.PREF_FILE_SEARCH_ENGINES) - setShortcutsChangedListener(FenixSearchEngineProvider.PREF_FILE_SEARCH_ENGINES) - view.awesome_bar.setOnTouchListener { _, _ -> - view.hideKeyboardAndSave() + view.hideKeyboard() false } @@ -183,6 +191,15 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { requireComponents.core.engine.speculativeCreateSession(isPrivate) + if (findNavController().previousBackStackEntry?.destination?.id == R.id.homeFragment) { + // When displayed above home, dispatches the touch events to scrim area to the HomeFragment + view.search_wrapper.background = ColorDrawable(Color.TRANSPARENT) + dialog?.window?.decorView?.setOnTouchListener { _, event -> + requireActivity().dispatchTouchEvent(event) + false + } + } + return view } @@ -191,11 +208,22 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + consumeFlow(requireComponents.core.store) { flow -> + flow.map { state -> state.search } + .ifChanged() + .collect { search -> + store.dispatch(SearchFragmentAction.UpdateSearchState(search)) + } + } + setupConstraints(view) - search_wrapper.setOnClickListener { - it.hideKeyboardAndSave() - dismissAllowingStateLoss() + // When displayed above browser, dismisses dialog on clicking scrim area + if (findNavController().previousBackStackEntry?.destination?.id == R.id.browserFragment) { + search_wrapper.setOnClickListener { + it.hideKeyboard() + dismissAllowingStateLoss() + } } view.search_engines_shortcut_button.setOnClickListener { @@ -298,6 +326,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { toolbarView.update(it) awesomeBarView.update(it) firstUpdate = false + addVoiceSearchButton(it) } } @@ -314,17 +343,27 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { } } - override fun onResume() { - super.onResume() - resetFocus() - toolbarView.view.edit.focus() - } - override fun onPause() { super.onPause() - qr_scan_button.isChecked = false view?.hideKeyboard() - toolbarView.view.requestFocus() + } + + /* + * 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. + */ + private fun hideDeviceKeyboard() { + // If the interactor/controller has handled a search event itself, it will hide the keyboard. + if (!dialogHandledAction) { + val imm = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.toggleSoftInput(InputMethodManager.HIDE_IMPLICIT_ONLY, 0) + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + hideDeviceKeyboard() } override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { @@ -344,7 +383,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { true } else -> { - view?.hideKeyboardAndSave() + view?.hideKeyboard() dismissAllowingStateLoss() true } @@ -431,6 +470,9 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { clear(fill_link_from_clipboard.id, TOP) connect(fill_link_from_clipboard.id, BOTTOM, pill_wrapper.id, TOP) + clear(fill_link_divider.id, TOP) + connect(fill_link_divider.id, BOTTOM, fill_link_from_clipboard.id, TOP) + applyTo(search_wrapper) } } @@ -444,28 +486,26 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { } } - private fun addSearchButton(toolbarView: ToolbarView) { - toolbarView.view.addEditAction( - BrowserToolbar.Button( - AppCompatResources.getDrawable(requireContext(), R.drawable.ic_microphone)!!, - requireContext().getString(R.string.voice_search_content_description), - visible = { - store.state.searchEngineSource.searchEngine.identifier.contains("google") && - isSpeechAvailable() && - requireContext().settings().shouldShowVoiceSearch - }, - listener = ::launchVoiceSearch + private fun addVoiceSearchButton(searchFragmentState: SearchFragmentState) { + if (voiceSearchButtonAlreadyAdded) return + val searchEngine = searchFragmentState.searchEngineSource.searchEngine + + val isVisible = + searchEngine?.id?.contains("google") == true && + isSpeechAvailable() && + requireContext().settings().shouldShowVoiceSearch + + if (isVisible) { + toolbarView.view.addEditAction( + BrowserToolbar.Button( + AppCompatResources.getDrawable(requireContext(), R.drawable.ic_microphone)!!, + requireContext().getString(R.string.voice_search_content_description), + visible = { true }, + listener = ::launchVoiceSearch + ) ) - ) - } - - /** - * Used to save keyboard status on stop/sleep, to be restored later. - * See #14559 - * */ - private fun View.hideKeyboardAndSave() { - keyboardVisible = false - this.hideKeyboard() + voiceSearchButtonAlreadyAdded = true + } } private fun launchVoiceSearch() { @@ -485,25 +525,23 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { private fun isSpeechAvailable(): Boolean = speechIntent.resolveActivity(requireContext().packageManager) != null - private fun setShortcutsChangedListener(preferenceFileName: String) { - requireComponents.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) { - requireContext().getSharedPreferences( - preferenceFileName, - Context.MODE_PRIVATE - ).registerOnSharedPreferenceChangeListener(viewLifecycleOwner) { _, _ -> - awesomeBarView.update(store.state) - } - } - } - private fun updateClipboardSuggestion(searchState: SearchFragmentState, clipboardUrl: String?) { val shouldShowView = searchState.showClipboardSuggestions && searchState.query.isEmpty() && !clipboardUrl.isNullOrEmpty() - fill_link_from_clipboard.visibility = if (shouldShowView) View.VISIBLE else View.GONE + fill_link_from_clipboard.isVisible = shouldShowView + fill_link_divider.isVisible = shouldShowView + pill_wrapper_divider.isVisible = + !(shouldShowView && requireComponents.settings.shouldUseBottomToolbar) + clipboard_url.isVisible = shouldShowView + clipboard_title.isVisible = shouldShowView + link_icon.isVisible = shouldShowView + clipboard_url.text = clipboardUrl + fill_link_from_clipboard.contentDescription = "${clipboard_title.text}, ${clipboard_url.text}." + if (clipboardUrl != null && !((activity as HomeActivity).browsingModeManager.mode.isPrivate)) { requireComponents.core.engine.speculativeConnect(clipboardUrl) } @@ -512,8 +550,11 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { private fun updateToolbarContentDescription(searchState: SearchFragmentState) { val urlView = toolbarView.view .findViewById(R.id.mozac_browser_toolbar_edit_url_view) - toolbarView.view.contentDescription = - searchState.searchEngineSource.searchEngine.name + ", " + urlView.hint + + searchState.searchEngineSource.searchEngine?.let { engine -> + toolbarView.view.contentDescription = engine.name + ", " + urlView.hint + } + urlView?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO } @@ -532,6 +573,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { } companion object { + private const val QR_FRAGMENT_TAG = "MOZAC_QR_FRAGMENT" private const val REQUEST_CODE_CAMERA_PERMISSIONS = 1 } } diff --git a/app/src/main/java/org/mozilla/fenix/search/SearchDialogInteractor.kt b/app/src/main/java/org/mozilla/fenix/search/SearchDialogInteractor.kt index cd35bdbce..cb7385ec7 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchDialogInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchDialogInteractor.kt @@ -4,8 +4,8 @@ package org.mozilla.fenix.search -import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.session.Session +import mozilla.components.browser.state.search.SearchEngine import org.mozilla.fenix.search.awesomebar.AwesomeBarInteractor import org.mozilla.fenix.search.toolbar.ToolbarInteractor 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 e55814e21..1c6570fe9 100644 --- a/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt @@ -4,8 +4,11 @@ package org.mozilla.fenix.search -import mozilla.components.browser.search.SearchEngine +import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.state.SearchState +import mozilla.components.browser.state.state.searchEngines +import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine import mozilla.components.lib.state.Action import mozilla.components.lib.state.State import mozilla.components.lib.state.Store @@ -13,7 +16,6 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.search.ext.areShortcutsAvailable /** * The [Store] for holding the [SearchFragmentState] and applying [SearchFragmentAction]s. @@ -29,7 +31,11 @@ class SearchFragmentStore( * Wraps a `SearchEngine` to give consumers the context that it was selected as a shortcut */ sealed class SearchEngineSource { - abstract val searchEngine: SearchEngine + abstract val searchEngine: SearchEngine? + + object None : SearchEngineSource() { + override val searchEngine: SearchEngine? = null + } data class Default(override val searchEngine: SearchEngine) : SearchEngineSource() data class Shortcut(override val searchEngine: SearchEngine) : SearchEngineSource() @@ -37,17 +43,20 @@ sealed class SearchEngineSource { /** * The state for the Search Screen + * * @property query The current search query string * @property url The current URL of the tab (if this fragment is shown for an already existing tab) * @property searchTerms The search terms used to search previously in this tab (if this fragment is shown * for an already existing tab) * @property searchEngineSource The current selected search engine with the context of how it was selected - * @property defaultEngineSource The current default search engine source + * @property defaultEngine The current default search engine (or null if none is available yet) * @property showSearchSuggestions Whether or not to show search suggestions from the search engine in the AwesomeBar * @property showSearchSuggestionsHint Whether or not to show search suggestions in private hint panel * @property showSearchShortcuts Whether or not to show search shortcuts in the AwesomeBar * @property areShortcutsAvailable Whether or not there are >=2 search engines installed - * so to know to present users with certain options or not. + * so to know to present users with certain options or not. + * @property showSearchShortcutsSetting Whether the setting for showing search shortcuts is enabled + * or disabled. * @property showClipboardSuggestions Whether or not to show clipboard suggestion in the AwesomeBar * @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 @@ -58,11 +67,12 @@ data class SearchFragmentState( val url: String, val searchTerms: String, val searchEngineSource: SearchEngineSource, - val defaultEngineSource: SearchEngineSource.Default, + val defaultEngine: SearchEngine?, val showSearchSuggestions: Boolean, val showSearchSuggestionsHint: Boolean, val showSearchShortcuts: Boolean, val areShortcutsAvailable: Boolean, + val showSearchShortcutsSetting: Boolean, val showClipboardSuggestions: Boolean, val showHistorySuggestions: Boolean, val showBookmarkSuggestions: Boolean, @@ -81,16 +91,9 @@ fun createInitialSearchFragmentState( ): SearchFragmentState { val settings = components.settings val tab = tabId?.let { components.core.store.state.findTab(it) } - val url = tab?.content?.url.orEmpty() - val currentSearchEngine = SearchEngineSource.Default( - components.search.provider.getDefaultEngine(activity) - ) - val browsingMode = activity.browsingModeManager.mode - val areShortcutsAvailable = components.search.provider.areShortcutsAvailable(activity) - - val shouldShowSearchSuggestions = when (browsingMode) { + val shouldShowSearchSuggestions = when (activity.browsingModeManager.mode) { BrowsingMode.Normal -> settings.shouldShowSearchSuggestions BrowsingMode.Private -> settings.shouldShowSearchSuggestions && settings.shouldShowSearchSuggestionsInPrivate @@ -100,14 +103,13 @@ fun createInitialSearchFragmentState( query = url, url = url, searchTerms = tab?.content?.searchTerms.orEmpty(), - searchEngineSource = currentSearchEngine, - defaultEngineSource = currentSearchEngine, + searchEngineSource = SearchEngineSource.None, + defaultEngine = null, showSearchSuggestions = shouldShowSearchSuggestions, showSearchSuggestionsHint = false, - showSearchShortcuts = url.isEmpty() && - areShortcutsAvailable && - settings.shouldShowSearchShortcuts, - areShortcutsAvailable = areShortcutsAvailable, + showSearchShortcuts = false, + areShortcutsAvailable = false, + showSearchShortcutsSetting = settings.shouldShowSearchShortcuts, showClipboardSuggestions = settings.shouldShowClipboardSuggestions, showHistorySuggestions = settings.shouldShowHistorySuggestions, showBookmarkSuggestions = settings.shouldShowBookmarkSuggestions, @@ -124,11 +126,14 @@ fun createInitialSearchFragmentState( sealed class SearchFragmentAction : Action { data class SetShowSearchSuggestions(val show: Boolean) : SearchFragmentAction() data class SearchShortcutEngineSelected(val engine: SearchEngine) : SearchFragmentAction() - data class SelectNewDefaultSearchEngine(val engine: SearchEngine) : SearchFragmentAction() data class ShowSearchShortcutEnginePicker(val show: Boolean) : SearchFragmentAction() - data class UpdateShortcutsAvailability(val areShortcutsAvailable: Boolean) : SearchFragmentAction() data class AllowSearchSuggestionsInPrivateModePrompt(val show: Boolean) : SearchFragmentAction() data class UpdateQuery(val query: String) : SearchFragmentAction() + + /** + * Updates the local `SearchFragmentState` from the global `SearchState` in `BrowserStore`. + */ + data class UpdateSearchState(val search: SearchState) : SearchFragmentAction() } /** @@ -143,15 +148,26 @@ private fun searchStateReducer(state: SearchFragmentState, action: SearchFragmen ) is SearchFragmentAction.ShowSearchShortcutEnginePicker -> state.copy(showSearchShortcuts = action.show && state.areShortcutsAvailable) - is SearchFragmentAction.UpdateShortcutsAvailability -> - state.copy(areShortcutsAvailable = action.areShortcutsAvailable) is SearchFragmentAction.UpdateQuery -> state.copy(query = action.query) - is SearchFragmentAction.SelectNewDefaultSearchEngine -> - state.copy(searchEngineSource = SearchEngineSource.Default(action.engine)) is SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt -> state.copy(showSearchSuggestionsHint = action.show) is SearchFragmentAction.SetShowSearchSuggestions -> state.copy(showSearchSuggestions = action.show) + is SearchFragmentAction.UpdateSearchState -> { + state.copy( + defaultEngine = action.search.selectedOrDefaultSearchEngine, + areShortcutsAvailable = action.search.searchEngines.size > 1, + showSearchShortcuts = state.url.isEmpty() && + state.showSearchShortcutsSetting && + action.search.searchEngines.size > 1, + searchEngineSource = if (state.searchEngineSource !is SearchEngineSource.Shortcut) { + action.search.selectedOrDefaultSearchEngine?.let { SearchEngineSource.Default(it) } + ?: SearchEngineSource.None + } else { + state.searchEngineSource + } + ) + } } } diff --git a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarInteractor.kt b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarInteractor.kt index e0dac77d0..e8f18cb7e 100644 --- a/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarInteractor.kt @@ -4,8 +4,8 @@ package org.mozilla.fenix.search.awesomebar -import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.session.Session +import mozilla.components.browser.state.search.SearchEngine /** * Interface for the AwesomeBarView Interactor. This interface is implemented by objects that want 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 69ea1b7a1..541711b35 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 @@ -10,8 +10,8 @@ import androidx.core.graphics.BlendModeCompat.SRC_IN import androidx.core.graphics.drawable.toBitmap import mozilla.components.browser.awesomebar.BrowserAwesomeBar import mozilla.components.browser.search.DefaultSearchEngineProvider -import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.session.Session +import mozilla.components.browser.state.search.SearchEngine import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.concept.engine.EngineSession import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider @@ -20,9 +20,10 @@ import mozilla.components.feature.awesomebar.provider.SearchActionProvider import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider import mozilla.components.feature.search.SearchUseCases -import mozilla.components.browser.search.ext.toDefaultSearchEngineProvider -import mozilla.components.feature.syncedtabs.DeviceIndicators +import mozilla.components.feature.search.ext.legacy +import mozilla.components.feature.search.ext.toDefaultSearchEngineProvider import mozilla.components.feature.session.SessionUseCases +import mozilla.components.feature.syncedtabs.DeviceIndicators import mozilla.components.feature.syncedtabs.SyncedTabsStorageSuggestionProvider import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.support.ktx.android.content.getColorFromAttr @@ -32,6 +33,7 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.ext.components import org.mozilla.fenix.search.SearchEngineSource import org.mozilla.fenix.search.SearchFragmentState +import mozilla.components.browser.search.SearchEngine as LegacySearchEngine /** * View that contains and configures the BrowserAwesomeBar @@ -65,8 +67,8 @@ class AwesomeBarView( private val searchUseCase = object : SearchUseCases.SearchUseCase { override fun invoke( searchTerms: String, - searchEngine: SearchEngine?, - parentSession: Session? + searchEngine: mozilla.components.browser.search.SearchEngine?, + parentSessionId: String? ) { interactor.onSearchTermsTapped(searchTerms) } @@ -75,8 +77,8 @@ class AwesomeBarView( private val shortcutSearchUseCase = object : SearchUseCases.SearchUseCase { override fun invoke( searchTerms: String, - searchEngine: SearchEngine?, - parentSession: Session? + searchEngine: mozilla.components.browser.search.SearchEngine?, + parentSessionId: String? ) { interactor.onSearchTermsTapped(searchTerms) } @@ -148,9 +150,7 @@ class AwesomeBarView( defaultSearchSuggestionProvider = SearchSuggestionProvider( context = activity, - defaultSearchEngineProvider = components.search.searchEngineManager.toDefaultSearchEngineProvider( - activity - ), + defaultSearchEngineProvider = components.core.store.toDefaultSearchEngineProvider(), searchUseCase = searchUseCase, fetchClient = components.core.client, mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS, @@ -163,9 +163,7 @@ class AwesomeBarView( defaultSearchActionProvider = SearchActionProvider( - defaultSearchEngineProvider = components.search.searchEngineManager.toDefaultSearchEngineProvider( - activity - ), + defaultSearchEngineProvider = components.core.store.toDefaultSearchEngineProvider(), searchUseCase = searchUseCase, icon = searchBitmap, showDescription = false @@ -173,7 +171,7 @@ class AwesomeBarView( shortcutsEnginePickerProvider = ShortcutsSuggestionProvider( - searchEngineProvider = components.search.provider, + store = components.core.store, context = activity, selectShortcutEngine = interactor::onSearchShortcutEngineSelected, selectShortcutEngineSettings = interactor::onClickSearchEngineSettings @@ -288,6 +286,7 @@ class AwesomeBarView( is SearchEngineSource.Shortcut -> getSuggestionProviderForEngine( state.searchEngineSource.searchEngine ) + is SearchEngineSource.None -> emptyList() } } @@ -311,22 +310,20 @@ class AwesomeBarView( BrowsingMode.Normal -> components.core.engine BrowsingMode.Private -> null } - val searchEngine = - components.search.provider.installedSearchEngines(activity).list.find { it.name == engine.name } - ?: components.search.provider.getDefaultEngine(activity) listOf( SearchActionProvider( defaultSearchEngineProvider = object : DefaultSearchEngineProvider { - override fun getDefaultSearchEngine(): SearchEngine? = searchEngine - override suspend fun retrieveDefaultSearchEngine(): SearchEngine? = - searchEngine + override fun getDefaultSearchEngine(): LegacySearchEngine? = + engine.legacy() + override suspend fun retrieveDefaultSearchEngine(): LegacySearchEngine? = + engine.legacy() }, searchUseCase = shortcutSearchUseCase, icon = searchBitmap ), SearchSuggestionProvider( - searchEngine, + engine.legacy(), shortcutSearchUseCase, components.core.client, limit = 3, 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 9bba4db2b..039b2bf24 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 @@ -7,17 +7,18 @@ package org.mozilla.fenix.search.awesomebar import android.content.Context import androidx.appcompat.content.res.AppCompatResources import androidx.core.graphics.drawable.toBitmap -import mozilla.components.browser.search.SearchEngine +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 org.mozilla.fenix.R -import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider import java.util.UUID /** * A [AwesomeBar.SuggestionProvider] implementation that provides search engine suggestions. */ class ShortcutsSuggestionProvider( - private val searchEngineProvider: FenixSearchEngineProvider, + private val store: BrowserStore, private val context: Context, private val selectShortcutEngine: (engine: SearchEngine) -> Unit, private val selectShortcutEngineSettings: () -> Unit @@ -34,10 +35,10 @@ class ShortcutsSuggestionProvider( override suspend fun onInputChanged(text: String): List { val suggestions = mutableListOf() - searchEngineProvider.installedSearchEngines(context).list.mapTo(suggestions) { + store.state.search.searchEngines.mapTo(suggestions) { AwesomeBar.Suggestion( provider = this, - id = it.identifier, + id = it.id, icon = it.icon, title = it.name, onSuggestionClicked = { diff --git a/app/src/main/java/org/mozilla/fenix/search/ext/SearchEngineProvider.kt b/app/src/main/java/org/mozilla/fenix/search/ext/SearchEngineProvider.kt deleted file mode 100644 index cbb598fd8..000000000 --- a/app/src/main/java/org/mozilla/fenix/search/ext/SearchEngineProvider.kt +++ /dev/null @@ -1,17 +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.search.ext - -import android.content.Context -import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider - -private const val MINIMUM_SEARCH_ENGINES_NUMBER_TO_SHOW_SHORTCUTS = 2 - -/** - * Return if the user has *at least 2* installed search engines. - * Useful to decide whether to show / enable certain functionalities. - */ -fun FenixSearchEngineProvider.areShortcutsAvailable(context: Context) = - installedSearchEngines(context).list.size >= MINIMUM_SEARCH_ENGINES_NUMBER_TO_SHOW_SHORTCUTS diff --git a/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt b/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt index 037c08708..dea282f36 100644 --- a/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt +++ b/app/src/main/java/org/mozilla/fenix/search/toolbar/ToolbarView.kt @@ -148,18 +148,22 @@ class ToolbarView( isInitialized = true } - val iconSize = - context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size) + val searchEngine = searchState.searchEngineSource.searchEngine - val scaledIcon = Bitmap.createScaledBitmap( - searchState.searchEngineSource.searchEngine.icon, - iconSize, - iconSize, - true - ) + if (searchEngine != null) { + val iconSize = + context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size) - val icon = BitmapDrawable(context.resources, scaledIcon) + val scaledIcon = Bitmap.createScaledBitmap( + searchEngine.icon, + iconSize, + iconSize, + true + ) + + val icon = BitmapDrawable(context.resources, scaledIcon) - view.edit.setIcon(icon, searchState.searchEngineSource.searchEngine.name) + view.edit.setIcon(icon, searchEngine.name) + } } } 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 be6f1a274..03f7d5deb 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt @@ -7,7 +7,7 @@ package org.mozilla.fenix.settings import android.os.Bundle import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreference -import org.mozilla.fenix.Config +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.MetricServiceType import org.mozilla.fenix.ext.components @@ -37,6 +37,9 @@ class DataChoicesFragment : PreferenceFragmentCompat() { } else { context.components.analytics.metrics.stop(MetricServiceType.Marketing) } + } else if (key == getPreferenceKey(R.string.pref_key_experimentation)) { + val enabled = context.settings().isExperimentationEnabled + context.components.analytics.experiments.globalUserParticipation = enabled } } } @@ -72,7 +75,7 @@ class DataChoicesFragment : PreferenceFragmentCompat() { requirePreference(R.string.pref_key_experimentation).apply { isChecked = context.settings().isExperimentationEnabled - isVisible = Config.channel.isReleaseOrBeta + isVisible = FeatureFlags.nimbusExperiments onPreferenceChangeListener = SharedPreferenceUpdater() } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/Extensions.kt b/app/src/main/java/org/mozilla/fenix/settings/Extensions.kt index b26446185..2ede851aa 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/Extensions.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/Extensions.kt @@ -26,6 +26,7 @@ fun SitePermissions.get(field: PhoneFeature) = when (field) { PhoneFeature.AUTOPLAY_AUDIBLE -> autoplayAudible PhoneFeature.AUTOPLAY_INAUDIBLE -> autoplayInaudible PhoneFeature.PERSISTENT_STORAGE -> localStorage + PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS -> mediaKeySystemAccess } fun SitePermissions.update(field: PhoneFeature, value: SitePermissions.Status) = when (field) { @@ -36,6 +37,7 @@ fun SitePermissions.update(field: PhoneFeature, value: SitePermissions.Status) = PhoneFeature.AUTOPLAY_AUDIBLE -> copy(autoplayAudible = value) PhoneFeature.AUTOPLAY_INAUDIBLE -> copy(autoplayInaudible = value) PhoneFeature.PERSISTENT_STORAGE -> copy(localStorage = value) + PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS -> copy(mediaKeySystemAccess = value) } /** 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 31ca24ecb..46e9d13e1 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/PhoneFeature.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/PhoneFeature.kt @@ -31,7 +31,8 @@ enum class PhoneFeature(val androidPermissionsList: Array) : Parcelable NOTIFICATION(emptyArray()), AUTOPLAY_AUDIBLE(emptyArray()), AUTOPLAY_INAUDIBLE(emptyArray()), - PERSISTENT_STORAGE(emptyArray()); + PERSISTENT_STORAGE(emptyArray()), + MEDIA_KEY_SYSTEM_ACCESS(emptyArray()); fun isAndroidPermissionGranted(context: Context): Boolean { return context.isPermissionGranted(androidPermissionsList.asIterable()) @@ -80,6 +81,7 @@ enum class PhoneFeature(val androidPermissionsList: Array) : Parcelable MICROPHONE -> context.getString(R.string.preference_phone_feature_microphone) NOTIFICATION -> context.getString(R.string.preference_phone_feature_notification) PERSISTENT_STORAGE -> context.getString(R.string.preference_phone_feature_persistent_storage) + MEDIA_KEY_SYSTEM_ACCESS -> context.getString(R.string.preference_phone_feature_media_key_system_access) AUTOPLAY_AUDIBLE, AUTOPLAY_INAUDIBLE -> context.getString(R.string.preference_browser_feature_autoplay) } } @@ -98,6 +100,7 @@ enum class PhoneFeature(val androidPermissionsList: Array) : Parcelable AUTOPLAY_AUDIBLE -> R.string.pref_key_browser_feature_autoplay_audible AUTOPLAY_INAUDIBLE -> R.string.pref_key_browser_feature_autoplay_inaudible PERSISTENT_STORAGE -> R.string.pref_key_browser_feature_persistent_storage + MEDIA_KEY_SYSTEM_ACCESS -> R.string.pref_key_browser_feature_media_key_system_access } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/RadioButtonPreference.kt b/app/src/main/java/org/mozilla/fenix/settings/RadioButtonPreference.kt index 999025216..b99be769a 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/RadioButtonPreference.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/RadioButtonPreference.kt @@ -19,7 +19,7 @@ import org.mozilla.fenix.ext.settings import org.mozilla.fenix.utils.view.GroupableRadioButton import org.mozilla.fenix.utils.view.uncheckAll -@Suppress("RestrictedApi", "PrivateResource") +@Suppress("RestrictedApi") open class RadioButtonPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null @@ -37,7 +37,7 @@ open class RadioButtonPreference @JvmOverloads constructor( context.withStyledAttributes( attrs, - androidx.preference.R.styleable.Preference, + R.styleable.RadioButtonPreference, getAttr( context, androidx.preference.R.attr.preferenceStyle, @@ -46,13 +46,10 @@ open class RadioButtonPreference @JvmOverloads constructor( 0 ) { defaultValue = when { - hasValue(androidx.preference.R.styleable.Preference_defaultValue) -> - getBoolean(androidx.preference.R.styleable.Preference_defaultValue, false) - hasValue(androidx.preference.R.styleable.Preference_android_defaultValue) -> - getBoolean( - androidx.preference.R.styleable.Preference_android_defaultValue, - false - ) + hasValue(R.styleable.RadioButtonPreference_defaultValue) -> + getBoolean(R.styleable.RadioButtonPreference_defaultValue, false) + hasValue(R.styleable.RadioButtonPreference_android_defaultValue) -> + getBoolean(R.styleable.RadioButtonPreference_android_defaultValue, false) else -> false } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/SecretDebugSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SecretDebugSettingsFragment.kt new file mode 100644 index 000000000..bedc939e6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/SecretDebugSettingsFragment.kt @@ -0,0 +1,55 @@ +/* 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 + +import android.os.Bundle +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.leanplum.Leanplum +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.showToolbar + +class SecretDebugSettingsFragment : PreferenceFragmentCompat() { + + override fun onResume() { + super.onResume() + showToolbar(getString(R.string.preferences_debug_info)) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.secret_info_settings_preferences, rootKey) + + val store = requireComponents.core.store + + requirePreference(R.string.pref_key_leanplum_user_id).apply { + summary = Leanplum.getUserId().let { + if (it.isNullOrEmpty()) { + "No User Id" + } else { + it + } + } + } + + requirePreference(R.string.pref_key_leanplum_device_id).apply { + summary = Leanplum.getDeviceId().let { + if (it.isNullOrEmpty()) { + "No Device Id" + } else { + it + } + } + } + + requirePreference(R.string.pref_key_search_region_home).apply { + summary = store.state.search.region?.home ?: "Unknown" + } + + requirePreference(R.string.pref_key_search_region_current).apply { + summary = store.state.search.region?.current ?: "Unknown" + } + } +} 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 e3fc88124..d63bfeee0 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt @@ -12,9 +12,6 @@ import org.mozilla.fenix.R import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar -/** - * Lets the user customize Private browsing options. - */ class SecretSettingsFragment : PreferenceFragmentCompat() { override fun onResume() { 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 2a55d96b1..9a8758aca 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -310,6 +310,9 @@ class SettingsFragment : PreferenceFragmentCompat() { resources.getString(R.string.pref_key_debug_settings) -> { SettingsFragmentDirections.actionSettingsFragmentToSecretSettingsFragment() } + resources.getString(R.string.pref_key_secret_debug_info) -> { + SettingsFragmentDirections.actionSettingsFragmentToSecretInfoSettingsFragment() + } resources.getString(R.string.pref_key_override_amo_collection) -> { val context = requireContext() val dialogView = LayoutInflater.from(context).inflate(R.layout.amo_collection_override_dialog, null) @@ -416,6 +419,9 @@ class SettingsFragment : PreferenceFragmentCompat() { findPreference( getPreferenceKey(R.string.pref_key_debug_settings) )?.isVisible = requireContext().settings().showSecretDebugMenuThisSession + findPreference( + getPreferenceKey(R.string.pref_key_secret_debug_info) + )?.isVisible = requireContext().settings().showSecretDebugMenuThisSession setupAmoCollectionOverridePreference(requireContext().settings()) } 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 fe5457dd7..c507489cc 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt @@ -38,13 +38,13 @@ object SupportUtils { HELP("faq-android"), PRIVATE_BROWSING_MYTHS("common-myths-about-private-browsing"), YOUR_RIGHTS("your-rights"), - TRACKING_PROTECTION("tracking-protection-firefox-preview"), + TRACKING_PROTECTION("tracking-protection-firefox-android"), WHATS_NEW("whats-new-firefox-preview"), 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-preview"), + SYNC_SETUP("how-set-firefox-sync-firefox-android"), QR_CAMERA_ACCESS("qr-camera-access") } diff --git a/app/src/main/java/org/mozilla/fenix/settings/TextPercentageSeekBarPreference.kt b/app/src/main/java/org/mozilla/fenix/settings/TextPercentageSeekBarPreference.kt index e8e67e557..2415922ab 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/TextPercentageSeekBarPreference.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/TextPercentageSeekBarPreference.kt @@ -229,19 +229,19 @@ class TextPercentageSeekBarPreference @JvmOverloads constructor( init { val a = context.obtainStyledAttributes( - attrs, R.styleable.SeekBarPreference, defStyleAttr, defStyleRes + attrs, R.styleable.TextPercentageSeekBarPreference, defStyleAttr, defStyleRes ) // The ordering of these two statements are important. If we want to set max first, we need // to perform the same steps by changing min/max to max/min as following: // mMax = a.getInt(...) and setMin(...). - mMin = a.getInt(R.styleable.SeekBarPreference_min, 0) - max = a.getInt(R.styleable.SeekBarPreference_android_max, SEEK_BAR_MAX) - seekBarIncrement = a.getInt(R.styleable.SeekBarPreference_seekBarIncrement, 0) - isAdjustable = a.getBoolean(R.styleable.SeekBarPreference_adjustable, true) - mShowSeekBarValue = a.getBoolean(R.styleable.SeekBarPreference_showSeekBarValue, false) + mMin = a.getInt(R.styleable.TextPercentageSeekBarPreference_min, 0) + max = a.getInt(R.styleable.TextPercentageSeekBarPreference_android_max, SEEK_BAR_MAX) + seekBarIncrement = a.getInt(R.styleable.TextPercentageSeekBarPreference_seekBarIncrement, 0) + isAdjustable = a.getBoolean(R.styleable.TextPercentageSeekBarPreference_adjustable, true) + mShowSeekBarValue = a.getBoolean(R.styleable.TextPercentageSeekBarPreference_showSeekBarValue, false) updatesContinuously = a.getBoolean( - R.styleable.SeekBarPreference_updatesContinuously, + R.styleable.TextPercentageSeekBarPreference_updatesContinuously, false ) a.recycle() diff --git a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebsitePermissionsView.kt b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebsitePermissionsView.kt index 44d0f8576..fbfb00e03 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebsitePermissionsView.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/quicksettings/WebsitePermissionsView.kt @@ -67,6 +67,10 @@ class WebsitePermissionsView( PhoneFeature.PERSISTENT_STORAGE to PermissionViewHolder( view.persistentStorageLabel, view.persistentStorageStatus + ), + PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS to PermissionViewHolder( + view.mediaKeySystemAccessLabel, + view.mediaKeySystemAccessStatus ) ) ) diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/AddSearchEngineFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/search/AddSearchEngineFragment.kt index d08520e2d..4521cc365 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/search/AddSearchEngineFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/search/AddSearchEngineFragment.kt @@ -19,26 +19,34 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import kotlinx.android.synthetic.main.custom_search_engine.* -import kotlinx.android.synthetic.main.fragment_add_search_engine.* -import kotlinx.android.synthetic.main.search_engine_radio_button.view.* +import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_form +import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_name_field +import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_search_string_field +import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engines_learn_more +import kotlinx.android.synthetic.main.custom_search_engine.edit_engine_name +import kotlinx.android.synthetic.main.custom_search_engine.edit_search_string +import kotlinx.android.synthetic.main.fragment_add_search_engine.search_engine_group +import kotlinx.android.synthetic.main.search_engine_radio_button.view.engine_icon +import kotlinx.android.synthetic.main.search_engine_radio_button.view.engine_text +import kotlinx.android.synthetic.main.search_engine_radio_button.view.overflow_menu +import kotlinx.android.synthetic.main.search_engine_radio_button.view.radio_button import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import mozilla.components.browser.search.SearchEngine +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.browser.state.state.availableSearchEngines +import mozilla.components.feature.search.ext.createSearchEngine import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar -import org.mozilla.fenix.perf.runBlockingIncrement import org.mozilla.fenix.settings.SupportUtils -import java.util.Locale @SuppressWarnings("LargeClass", "TooManyFunctions") class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), @@ -51,14 +59,13 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), super.onCreate(savedInstanceState) setHasOptionsMenu(true) - availableEngines = runBlockingIncrement { - requireContext() - .components - .search - .provider - .uninstalledSearchEngines(requireContext()) - .list - } + availableEngines = requireContext() + .components + .core + .store + .state + .search + .availableSearchEngines selectedIndex = if (availableEngines.isEmpty()) CUSTOM_INDEX else FIRST_INDEX } @@ -72,7 +79,7 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), ) val setupSearchEngineItem: (Int, SearchEngine) -> Unit = { index, engine -> - val engineId = engine.identifier + val engineId = engine.id val engineItem = makeButtonFromSearchEngine( engine = engine, layoutInflater = layoutInflater, @@ -123,7 +130,8 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), CUSTOM_INDEX -> createCustomEngine() else -> { val engine = availableEngines[selectedIndex] - installEngine(engine) + requireComponents.useCases.searchUseCases.addSearchEngine(engine) + findNavController().popBackStack() } } @@ -141,9 +149,9 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), val name = edit_engine_name.text?.toString()?.trim() ?: "" val searchString = edit_search_string.text?.toString() ?: "" - val hasError = checkForErrors(name, searchString) - - if (hasError) { return } + if (checkForErrors(name, searchString)) { + return + } viewLifecycleOwner.lifecycleScope.launch(Main) { val result = withContext(IO) { @@ -159,22 +167,14 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), .getString(R.string.search_add_custom_engine_error_cannot_reach, name) } SearchStringValidator.Result.Success -> { - try { - CustomSearchEngineStore.addSearchEngine( - context = requireContext(), - engineName = name, - searchQuery = searchString - ) - } catch (engineNameExists: CustomSearchEngineStore.EngineNameAlreadyExists) { - custom_search_engine_name_field.error = - String.format( - resources.getString( - R.string.search_add_custom_engine_error_existing_name - ), name - ) - return@launch - } - requireComponents.search.provider.reload() + val searchEngine = createSearchEngine( + name, + searchString.toSearchUrl(), + requireComponents.core.icons.loadIcon(IconRequest(searchString)).await().bitmap + ) + + requireComponents.useCases.searchUseCases.addSearchEngine(searchEngine) + val successMessage = resources .getString(R.string.search_add_custom_engine_success_message, name) @@ -196,27 +196,12 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), } fun checkForErrors(name: String, searchString: String): Boolean { - val existingIdentifiers = requireComponents - .search - .provider - .allSearchEngineIdentifiers() - .map { it.toLowerCase(Locale.ROOT) } - - val hasError = when { + return when { name.isEmpty() -> { custom_search_engine_name_field.error = resources .getString(R.string.search_add_custom_engine_error_empty_name) true } - existingIdentifiers.contains(name.toLowerCase(Locale.ROOT)) -> { - custom_search_engine_name_field.error = - String.format( - resources.getString( - R.string.search_add_custom_engine_error_existing_name - ), name - ) - true - } searchString.isEmpty() -> { custom_search_engine_search_string_field.error = resources.getString(R.string.search_add_custom_engine_error_empty_search_string) @@ -229,21 +214,6 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), } else -> false } - - return hasError - } - - private fun installEngine(engine: SearchEngine) { - viewLifecycleOwner.lifecycleScope.launch(Main) { - withContext(IO) { - requireContext().components.search.provider.installSearchEngine( - requireContext(), - engine - ) - } - - findNavController().popBackStack() - } } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { @@ -303,3 +273,7 @@ class AddSearchEngineFragment : Fragment(R.layout.fragment_add_search_engine), private const val FIRST_INDEX = 0 } } + +private fun String.toSearchUrl(): String { + return replace("%s", "{searchTerms}") +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/EditCustomSearchEngineFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/search/EditCustomSearchEngineFragment.kt index 677bd349a..bc70833fe 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/search/EditCustomSearchEngineFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/search/EditCustomSearchEngineFragment.kt @@ -4,7 +4,6 @@ package org.mozilla.fenix.settings.search -import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuInflater @@ -14,21 +13,23 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.custom_search_engine.* +import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_name_field +import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engine_search_string_field +import kotlinx.android.synthetic.main.custom_search_engine.custom_search_engines_learn_more +import kotlinx.android.synthetic.main.custom_search_engine.edit_engine_name +import kotlinx.android.synthetic.main.custom_search_engine.edit_search_string import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import mozilla.components.browser.search.SearchEngine +import mozilla.components.browser.state.search.SearchEngine import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar -import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.SupportUtils -import java.util.Locale /** * Fragment to enter a custom search engine name and URL template. @@ -41,17 +42,21 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setHasOptionsMenu(true) - searchEngine = CustomSearchEngineStore.loadCustomSearchEngines(requireContext()).first { - it.identifier == args.searchEngineIdentifier - } + + searchEngine = requireNotNull( + requireComponents.core.store.state.search.customSearchEngines.find { engine -> + engine.id == args.searchEngineIdentifier + } + ) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val url = searchEngine.resultUrls[0] + edit_engine_name.setText(searchEngine.name) - val decodedUrl = Uri.decode(searchEngine.buildSearchUrl("%s")) - edit_search_string.setText(decodedUrl) + edit_search_string.setText(url.toEditableUrl()) custom_search_engines_learn_more.setOnClickListener { (activity as HomeActivity).openToBrowserAndLoad( @@ -92,9 +97,7 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng val name = edit_engine_name.text?.toString()?.trim() ?: "" val searchString = edit_search_string.text?.toString() ?: "" - val hasError = checkForErrors(name, searchString) - - if (hasError) { + if (checkForErrors(name, searchString)) { return } @@ -111,14 +114,15 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng custom_search_engine_search_string_field.error = resources .getString(R.string.search_add_custom_engine_error_cannot_reach, name) } + SearchStringValidator.Result.Success -> { - CustomSearchEngineStore.updateSearchEngine( - context = requireContext(), - oldEngineName = args.searchEngineIdentifier, - newEngineName = name, - searchQuery = searchString + val update = searchEngine.copy( + name = name, + resultUrls = listOf(searchString.toSearchUrl()) ) - requireComponents.search.provider.reload() + + requireComponents.useCases.searchUseCases.addSearchEngine(update) + val successMessage = resources .getString(R.string.search_edit_custom_engine_success_message, name) @@ -131,9 +135,7 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng .setText(successMessage) .show() } - if (args.isDefaultSearchEngine) { - requireComponents.search.provider.setDefaultEngine(requireContext(), name) - } + findNavController().popBackStack() } } @@ -141,28 +143,12 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng } private fun checkForErrors(name: String, searchString: String): Boolean { - val existingIdentifiers = requireComponents - .search - .provider - .allSearchEngineIdentifiers() - .map { it.toLowerCase(Locale.ROOT) } - - val nameHasChanged = name != args.searchEngineIdentifier - val hasError = when { + return when { name.isEmpty() -> { custom_search_engine_name_field.error = resources .getString(R.string.search_add_custom_engine_error_empty_name) true } - existingIdentifiers.contains(name.toLowerCase(Locale.ROOT)) && nameHasChanged -> { - custom_search_engine_name_field.error = - String.format( - resources.getString( - R.string.search_add_custom_engine_error_existing_name - ), name - ) - true - } searchString.isEmpty() -> { custom_search_engine_search_string_field.error = resources.getString(R.string.search_add_custom_engine_error_empty_search_string) @@ -175,6 +161,13 @@ class EditCustomSearchEngineFragment : Fragment(R.layout.fragment_add_search_eng } else -> false } - return hasError } } + +private fun String.toEditableUrl(): String { + return replace("{searchTerms}", "%s") +} + +private fun String.toSearchUrl(): String { + return replace("%s", "{searchTerms}") +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/RadioSearchEngineListPreference.kt b/app/src/main/java/org/mozilla/fenix/settings/search/RadioSearchEngineListPreference.kt index 107b908dc..caea80564 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/search/RadioSearchEngineListPreference.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/search/RadioSearchEngineListPreference.kt @@ -5,27 +5,165 @@ package org.mozilla.fenix.settings.search import android.content.Context +import android.content.res.Resources +import android.graphics.drawable.BitmapDrawable import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import android.widget.CompoundButton -import mozilla.components.browser.search.SearchEngine +import android.widget.LinearLayout +import android.widget.RadioGroup +import androidx.core.view.isVisible +import androidx.navigation.Navigation +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import kotlinx.android.synthetic.main.search_engine_radio_button.view.engine_icon +import kotlinx.android.synthetic.main.search_engine_radio_button.view.engine_text +import kotlinx.android.synthetic.main.search_engine_radio_button.view.overflow_menu +import kotlinx.android.synthetic.main.search_engine_radio_button.view.radio_button +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.browser.state.state.SearchState +import mozilla.components.browser.state.state.searchEngines +import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.lib.state.ext.flow +import mozilla.components.support.ktx.android.view.toScope +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.fenix.R import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.ext.getRootView +import org.mozilla.fenix.utils.allowUndo class RadioSearchEngineListPreference @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.preferenceStyle -) : SearchEngineListPreference(context, attrs, defStyleAttr) { - override val itemResId: Int +) : Preference(context, attrs, defStyleAttr), CompoundButton.OnCheckedChangeListener { + val itemResId: Int get() = R.layout.search_engine_radio_button - override fun updateDefaultItem(defaultButton: CompoundButton) { - defaultButton.isChecked = true + init { + layoutResource = R.layout.preference_search_engine_chooser } - override fun onSearchEngineSelected(searchEngine: SearchEngine) { - context.components.search.provider.setDefaultEngine(context, searchEngine.identifier) - context.settings().defaultSearchEngineName = searchEngine.name + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + subscribeToSearchEngineUpdates( + context.components.core.store, + holder.itemView + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun subscribeToSearchEngineUpdates(store: BrowserStore, view: View) = view.toScope().launch { + store.flow() + .map { state -> state.search } + .ifChanged() + .collect { state -> refreshSearchEngineViews(view, state) } + } + + private fun refreshSearchEngineViews(view: View, state: SearchState) { + val searchEngineGroup = view.findViewById(R.id.search_engine_group) + searchEngineGroup!!.removeAllViews() + + val layoutInflater = LayoutInflater.from(context) + val layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + + state.searchEngines.forEach { engine -> + val searchEngineView = makeButtonFromSearchEngine( + engine = engine, + layoutInflater = layoutInflater, + res = context.resources, + allowDeletion = state.searchEngines.size > 1, + isSelected = engine == state.selectedOrDefaultSearchEngine + ) + + searchEngineGroup.addView(searchEngineView, layoutParams) + } + } + + private fun makeButtonFromSearchEngine( + engine: SearchEngine, + layoutInflater: LayoutInflater, + res: Resources, + allowDeletion: Boolean, + isSelected: Boolean + ): View { + val isCustomSearchEngine = engine.type == SearchEngine.Type.CUSTOM + + val wrapper = layoutInflater.inflate(itemResId, null) as LinearLayout + wrapper.setOnClickListener { wrapper.radio_button.isChecked = true } + wrapper.radio_button.tag = engine.id + wrapper.radio_button.isChecked = isSelected + wrapper.radio_button.setOnCheckedChangeListener(this) + wrapper.engine_text.text = engine.name + wrapper.overflow_menu.isVisible = allowDeletion || isCustomSearchEngine + wrapper.overflow_menu.setOnClickListener { + SearchEngineMenu( + context = context, + allowDeletion = allowDeletion, + isCustomSearchEngine = isCustomSearchEngine, + onItemTapped = { + when (it) { + is SearchEngineMenu.Item.Edit -> editCustomSearchEngine(wrapper, engine) + is SearchEngineMenu.Item.Delete -> deleteSearchEngine( + context, + engine + ) + } + } + ).menuBuilder.build(context).show(wrapper.overflow_menu) + } + val iconSize = res.getDimension(R.dimen.preference_icon_drawable_size).toInt() + val engineIcon = BitmapDrawable(res, engine.icon) + engineIcon.setBounds(0, 0, iconSize, iconSize) + wrapper.engine_icon.setImageDrawable(engineIcon) + return wrapper + } + + override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { + val searchEngineId = buttonView.tag.toString() + val engine = requireNotNull( + context.components.core.store.state.search.searchEngines.find { searchEngine -> + searchEngine.id == searchEngineId + } + ) + + context.components.useCases.searchUseCases.selectSearchEngine(engine) + } + + private fun editCustomSearchEngine(view: View, engine: SearchEngine) { + val directions = SearchEngineFragmentDirections + .actionSearchEngineFragmentToEditCustomSearchEngineFragment(engine.id) + + Navigation.findNavController(view).navigate(directions) + } + + private fun deleteSearchEngine( + context: Context, + engine: SearchEngine + ) { + context.components.useCases.searchUseCases.removeSearchEngine(engine) + + MainScope().allowUndo( + view = context.getRootView()!!, + message = context + .getString(R.string.search_delete_search_engine_success_message, engine.name), + undoActionTitle = context.getString(R.string.snackbar_deleted_undo), + onCancel = { + context.components.useCases.searchUseCases.addSearchEngine(engine) + }, + operation = {} + ) } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineFragment.kt index 05b3ef201..c5971e7f9 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineFragment.kt @@ -70,15 +70,11 @@ class SearchEngineFragment : PreferenceFragmentCompat() { isChecked = context.settings().shouldShowClipboardSuggestions } - val searchEngineListPreference = - requirePreference(R.string.pref_key_search_engine_list) - val showVoiceSearchPreference = requirePreference(R.string.pref_key_show_voice_search).apply { isChecked = context.settings().shouldShowVoiceSearch } - searchEngineListPreference.reload(requireContext()) searchSuggestionsPreference.onPreferenceChangeListener = SharedPreferenceUpdater() showSearchShortcuts.onPreferenceChangeListener = SharedPreferenceUpdater() showHistorySuggestions.onPreferenceChangeListener = SharedPreferenceUpdater() diff --git a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineListPreference.kt b/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineListPreference.kt deleted file mode 100644 index c01c305d0..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/search/SearchEngineListPreference.kt +++ /dev/null @@ -1,242 +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.settings.search - -import android.content.Context -import android.content.res.Resources -import android.graphics.drawable.BitmapDrawable -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.CompoundButton -import android.widget.LinearLayout -import android.widget.RadioGroup -import androidx.core.view.isVisible -import androidx.navigation.Navigation -import androidx.preference.Preference -import androidx.preference.PreferenceViewHolder -import kotlinx.android.synthetic.main.search_engine_radio_button.view.engine_icon -import kotlinx.android.synthetic.main.search_engine_radio_button.view.engine_text -import kotlinx.android.synthetic.main.search_engine_radio_button.view.overflow_menu -import kotlinx.android.synthetic.main.search_engine_radio_button.view.radio_button -import kotlinx.coroutines.MainScope -import mozilla.components.browser.search.SearchEngine -import mozilla.components.browser.search.provider.SearchEngineList -import org.mozilla.fenix.R -import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore -import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.getRootView -import org.mozilla.fenix.ext.settings -import org.mozilla.fenix.utils.allowUndo -import java.util.Locale - -abstract class SearchEngineListPreference @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = android.R.attr.preferenceStyle -) : Preference(context, attrs, defStyleAttr), CompoundButton.OnCheckedChangeListener { - - protected lateinit var searchEngineList: SearchEngineList - protected var searchEngineGroup: RadioGroup? = null - - protected abstract val itemResId: Int - - init { - layoutResource = R.layout.preference_search_engine_chooser - } - - override fun onBindViewHolder(holder: PreferenceViewHolder?) { - super.onBindViewHolder(holder) - searchEngineGroup = holder!!.itemView.findViewById(R.id.search_engine_group) - reload(searchEngineGroup!!.context) - } - - fun reload(context: Context) { - searchEngineList = context.components.search.provider.installedSearchEngines(context) - refreshSearchEngineViews(context) - } - - protected abstract fun onSearchEngineSelected(searchEngine: SearchEngine) - protected abstract fun updateDefaultItem(defaultButton: CompoundButton) - - private fun refreshSearchEngineViews(context: Context) { - if (searchEngineGroup == null) { - // We want to refresh the search engine list of this preference in onResume, - // but the first time this preference is created onResume is called before onCreateView - // so searchEngineGroup is not set yet. - return - } - - val defaultEngineId = context.components.search.provider.getDefaultEngine(context).identifier - - val selectedEngine = (searchEngineList.list.find { - it.identifier == defaultEngineId - } ?: searchEngineList.list.first()).identifier - - // set the search engine manager default - context.components.search.provider.setDefaultEngine(context, selectedEngine) - - searchEngineGroup!!.removeAllViews() - - val layoutInflater = LayoutInflater.from(context) - val layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - - val setupSearchEngineItem: (Int, SearchEngine) -> Unit = { index, engine -> - val engineId = engine.identifier - val engineItem = makeButtonFromSearchEngine( - engine = engine, - layoutInflater = layoutInflater, - res = context.resources, - allowDeletion = searchEngineList.list.size > 1 - ) - - engineItem.id = index + (searchEngineList.default?.let { 1 } ?: 0) - engineItem.tag = engineId - if (engineId == selectedEngine) { - updateDefaultItem(engineItem.radio_button) - /* #11465 -> radio_button.isChecked = true does not trigger - * onSearchEngineSelected because searchEngineGroup has null views at that point. - * So we trigger it here.*/ - onSearchEngineSelected(engine) - } - searchEngineGroup!!.addView(engineItem, layoutParams) - } - - searchEngineList.default?.apply { - setupSearchEngineItem(0, this) - } - - searchEngineList.list - .filter { it.identifier != searchEngineList.default?.identifier } - .sortedBy { it.name.toLowerCase(Locale.getDefault()) } - .forEachIndexed(setupSearchEngineItem) - } - - private fun makeButtonFromSearchEngine( - engine: SearchEngine, - layoutInflater: LayoutInflater, - res: Resources, - allowDeletion: Boolean - ): View { - val isCustomSearchEngine = - CustomSearchEngineStore.isCustomSearchEngine(context, engine.identifier) - - val wrapper = layoutInflater.inflate(itemResId, null) as LinearLayout - wrapper.setOnClickListener { wrapper.radio_button.isChecked = true } - wrapper.radio_button.setOnCheckedChangeListener(this) - wrapper.engine_text.text = engine.name - wrapper.overflow_menu.isVisible = allowDeletion || isCustomSearchEngine - wrapper.overflow_menu.setOnClickListener { - SearchEngineMenu( - context = context, - allowDeletion = allowDeletion, - isCustomSearchEngine = isCustomSearchEngine, - onItemTapped = { - when (it) { - is SearchEngineMenu.Item.Edit -> editCustomSearchEngine(engine) - is SearchEngineMenu.Item.Delete -> deleteSearchEngine( - context, - engine, - isCustomSearchEngine - ) - } - } - ).menuBuilder.build(context).show(wrapper.overflow_menu) - } - val iconSize = res.getDimension(R.dimen.preference_icon_drawable_size).toInt() - val engineIcon = BitmapDrawable(res, engine.icon) - engineIcon.setBounds(0, 0, iconSize, iconSize) - wrapper.engine_icon.setImageDrawable(engineIcon) - return wrapper - } - - override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { - searchEngineList.list.forEach { engine -> - val wrapper: LinearLayout = - searchEngineGroup?.findViewWithTag(engine.identifier) ?: return - - when (wrapper.radio_button == buttonView) { - true -> onSearchEngineSelected(engine) - false -> { - wrapper.radio_button.setOnCheckedChangeListener(null) - wrapper.radio_button.isChecked = false - wrapper.radio_button.setOnCheckedChangeListener(this) - } - } - } - } - - private fun editCustomSearchEngine(engine: SearchEngine) { - val wasDefault = context.components.search.provider.getDefaultEngine(context).identifier == engine.identifier - val directions = SearchEngineFragmentDirections - .actionSearchEngineFragmentToEditCustomSearchEngineFragment(engine.identifier, wasDefault) - Navigation.findNavController(searchEngineGroup!!).navigate(directions) - } - - private fun deleteSearchEngine( - context: Context, - engine: SearchEngine, - isCustomSearchEngine: Boolean - ) { - val isDefaultEngine = engine == context.components.search.provider.getDefaultEngine(context) - val initialEngineList = searchEngineList.copy() - val initialDefaultEngine = searchEngineList.default - - context.components.search.provider.uninstallSearchEngine( - context, - engine, - isCustomSearchEngine - ) - - MainScope().allowUndo( - view = context.getRootView()!!, - message = context - .getString(R.string.search_delete_search_engine_success_message, engine.name), - undoActionTitle = context.getString(R.string.snackbar_deleted_undo), - onCancel = { - context.components.search.provider.installSearchEngine( - context, - engine, - isCustomSearchEngine - ) - - searchEngineList = initialEngineList.copy( - default = initialDefaultEngine - ) - - refreshSearchEngineViews(context) - }, - operation = { - if (isDefaultEngine) { - val default = context.components.search.provider.getDefaultEngine(context) - context.components.search.provider.setDefaultEngine(context, default.identifier) - context.settings().defaultSearchEngineName = default.name - } - if (isCustomSearchEngine) { - context.components.analytics.metrics.track(Event.CustomEngineDeleted) - } - refreshSearchEngineViews(context) - } - ) - - searchEngineList = searchEngineList.copy( - list = searchEngineList.list.filter { - it.identifier != engine.identifier - }, - default = if (searchEngineList.default?.identifier == engine.identifier) { - null - } else { - searchEngineList.default - } - ) - - refreshSearchEngineViews(context) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsDetailsExceptionsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsDetailsExceptionsFragment.kt index cda13889c..5b4d0aa99 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsDetailsExceptionsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsDetailsExceptionsFragment.kt @@ -26,6 +26,7 @@ import org.mozilla.fenix.settings.PhoneFeature.LOCATION import org.mozilla.fenix.settings.PhoneFeature.MICROPHONE import org.mozilla.fenix.settings.PhoneFeature.NOTIFICATION import org.mozilla.fenix.settings.PhoneFeature.PERSISTENT_STORAGE +import org.mozilla.fenix.settings.PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS import org.mozilla.fenix.settings.requirePreference class SitePermissionsDetailsExceptionsFragment : PreferenceFragmentCompat() { @@ -59,6 +60,7 @@ class SitePermissionsDetailsExceptionsFragment : PreferenceFragmentCompat() { initPhoneFeature(MICROPHONE) initPhoneFeature(NOTIFICATION) initPhoneFeature(PERSISTENT_STORAGE) + initPhoneFeature(MEDIA_KEY_SYSTEM_ACCESS) bindClearPermissionsButton() } diff --git a/app/src/main/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsManagePhoneFeatureFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsManagePhoneFeatureFragment.kt index d96f44836..2644e8ac9 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsManagePhoneFeatureFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsManagePhoneFeatureFragment.kt @@ -28,11 +28,13 @@ import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.AS import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.BLOCKED import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.PhoneFeature.AUTOPLAY_AUDIBLE import org.mozilla.fenix.settings.PhoneFeature.AUTOPLAY_INAUDIBLE +import org.mozilla.fenix.settings.PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS import org.mozilla.fenix.settings.setStartCheckedIndicator import org.mozilla.fenix.utils.Settings @@ -132,6 +134,13 @@ class SitePermissionsManagePhoneFeatureFragment : Fragment() { saveActionInSettings(AUTOPLAY_BLOCK_AUDIBLE) } restoreState(AUTOPLAY_BLOCK_AUDIBLE) + } else if (args.phoneFeature == MEDIA_KEY_SYSTEM_ACCESS) { + visibility = View.VISIBLE + text = getString(R.string.preference_option_phone_feature_allowed) + setOnClickListener { + saveActionInSettings(ALLOWED) + } + restoreState(ALLOWED) } else { visibility = View.GONE } @@ -205,6 +214,7 @@ class SitePermissionsManagePhoneFeatureFragment : Fragment() { requireComponents.analytics.metrics.track(Event.AutoPlaySettingChanged(setting)) settings.setSitePermissionsPhoneFeatureAction(AUTOPLAY_AUDIBLE, audible) settings.setSitePermissionsPhoneFeatureAction(AUTOPLAY_INAUDIBLE, inaudible) + context?.components?.useCases?.sessionUseCases?.reload?.invoke() } private fun bindBlockedByAndroidContainer(rootView: View) { diff --git a/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingObserver.kt b/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingObserver.kt index cc30b7421..b86276e82 100644 --- a/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingObserver.kt +++ b/app/src/main/java/org/mozilla/fenix/shortcut/PwaOnboardingObserver.kt @@ -4,9 +4,18 @@ package org.mozilla.fenix.shortcut +import androidx.lifecycle.LifecycleOwner import androidx.navigation.NavController -import mozilla.components.browser.session.Session +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.store.BrowserStore import mozilla.components.feature.pwa.WebAppUseCases +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import org.mozilla.fenix.R import org.mozilla.fenix.browser.BrowserFragmentDirections import org.mozilla.fenix.ext.nav @@ -15,22 +24,41 @@ import org.mozilla.fenix.utils.Settings /** * Displays the [PwaOnboardingDialogFragment] info dialog when a PWA is opened in the browser for the third time. */ +@ExperimentalCoroutinesApi class PwaOnboardingObserver( + private val store: BrowserStore, + private val lifecycleOwner: LifecycleOwner, private val navController: NavController, private val settings: Settings, private val webAppUseCases: WebAppUseCases -) : Session.Observer { +) { - override fun onLoadingStateChanged(session: Session, loading: Boolean) { - if (!loading && webAppUseCases.isInstallable() && !settings.userKnowsAboutPwas) { - settings.incrementVisitedInstallableCount() - if (settings.shouldShowPwaCfr) { - val directions = - BrowserFragmentDirections.actionBrowserFragmentToPwaOnboardingDialogFragment() - navController.nav(R.id.browserFragment, directions) - settings.lastCfrShownTimeInMillis = System.currentTimeMillis() - settings.userKnowsAboutPwas = true + private var scope: CoroutineScope? = null + + fun start() { + scope = store.flowScoped(lifecycleOwner) { flow -> + flow.mapNotNull { state -> + state.selectedTab + } + .ifChanged { + it.content.webAppManifest + } + .collect { + if (webAppUseCases.isInstallable() && !settings.userKnowsAboutPwas) { + settings.incrementVisitedInstallableCount() + if (settings.shouldShowPwaCfr) { + val directions = + BrowserFragmentDirections.actionBrowserFragmentToPwaOnboardingDialogFragment() + navController.nav(R.id.browserFragment, directions) + settings.lastCfrShownTimeInMillis = System.currentTimeMillis() + settings.userKnowsAboutPwas = true + } + } } } } + + fun stop() { + scope?.cancel() + } } diff --git a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsLayout.kt b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsLayout.kt index 2d3b5c0a4..56255910c 100644 --- a/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsLayout.kt +++ b/app/src/main/java/org/mozilla/fenix/sync/SyncedTabsLayout.kt @@ -21,6 +21,9 @@ import mozilla.components.browser.storage.sync.Tab import mozilla.components.feature.syncedtabs.view.SyncedTabsView import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.ext.components import org.mozilla.fenix.sync.ext.toAdapterItem import org.mozilla.fenix.sync.ext.toStringRes import java.lang.IllegalStateException @@ -32,8 +35,9 @@ class SyncedTabsLayout @JvmOverloads constructor( ) : FrameLayout(context, attrs, defStyleAttr), SyncedTabsView { override var listener: SyncedTabsView.Listener? = null + private val metrics = context.components.analytics.metrics - private val adapter = SyncedTabsAdapter(ListenerDelegate { listener }) + private val adapter = SyncedTabsAdapter(ListenerDelegate(metrics) { listener }) private val coroutineScope = CoroutineScope(Dispatchers.Main) init { @@ -110,6 +114,7 @@ class SyncedTabsLayout @JvmOverloads constructor( * when we get a null reference, we never get a new binding to the non-null listener. */ class ListenerDelegate( + private val metrics: MetricController, private val listener: (() -> SyncedTabsView.Listener?) ) : SyncedTabsView.Listener { override fun onRefresh() { @@ -118,5 +123,6 @@ class ListenerDelegate( override fun onTabClicked(tab: Tab) { listener.invoke()?.onTabClicked(tab) + metrics.track(Event.SyncedTabOpened) } } 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 b723d97be..a2f411785 100644 --- a/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabhistory/TabHistoryDialogFragment.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.mapNotNull import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab 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.ext.components @@ -26,11 +27,6 @@ import org.mozilla.fenix.ext.requireComponents class TabHistoryDialogFragment : BottomSheetDialogFragment() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NO_TITLE, R.style.BottomSheet) - } - var customTabSessionId: String? = null override fun onCreateView( @@ -43,6 +39,8 @@ class TabHistoryDialogFragment : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + view.setBackgroundColor(view.context.getColorFromAttr(R.attr.foundation)) + customTabSessionId = requireArguments().getString(EXTRA_SESSION_ID) val controller = DefaultTabHistoryController( diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/FenixTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabtray/FenixTabsAdapter.kt index 711adaeed..4f1bb4d01 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/FenixTabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/FenixTabsAdapter.kt @@ -59,13 +59,22 @@ class FenixTabsAdapter( override fun onBindViewHolder( holder: TabViewHolder, position: Int, - payloads: MutableList + payloads: List ) { if (payloads.isNullOrEmpty()) { onBindViewHolder(holder, position) return } + // Having non-empty payloads means we have to make a partial update. + // This currently only happens when changing between the Normal and MultiSelect modes + // when we want to either show the last opened tab as selected (default) or hide this ui decorator. + if (mode is TabTrayDialogFragmentState.Mode.Normal) { + super.onBindViewHolder(holder, position, listOf(PAYLOAD_HIGHLIGHT_SELECTED_ITEM)) + } else { + super.onBindViewHolder(holder, position, listOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM)) + } + holder.tab?.let { showCheckedIfSelected(it, holder.itemView) } } @@ -133,6 +142,11 @@ class FenixTabsAdapter( tabsList = recyclerView } + override fun isTabSelected(tabs: Tabs, position: Int): Boolean { + return mode is TabTrayDialogFragmentState.Mode.Normal && + tabs.selectedIndex == position + } + private fun showCheckedIfSelected(tab: Tab, view: View) { val shouldBeChecked = mode is TabTrayDialogFragmentState.Mode.MultiSelect && selectedItems.contains(tab) diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/SyncedTabsController.kt b/app/src/main/java/org/mozilla/fenix/tabtray/SyncedTabsController.kt index c41579095..b982ca21a 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/SyncedTabsController.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/SyncedTabsController.kt @@ -21,6 +21,7 @@ import mozilla.components.browser.storage.sync.SyncedDeviceTabs import mozilla.components.feature.syncedtabs.view.SyncedTabsView import mozilla.components.lib.state.ext.flowScoped import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.sync.ListenerDelegate import org.mozilla.fenix.sync.SyncedTabsAdapter import org.mozilla.fenix.sync.ext.toAdapterList @@ -34,11 +35,12 @@ class SyncedTabsController( private val view: View, store: TabTrayDialogFragmentStore, private val concatAdapter: ConcatAdapter, - coroutineContext: CoroutineContext = Dispatchers.Main + coroutineContext: CoroutineContext = Dispatchers.Main, + metrics: MetricController ) : SyncedTabsView { override var listener: SyncedTabsView.Listener? = null - val adapter = SyncedTabsAdapter(ListenerDelegate { listener }) + val adapter = SyncedTabsAdapter(ListenerDelegate(metrics) { listener }) private val scope: CoroutineScope = CoroutineScope(coroutineContext) diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt index bb3a4f56b..f6e4ce768 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt @@ -250,6 +250,7 @@ class DefaultTabTrayController( override fun handleRecentlyClosedClicked() { val directions = TabTrayDialogFragmentDirections.actionGlobalRecentlyClosed() navController.navigate(directions) + metrics.track(Event.RecentlyClosedTabsOpened) } override fun handleGoToTabsSettingClicked() { diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt index c0dcd4d38..88317629c 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt @@ -72,22 +72,29 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler private lateinit var tabTrayDialogStore: TabTrayDialogFragmentStore private val snackbarAnchor: View? - get() = if (tabTrayView.fabView.new_tab_button.isVisible || - tabTrayView.mode != Mode.Normal - ) tabTrayView.fabView.new_tab_button - /* During selection of the tabs to the collection, the FAB is not visible, - which leads to not attaching a needed AnchorView. That's why, we're not only checking, if it's not visible, - but also if we're not in a "Normal" mode, so after selecting tabs for a collection, we're pushing snackbar - above the FAB, as we're switching from "Multiselect" to "Normal". */ - else null + get() = + // Fab is hidden when Talkback is activated. See #16592 + if (requireContext().settings().accessibilityServicesEnabled) null + else if (tabTrayView.fabView.new_tab_button.isVisible || + tabTrayView.mode != Mode.Normal + ) tabTrayView.fabView.new_tab_button + /* During selection of the tabs to the collection, the FAB is not visible, + which leads to not attaching a needed AnchorView. That's why, we're not only + checking, if it's not visible, but also if we're not in a "Normal" mode, so after + selecting tabs for a collection, we're pushing snackbar + above the FAB, as we're switching from "Multiselect" to "Normal". */ + else null private val collectionStorageObserver = object : TabCollectionStorage.Observer { - override fun onCollectionCreated(title: String, sessions: List) { - showCollectionSnackbar(sessions.size, true) + override fun onCollectionCreated(title: String, sessions: List, id: Long?) { + showCollectionSnackbar(sessions.size, true, collectionToSelect = id) } override fun onTabsAdded(tabCollection: TabCollection, sessions: List) { - showCollectionSnackbar(sessions.size) + showCollectionSnackbar( + sessions.size, + collectionToSelect = tabCollection.id + ) } } @@ -350,7 +357,11 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler requireComponents.core.tabCollectionStorage.register(collectionStorageObserver, this) } - private fun showCollectionSnackbar(tabSize: Int, isNewCollection: Boolean = false) { + private fun showCollectionSnackbar( + tabSize: Int, + isNewCollection: Boolean = false, + collectionToSelect: Long? + ) { view.let { val messageStringRes = when { isNewCollection -> { @@ -374,7 +385,10 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler .setAction(requireContext().getString(R.string.create_collection_view)) { dismissAllowingStateLoss() findNavController().navigate( - TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = false) + TabTrayDialogFragmentDirections.actionGlobalHome( + focusOnAddressBar = false, + focusOnCollection = collectionToSelect ?: -1L + ) ) } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt index 5512320b0..5d82b2775 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt @@ -7,6 +7,8 @@ package org.mozilla.fenix.tabtray import android.content.Context import android.view.LayoutInflater import android.view.View +import android.view.View.INVISIBLE +import android.view.View.VISIBLE import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import androidx.annotation.IdRes @@ -42,17 +44,18 @@ import mozilla.components.browser.tabstray.TabViewHolder import mozilla.components.feature.syncedtabs.SyncedTabsFeature import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.util.dpToPx +import mozilla.components.ui.tabcounter.TabCounter.Companion.INFINITE_CHAR_PADDING_BOTTOM import org.mozilla.fenix.R import org.mozilla.fenix.browser.InfoBanner import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.components.toolbar.TabCounter.Companion.INFINITE_CHAR_PADDING_BOTTOM -import org.mozilla.fenix.components.toolbar.TabCounter.Companion.MAX_VISIBLE_TABS -import org.mozilla.fenix.components.toolbar.TabCounter.Companion.SO_MANY_TABS_OPEN +import mozilla.components.ui.tabcounter.TabCounter.Companion.MAX_VISIBLE_TABS +import mozilla.components.ui.tabcounter.TabCounter.Companion.SO_MANY_TABS_OPEN import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.updateAccessibilityCollectionInfo import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.MultiselectModeChange import org.mozilla.fenix.tabtray.TabTrayDialogFragmentState.Mode +import org.mozilla.fenix.utils.Settings import java.text.NumberFormat import kotlin.math.max import kotlin.math.roundToInt @@ -61,7 +64,7 @@ import mozilla.components.browser.storage.sync.Tab as SyncTab /** * View that contains and configures the BrowserAwesomeBar */ -@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "ForbiddenComment") class TabTrayView( private val container: ViewGroup, private val tabsAdapter: FenixTabsAdapter, @@ -94,9 +97,10 @@ class TabTrayView( private var tabsTouchHelper: TabsTouchHelper private val collectionsButtonAdapter = SaveToCollectionsButtonAdapter(interactor, isPrivate) + private val metrics = container.context.components.analytics.metrics private val syncedTabsController = - SyncedTabsController(lifecycleOwner, view, store, concatAdapter) + SyncedTabsController(lifecycleOwner, view, store, concatAdapter, metrics = metrics) private val syncedTabsFeature = ViewBoundFeatureWrapper() private var hasLoaded = false @@ -124,12 +128,12 @@ class TabTrayView( } behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onSlide(bottomSheet: View, slideOffset: Float) { - if (interactor.onModeRequested() is Mode.Normal && !hasAccessibilityEnabled) { - if (slideOffset >= SLIDE_OFFSET) { - fabView.new_tab_button.show() - } else { - fabView.new_tab_button.hide() - } + if ( + interactor.onModeRequested() is Mode.Normal && + !hasAccessibilityEnabled && + slideOffset >= SLIDE_OFFSET + ) { + fabView.new_tab_button.show() } } @@ -266,10 +270,15 @@ class TabTrayView( adjustNewTabButtonsForNormalMode() + displayInfoBannerIfNeccessary(tabs, view.context.settings()) + } + + private fun displayInfoBannerIfNeccessary(tabs: List, settings: Settings) { @Suppress("ComplexCondition") - if ( - view.context.settings().shouldShowGridViewBanner && - view.context.settings().canShowCfr && + val infoBanner = if ( + settings.shouldShowGridViewBanner && + settings.canShowCfr && + settings.listTabView && tabs.size >= TAB_COUNT_SHOW_CFR ) { InfoBanner( @@ -279,17 +288,14 @@ class TabTrayView( actionText = view.context.getString(R.string.tab_tray_grid_view_banner_positive_button_text), container = view.infoBanner, dismissByHiding = true, - dismissAction = { view.context.settings().shouldShowGridViewBanner = false } + dismissAction = { settings.shouldShowGridViewBanner = false } ) { interactor.onGoToTabsSettings() - view.context.settings().shouldShowGridViewBanner = false - }.apply { - view.infoBanner.visibility = View.VISIBLE - showBanner() + settings.shouldShowGridViewBanner = false } } else if ( - view.context.settings().shouldShowAutoCloseTabsBanner && - view.context.settings().canShowCfr && + settings.shouldShowAutoCloseTabsBanner && + settings.canShowCfr && tabs.size >= TAB_COUNT_SHOW_CFR ) { InfoBanner( @@ -299,14 +305,18 @@ class TabTrayView( actionText = view.context.getString(R.string.tab_tray_close_tabs_banner_positive_button_text), container = view.infoBanner, dismissByHiding = true, - dismissAction = { view.context.settings().shouldShowAutoCloseTabsBanner = false } + dismissAction = { settings.shouldShowAutoCloseTabsBanner = false } ) { interactor.onGoToTabsSettings() - view.context.settings().shouldShowAutoCloseTabsBanner = false - }.apply { - view.infoBanner.visibility = View.VISIBLE - showBanner() + settings.shouldShowAutoCloseTabsBanner = false } + } else { + null + } + + infoBanner?.apply { + view.infoBanner.visibility = VISIBLE + showBanner() } } @@ -572,9 +582,9 @@ class TabTrayView( } view.tabsTray.visibility = if (hasNoTabs) { - View.INVISIBLE + INVISIBLE } else { - View.VISIBLE + VISIBLE } counter_text.text = updateTabCounter(browserState.normalTabs.size) @@ -690,7 +700,7 @@ class TabTrayView( view.resources.getDimensionPixelSize(R.dimen.tab_tray_top_offset) } - behavior.setExpandedOffset(topOffset) + behavior.expandedOffset = topOffset } fun dismissMenu() { diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt index 49bb453e8..1965f32b1 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt @@ -13,7 +13,7 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.AppCompatImageButton import androidx.core.content.ContextCompat import kotlinx.android.synthetic.main.tab_tray_grid_item.view.* -import mozilla.components.browser.state.state.MediaState +import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.tabstray.TabViewHolder import mozilla.components.browser.tabstray.TabsTrayStyling @@ -21,24 +21,27 @@ import mozilla.components.browser.tabstray.thumbnail.TabThumbnailView import mozilla.components.browser.toolbar.MAX_URI_LENGTH import mozilla.components.concept.base.images.ImageLoadRequest import mozilla.components.concept.base.images.ImageLoader +import mozilla.components.concept.engine.mediasession.MediaSession import mozilla.components.concept.tabstray.Tab import mozilla.components.concept.tabstray.TabsTray -import mozilla.components.feature.media.ext.pauseIfPlaying -import mozilla.components.feature.media.ext.playIfPaused import mozilla.components.support.base.observer.Observable import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.getMediaStateForSession import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.removeAndDisable import org.mozilla.fenix.ext.removeTouchDelegate import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showAndEnable import org.mozilla.fenix.ext.toShortUrl -import org.mozilla.fenix.utils.Do import kotlin.math.max +import mozilla.components.browser.state.state.MediaState +import mozilla.components.feature.media.ext.pauseIfPlaying +import mozilla.components.feature.media.ext.playIfPaused +import org.mozilla.fenix.FeatureFlags.newMediaSessionApi +import org.mozilla.fenix.ext.getMediaStateForSession +import org.mozilla.fenix.utils.Do /** * A RecyclerView ViewHolder implementation for "tab" items. @@ -68,6 +71,7 @@ class TabTrayViewHolder( /** * Displays the data of the given session and notifies the given observable about events. */ + @Suppress("ComplexMethod", "LongMethod") override fun bind( tab: Tab, isSelected: Boolean, @@ -94,49 +98,98 @@ class TabTrayViewHolder( // Media state playPauseButtonView.increaseTapArea(PLAY_PAUSE_BUTTON_EXTRA_DPS) - with(playPauseButtonView) { - invalidate() - Do exhaustive when (store.state.getMediaStateForSession(tab.id)) { - MediaState.State.PAUSED -> { - showAndEnable() - contentDescription = - context.getString(R.string.mozac_feature_media_notification_action_play) - setImageDrawable( - AppCompatResources.getDrawable(context, R.drawable.media_state_play) - ) - } - MediaState.State.PLAYING -> { - showAndEnable() - contentDescription = - context.getString(R.string.mozac_feature_media_notification_action_pause) - setImageDrawable( - AppCompatResources.getDrawable(context, R.drawable.media_state_pause) - ) + if (newMediaSessionApi) { + with(playPauseButtonView) { + invalidate() + val sessionState = store.state.findTabOrCustomTab(tab.id) + when (sessionState?.mediaSessionState?.playbackState) { + MediaSession.PlaybackState.PAUSED -> { + showAndEnable() + contentDescription = + context.getString(R.string.mozac_feature_media_notification_action_play) + setImageDrawable( + AppCompatResources.getDrawable(context, R.drawable.media_state_play) + ) + } + + MediaSession.PlaybackState.PLAYING -> { + showAndEnable() + contentDescription = + context.getString(R.string.mozac_feature_media_notification_action_pause) + setImageDrawable( + AppCompatResources.getDrawable(context, R.drawable.media_state_pause) + ) + } + + else -> { + removeTouchDelegate() + removeAndDisable() + } } - MediaState.State.NONE -> { - removeTouchDelegate() - removeAndDisable() + setOnClickListener { + when (sessionState?.mediaSessionState?.playbackState) { + MediaSession.PlaybackState.PLAYING -> { + metrics.track(Event.TabMediaPause) + sessionState.mediaSessionState?.controller?.pause() + } + + MediaSession.PlaybackState.PAUSED -> { + metrics.track(Event.TabMediaPlay) + sessionState.mediaSessionState?.controller?.play() + } + else -> throw AssertionError( + "Play/Pause button clicked without play/pause state." + ) + } } } - } + } else { + with(playPauseButtonView) { + invalidate() + Do exhaustive when (store.state.getMediaStateForSession(tab.id)) { + MediaState.State.PAUSED -> { + showAndEnable() + contentDescription = + context.getString(R.string.mozac_feature_media_notification_action_play) + setImageDrawable( + AppCompatResources.getDrawable(context, R.drawable.media_state_play) + ) + } - playPauseButtonView.setOnClickListener { - Do exhaustive when (store.state.getMediaStateForSession(tab.id)) { - MediaState.State.PLAYING -> { - metrics.track(Event.TabMediaPause) - store.state.media.pauseIfPlaying() - } + MediaState.State.PLAYING -> { + showAndEnable() + contentDescription = + context.getString(R.string.mozac_feature_media_notification_action_pause) + setImageDrawable( + AppCompatResources.getDrawable(context, R.drawable.media_state_pause) + ) + } - MediaState.State.PAUSED -> { - metrics.track(Event.TabMediaPlay) - store.state.media.playIfPaused() + MediaState.State.NONE -> { + removeTouchDelegate() + removeAndDisable() + } } + } + + playPauseButtonView.setOnClickListener { + Do exhaustive when (store.state.getMediaStateForSession(tab.id)) { + MediaState.State.PLAYING -> { + metrics.track(Event.TabMediaPause) + store.state.media.pauseIfPlaying() + } + + MediaState.State.PAUSED -> { + metrics.track(Event.TabMediaPlay) + store.state.media.playIfPaused() + } - MediaState.State.NONE -> throw AssertionError( - "Play/Pause button clicked without play/pause state." - ) + MediaState.State.NONE -> throw AssertionError( + "Play/Pause button clicked without play/pause state." + ) + } } } @@ -174,10 +227,9 @@ class TabTrayViewHolder( .take(MAX_URI_LENGTH) } - @VisibleForTesting - internal fun updateSelectedTabIndicator(isSelected: Boolean) { + override fun updateSelectedTabIndicator(showAsSelected: Boolean) { if (itemView.context.settings().gridTabView) { - itemView.tab_tray_grid_item.background = if (isSelected) { + itemView.tab_tray_grid_item.background = if (showAsSelected) { AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border) } else { null @@ -185,7 +237,7 @@ class TabTrayViewHolder( return } - val color = if (isSelected) { + val color = if (showAsSelected) { R.color.tab_tray_item_selected_background_normal_theme } else { R.color.tab_tray_item_background_normal_theme 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 b0257d02a..2dc8e682b 100644 --- a/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt @@ -71,7 +71,11 @@ class TrackingProtectionPanelDialogFragment : AppCompatDialogFragment(), UserInt ): View? { val view = inflateRootView(container) val session = requireComponents.core.sessionManager.findSessionById(args.sessionId) + + @Suppress("DEPRECATION") + // TODO Use browser store instead of session observer: https://github.com/mozilla-mobile/fenix/issues/16944 session?.register(sessionObserver, view = view) + trackingProtectionStore = StoreProvider.get(this) { TrackingProtectionStore( TrackingProtectionState( diff --git a/app/src/main/java/org/mozilla/fenix/utils/FragmentPreDrawManager.kt b/app/src/main/java/org/mozilla/fenix/utils/FragmentPreDrawManager.kt deleted file mode 100644 index 0160ac7a7..000000000 --- a/app/src/main/java/org/mozilla/fenix/utils/FragmentPreDrawManager.kt +++ /dev/null @@ -1,30 +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.utils - -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch - -/** - * Helper class that allows executing code immediately before [Fragment]s View being drawn. - */ -class FragmentPreDrawManager( - private val fragment: Fragment -) { - init { - fragment.postponeEnterTransition() - } - - fun execute(code: suspend () -> Unit) { - fragment.view?.doOnPreDraw { - fragment.viewLifecycleOwner.lifecycleScope.launch { - code() - fragment.startPostponedEnterTransition() - } - } - } -} 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 0b70e1373..55ef0b4e4 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -231,7 +231,7 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false ) - val isExperimentationEnabled by booleanPreference( + var isExperimentationEnabled by booleanPreference( appContext.getPreferenceKey(R.string.pref_key_experimentation), default = false ) @@ -756,7 +756,8 @@ class Settings(private val appContext: Context) : PreferencesHolder { camera = getSitePermissionsPhoneFeatureAction(PhoneFeature.CAMERA), autoplayAudible = getSitePermissionsPhoneFeatureAutoplayAction(PhoneFeature.AUTOPLAY_AUDIBLE), autoplayInaudible = getSitePermissionsPhoneFeatureAutoplayAction(PhoneFeature.AUTOPLAY_INAUDIBLE), - persistentStorage = getSitePermissionsPhoneFeatureAction(PhoneFeature.PERSISTENT_STORAGE) + persistentStorage = getSitePermissionsPhoneFeatureAction(PhoneFeature.PERSISTENT_STORAGE), + mediaKeySystemAccess = getSitePermissionsPhoneFeatureAction(PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS) ) } @@ -767,7 +768,9 @@ class Settings(private val appContext: Context) : PreferencesHolder { PhoneFeature.LOCATION, PhoneFeature.CAMERA, PhoneFeature.AUTOPLAY_AUDIBLE, - PhoneFeature.AUTOPLAY_INAUDIBLE + PhoneFeature.AUTOPLAY_INAUDIBLE, + PhoneFeature.PERSISTENT_STORAGE, + PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS ).map { it.getPreferenceKey(appContext) } preferences.registerOnSharedPreferenceChangeListener(lifecycleOwner) { _, key -> @@ -923,6 +926,16 @@ class Settings(private val appContext: Context) : PreferencesHolder { BuildConfig.AMO_COLLECTION ) + var mobileBookmarksSize by intPreference( + appContext.getPreferenceKey(R.string.pref_key_mobile_bookmarks_size), + 0 + ) + + var desktopBookmarksSize by intPreference( + appContext.getPreferenceKey(R.string.pref_key_desktop_bookmarks_size), + 0 + ) + private var savedLoginsSortingStrategyString by stringPreference( appContext.getPreferenceKey(R.string.pref_key_saved_logins_sorting_strategy), default = SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort.strategyString 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 af5f9169c..21c56576b 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/ToolbarPopupWindow.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/ToolbarPopupWindow.kt @@ -20,6 +20,7 @@ import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.store.BrowserStore import org.mozilla.fenix.R import org.mozilla.fenix.components.FenixSnackbar +import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import java.lang.ref.WeakReference @@ -74,6 +75,7 @@ object ToolbarPopupWindow { .setText(context.getString(R.string.browser_toolbar_url_copied_to_clipboard_snackbar)) .show() } + context.components.analytics.metrics.track(Event.CopyUrlUsed) } customView.paste.setOnClickListener { diff --git a/app/src/main/res/drawable/ic_bookmark_list.xml b/app/src/main/res/drawable/ic_bookmark_list.xml new file mode 100644 index 000000000..eec7c8ffe --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_list.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/layout/checkbox_left_preference.xml b/app/src/main/res/layout/checkbox_left_preference.xml index 3fc661557..b3d33ad86 100644 --- a/app/src/main/res/layout/checkbox_left_preference.xml +++ b/app/src/main/res/layout/checkbox_left_preference.xml @@ -5,7 +5,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="48dp" + android:layout_height="wrap_content" + android:minHeight="48dp" android:layout_marginBottom="8dp" android:background="?android:selectableItemBackground" android:clickable="true" diff --git a/app/src/main/res/layout/component_downloads.xml b/app/src/main/res/layout/component_downloads.xml index a6bcbe35e..ef2a15f7e 100644 --- a/app/src/main/res/layout/component_downloads.xml +++ b/app/src/main/res/layout/component_downloads.xml @@ -25,7 +25,7 @@ android:layout_width="0dp" android:layout_height="0dp" android:gravity="center" - android:text="@string/download_empty_message" + android:text="@string/download_empty_message_1" android:textColor="?secondaryText" android:textSize="16sp" android:visibility="gone" diff --git a/app/src/main/res/layout/component_tabstray.xml b/app/src/main/res/layout/component_tabstray.xml index 20aff53f0..be2e438de 100644 --- a/app/src/main/res/layout/component_tabstray.xml +++ b/app/src/main/res/layout/component_tabstray.xml @@ -47,105 +47,104 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/infoBanner" /> - - - - - + + + + + + + + + + - - - - + + - - - - - - - - - - - + android:layout_height="match_parent" + android:contentDescription="@string/tabs_header_private_tabs_title" + android:icon="@drawable/ic_private_browsing" /> + + + + + + - -