Merge tag 'v85.1.1' into upstream-sync

pull/274/head
Adam Novak 3 years ago
commit 6d4a47921c

@ -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 #`<this releng issue>`: Pin to stable AC `<version>` 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 `<version>` 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%)

4
.gitignore vendored

@ -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__/

@ -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

@ -8,7 +8,7 @@ tasks:
- $let:
taskgraph:
branch: taskgraph
revision: 2b2622598df02bde211d8cedb334b7b22fb883a4
revision: a458418ef7cdd6778f1283926c6116966255bc24
trustDomain: mobile
in:
$let:

@ -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) {

@ -48,17 +48,6 @@
column="6"/>
</issue>
<issue
id="MozMultipleConstraintLayouts"
message="Flatten the view hierarchy by using one `ConstraintLayout`, if possible. If the alternative is several nested `ViewGroup`, it may not help performance and this may be worth suppressing."
errorLine1=" &lt;androidx.constraintlayout.widget.ConstraintLayout"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/component_tabstray.xml"
line="52"
column="6"/>
</issue>
<issue
id="MozMultipleConstraintLayouts"
message="Flatten the view hierarchy by using one `ConstraintLayout`, if possible. If the alternative is several nested `ViewGroup`, it may not help performance and this may be worth suppressing."
@ -114,17 +103,6 @@
column="14"/>
</issue>
<issue
id="MozMultipleConstraintLayouts"
message="Flatten the view hierarchy by using one `ConstraintLayout`, if possible. If the alternative is several nested `ViewGroup`, it may not help performance and this may be worth suppressing."
errorLine1=" &lt;androidx.constraintlayout.widget.ConstraintLayout"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/fragment_search_dialog.xml"
line="77"
column="6"/>
</issue>
<issue
id="MozMultipleConstraintLayouts"
message="Flatten the view hierarchy by using one `ConstraintLayout`, if possible. If the alternative is several nested `ViewGroup`, it may not help performance and this may be worth suppressing."

@ -60,5 +60,7 @@
<issue id="TooDeepLayout" severity="warning" /> <!-- depth can be customized -->
<issue id="TooManyViews" severity="warning" /> <!-- view count can be customized -->
<!-- Correctness: checks with increased severity -->
<issue id="PrivateResource" severity="error" />
</lint>

@ -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

@ -119,4 +119,4 @@
# Keep Android Lifecycle methods
# https://bugzilla.mozilla.org/show_bug.cgi?id=1596302
-keep class androidx.lifecycle.** { *; }
-keep class androidx.lifecycle.** { *; }

@ -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<NavController>? = null
fun setNavigationController(navController: NavController) {
this.navController = WeakReference(navController)
}
override fun onLoadRequest(
engineSession: EngineSession,
uri: String,

@ -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()
}

@ -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)

@ -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!!)

@ -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)
}

@ -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.

@ -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()

@ -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()
}
}
}

@ -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()

@ -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()

@ -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() {

@ -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
)
}

@ -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")))
}

@ -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
)

@ -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))
}

@ -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))

@ -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)

@ -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())

@ -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")))
}

@ -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<RecyclerView.ViewHolder>(
@ -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))))
}

@ -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))

@ -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<RecyclerView.ViewHolder>(
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())

@ -232,6 +232,9 @@
<service android:name=".media.MediaService"
android:exported="false" />
<service android:name=".media.MediaSessionService"
android:exported="false" />
<service
android:name=".customtabs.CustomTabsService"
android:exported="true"

@ -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/. */
/**
* 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);
};
/**
* 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;
// 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;
}
}
document.addEventListener('DOMContentLoaded', function () {
if (window.history.length == 1) {
document.getElementById('backButton').style.display = 'none';
} else {
document.getElementById('backButton').addEventListener('click', () => window.history.back() );
}
});
parseQuery(document.documentURI);

@ -8,6 +8,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width; user-scalable=false;" />
<meta http-equiv="Content-Security-Policy" content="default-src resource:; object-src 'none'" />
<link rel="stylesheet" type="text/css" href="shared_error_style.css" />
<link rel="stylesheet" type="text/css" href="high_risk_error_style.css" />
</head>
@ -29,14 +30,9 @@
</div>
<!-- Back Button -->
<button id="backButton" onclick="window.history.back()">Go back</button>
<button id="backButton">Go back</button>
</div>
</body>
<script src="./errorPageScripts.js"></script>
<script type="text/javascript">
if (window.history.length == 1) {
document.getElementById('backButton').style.display = 'none';
}
</script>
<script src="./highRiskErrorPages.js"></script>
</html>

@ -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);

@ -8,6 +8,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width; user-scalable=false;" />
<meta http-equiv="Content-Security-Policy" content="default-src resource:; object-src 'none'" />
<link rel="stylesheet" type="text/css" href="shared_error_style.css" />
<link
rel="stylesheet"
@ -33,13 +34,12 @@
</div>
<!-- Retry Button -->
<button id="errorTryAgain" onclick="window.location.reload()"></button>
<button id="errorTryAgain"></button>
<!-- Advanced Button -->
<button
id="advancedButton"
class="buttonSecondary hidden"
onclick="toggleAdvancedAndScroll()"
></button>
<hr id="horizontalLine" hidden />
@ -52,7 +52,6 @@
>
<button
id="advancedPanelBackButton"
onClick="window.history.back()"
></button>
</div>
<div
@ -62,7 +61,6 @@
<button
id="advancedPanelAcceptButton"
class="buttonSecondary"
onClick="acceptAndContinue(true)"
></button>
</div>
</div>
@ -100,5 +98,5 @@
}
</script>
<script src="./errorPageScripts.js"></script>
<script src="./lowMediumErrorPages.js"></script>
</html>

@ -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<NavController>? = 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)"
}
}

@ -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
}

@ -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()
}

@ -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) {

@ -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<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
@ -86,6 +87,9 @@ class TelemetryMiddleware(
}
}
}
is DownloadAction.AddDownloadAction -> {
metrics.track(Event.DownloadAdded)
}
}
next(action)

@ -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<AddonsManagementFragmentArgs>()
/**
* 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<Addon>, 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"
}
}

@ -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)

@ -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<SecureWindowFeature>()
private var fullScreenMediaFeature =
ViewBoundFeatureWrapper<MediaFullscreenOrientationFeature>()
private var fullScreenMediaSessionFeature =
ViewBoundFeatureWrapper<MediaSessionFullscreenFeature>()
private val searchFeature = ViewBoundFeatureWrapper<SearchFeature>()
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<String> = listOf(
SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE),
SupportUtils.getFirefoxAccountSumoUrl()
)
val intentSourcesList: List<SessionState.Source> = 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()
}
}
}
}

@ -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<Session>) {
override fun onCollectionCreated(title: String, sessions: List<Session>, id: Long?) {
showTabSavedToCollectionSnackbar(sessions.size, true)
}

@ -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),

@ -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

@ -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()

@ -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,

@ -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()
}
}
}

@ -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)
)
}

@ -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)
}
}
}
}

@ -40,7 +40,7 @@ class TabCollectionStorage(
/**
* A collection has been created
*/
fun onCollectionCreated(title: String, sessions: List<Session>) = Unit
fun onCollectionCreated(title: String, sessions: List<Session>, id: Long?) = Unit
/**
* Tab(s) have been added to collection
@ -63,8 +63,8 @@ class TabCollectionStorage(
}
suspend fun createCollection(title: String, sessions: List<Session>) = 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<Session>) = ioScope.launch {

@ -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) }

@ -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) {

@ -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<NoExtraKeys>(
{ MediaState.stop.record(it) }
)
is Event.MediaFullscreenState -> EventWrapper<NoExtraKeys>(
{ MediaState.fullscreen.record(it) }
)
is Event.MediaPictureInPictureState -> EventWrapper<NoExtraKeys>(
{ MediaState.pictureInPicture.record(it) }
)
is Event.InAppNotificationDownloadOpen -> EventWrapper<NoExtraKeys>(
{ DownloadNotification.inAppOpen.record(it) }
)
@ -420,6 +431,18 @@ private val Event.wrapper: EventWrapper<*>?
is Event.NotificationDownloadTryAgain -> EventWrapper<NoExtraKeys>(
{ DownloadNotification.tryAgain.record(it) }
)
is Event.DownloadAdded -> EventWrapper<NoExtraKeys>(
{ DownloadsMisc.downloadAdded.record(it) }
)
is Event.DownloadsScreenOpened -> EventWrapper<NoExtraKeys>(
{ DownloadsManagement.downloadsScreenOpened.record(it) }
)
is Event.DownloadsItemOpened -> EventWrapper<NoExtraKeys>(
{ DownloadsManagement.itemOpened.record(it) }
)
is Event.DownloadsItemDeleted -> EventWrapper<NoExtraKeys>(
{ DownloadsManagement.itemDeleted.record(it) }
)
is Event.NotificationMediaPlay -> EventWrapper<NoExtraKeys>(
{ 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<NoExtraKeys>(
{ Events.copyUrlTapped.record(it) }
)
is Event.SyncedTabOpened -> EventWrapper<NoExtraKeys>(
{ Events.syncedTabOpened.record(it) }
)
is Event.RecentlyClosedTabsOpened -> EventWrapper<NoExtraKeys>(
{ Events.recentlyClosedTabsOpened.record(it) }
)
Event.MasterPasswordMigrationDisplayed -> EventWrapper<NoExtraKeys>(
{ MasterPassword.displayed.record(it) }
@ -683,6 +717,7 @@ private val Event.wrapper: EventWrapper<*>?
class GleanMetricsService(
private val context: Context,
private val store: Lazy<BrowserStore>,
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() {

@ -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)

@ -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<SearchEngine> {
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
}
}

@ -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<SearchEngine> {
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"
}

@ -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()
}
}
}

@ -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

@ -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
))
}

@ -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

@ -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 ||

@ -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<Session, Session.Observer>? = 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

@ -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<MenuCandidate> {
return when (showOnly) {
BrowsingMode.Normal -> listOf(newTabItem)
BrowsingMode.Private -> listOf(newPrivateTabItem)
}
}
@VisibleForTesting
internal fun menuItems(toolbarPosition: ToolbarPosition): List<MenuCandidate> {
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))
}
}

@ -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
}
}

@ -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<MenuCandidate> {
return when (showOnly) {
BrowsingMode.Normal -> listOf(newTabItem)
BrowsingMode.Private -> listOf(newPrivateTabItem)
}
}
@VisibleForTesting
internal fun menuItems(toolbarPosition: ToolbarPosition): List<MenuCandidate> {
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))
}
}

@ -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<TabCounter> = WeakReference<TabCounter>(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
}
}

@ -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

@ -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))
}

@ -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

@ -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))
}
}

@ -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,

@ -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)

@ -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)
}
}

@ -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,

@ -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"
}
}

@ -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

@ -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 <T> 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 <T> identity(value: T) = value

@ -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<Session>) {
scrollAndAnimateCollection()
}
override fun onTabsAdded(tabCollection: TabCollection, sessions: List<Session>) {
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<TopSitesFeature>()
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
}

@ -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)

@ -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
* */

@ -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)

@ -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
}
}

@ -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
)
}
}

@ -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

@ -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
)
}

@ -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()
}
}

@ -47,6 +47,7 @@ class CollectionViewHolder(
}
collection_overflow_button.setOnClickListener {
interactor.onCollectionMenuOpened()
collectionMenu.menuBuilder
.build(view.context)
.show(anchor = it)

@ -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 ->

@ -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<T> : Fragment() {
@ -36,7 +35,6 @@ abstract class LibraryPageFragment<T> : Fragment() {
}
(activity as HomeActivity).browsingModeManager.mode = BrowsingMode.fromBoolean(private)
hideToolbar()
}
override fun onDetach() {

@ -45,8 +45,6 @@ interface BookmarkController {
fun handleBookmarkFolderDeletion(nodes: Set<BookmarkNode>)
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,

@ -120,12 +120,4 @@ class BookmarkFragmentInteractor(
override fun onRequestSync() {
bookmarksController.handleRequestSync()
}
override fun onStartSwipingItem() {
bookmarksController.handleStartSwipingItem()
}
override fun onStopSwipingItem() {
bookmarksController.handleStopSwipingItem()
}
}

@ -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<String> = emptyList(),
val isLoading: Boolean = true,
val isSwipeToRefreshEnabled: Boolean = true
val isLoading: Boolean = true
) : State {
sealed class Mode : SelectionHolder<BookmarkNode> {
override val selectedItems = emptySet<BookmarkNode>()
@ -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
)
)
}
}

@ -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)
}
}

@ -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<BookmarkNode> {
*
*/
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
}

@ -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<DownloadItem>) {

@ -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<DownloadItem>(), 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<DownloadItem>(), 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<DownloadItem>(), 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<DownloadItem>(), UserInteractionHan
}
private fun deleteDownloadItems(items: Set<DownloadItem>) {
metrics.track(Event.DownloadsItemDeleted)
updatePendingDownloadToDelete(items)
undoScope = CoroutineScope(IO)
undoScope?.allowUndo(
@ -175,7 +184,7 @@ class DownloadFragment : LibraryPageFragment<DownloadItem>(), 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<DownloadItem>(), 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<DownloadItem>): 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<DownloadItem>(), UserInteractionHan
filePath = item.filePath
)
}
metrics.track(Event.DownloadsItemOpened)
}
private fun getDeleteDownloadItemsOperation(items: Set<DownloadItem>): (suspend () -> Unit) {

@ -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))
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save