Merge tag 'v90.1.2' into upstream-sync

pull/420/head
Adam Novak 3 years ago
commit 2380d51808

@ -50,11 +50,4 @@
# Possible regressions throughout the app
*.pro @mozilla-mobile/Performance
*proguard* @mozilla-mobile/Performance
# Possible startup regressions
*Application.kt @mozilla-mobile/Performance
# We want to be aware of new features behind flags as well as features
# about to be enabled.
FeatureFlags.kt @mozilla-mobile/Performance
# --- PERFORMANCE END --- #

@ -105,3 +105,30 @@ jobs:
name: lintDebug report
path: app/build/reports/lint-results-debug.html
run-ui:
runs-on: macos-latest
if: github.event.pull_request.head.repo.full_name != github.repository && github.actor != 'MickeyMoz'
timeout-minutes: 60
strategy:
matrix:
api-level: [28]
target: [google_apis]
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Run subset of UI Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
target: ${{ matrix.target }}
arch: x86_64
profile: pixel_3a
script:
"./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=\
org.mozilla.fenix.ui.NavigationToolbarTest#visitURLTest"
- name: Upload Test Artifacts
uses: actions/upload-artifact@v2
with:
name: test-report
path: app/build/reports

@ -6,7 +6,7 @@ name: "Sync Strings"
on:
schedule:
- cron: '0 */4 * * *'
- cron: '0 2 * * *'
jobs:
main:
@ -15,9 +15,7 @@ jobs:
steps:
- name: "Discover Fenix Beta Version"
id: fenix-beta-version
uses: mozilla-mobile/fenix-beta-version@1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: mozilla-mobile/fenix-beta-version@1.1.0
- name: "Checkout Master Branch"
uses: actions/checkout@v2
with:

@ -198,18 +198,12 @@ android {
androidTest {
resources.srcDirs += ['src/androidTest/resources']
}
debug {
java.srcDirs = ['src/geckoNightly/java']
}
nightly {
java.srcDirs = ['src/geckoNightly/java']
}
beta {
java.srcDirs = ['src/migration/java', 'src/geckoBeta/java']
java.srcDirs = ['src/migration/java']
manifest.srcFile "src/migration/AndroidManifest.xml"
}
release {
java.srcDirs = ['src/migration/java', 'src/geckoRelease/java']
java.srcDirs = ['src/migration/java']
manifest.srcFile "src/migration/AndroidManifest.xml"
}
forkDebug {
@ -281,7 +275,7 @@ android.applicationVariants.all { variant ->
def isDebug = variant.buildType.resValues['IS_DEBUG']?.value ?: false
def useReleaseVersioning = variant.buildType.buildConfigFields['USE_RELEASE_VERSIONING']?.value ?: false
def versionName = Config.releaseVersionName(project) == "" ? Config.generateDebugVersionName() : Config.releaseVersionName(project)
def versionName = variant.buildType.name == 'nightly' ? Config.nightlyVersionName() : Config.releaseVersionName(project)
println("----------------------------------------------")
println("Variant name: " + variant.name)
@ -295,9 +289,9 @@ android.applicationVariants.all { variant ->
// The Google Play Store does not allow multiple APKs for the same app that all have the
// same version code. Therefore we need to have different version codes for our ARM and x86
// builds.
println("Version Name: " + versionName)
println("versionName override: $versionName")
variant.outputs.each { output ->
def abi = output.getFilter(OutputFile.ABI)
// We use the same version code generator, that we inherited from Fennec, across all channels - even on
@ -416,9 +410,7 @@ androidExtensions {
experimental = true
}
// Generate Kotlin code and markdown docs for the Fenix Glean metrics.
ext.gleanGenerateMarkdownDocs = true
ext.gleanDocsDirectory = "$rootDir/docs"
// Generate Kotlin code for the Fenix Glean metrics.
apply plugin: "org.mozilla.telemetry.glean-gradle-plugin"
configurations {
@ -446,13 +438,7 @@ dependencies {
jnaForTest Deps.jna
testImplementation files(configurations.jnaForTest.copyRecursive().files)
debugImplementation Deps.mozilla_browser_engine_gecko_nightly
forkDebugImplementation Deps.mozilla_browser_engine_gecko_nightly
nightlyImplementation Deps.mozilla_browser_engine_gecko_nightly
betaImplementation Deps.mozilla_browser_engine_gecko_beta
releaseImplementation Deps.mozilla_browser_engine_gecko_release
forkReleaseImplementation Deps.mozilla_browser_engine_gecko_release
implementation Deps.mozilla_browser_engine_gecko
implementation Deps.kotlin_stdlib
implementation Deps.kotlin_coroutines
@ -479,7 +465,6 @@ dependencies {
implementation Deps.mozilla_browser_icons
implementation Deps.mozilla_browser_menu
implementation Deps.mozilla_browser_menu2
implementation Deps.mozilla_browser_search
implementation Deps.mozilla_browser_session
implementation Deps.mozilla_browser_session_storage
implementation Deps.mozilla_browser_state
@ -548,6 +533,7 @@ dependencies {
implementation Deps.mozilla_ui_tabcounter
implementation Deps.mozilla_lib_crash
implementation Deps.mozilla_lib_state
implementation Deps.mozilla_lib_dataprotect
debugImplementation Deps.leakcanary
forkDebugImplementation Deps.leakcanary
@ -572,6 +558,8 @@ dependencies {
androidTestImplementation Deps.uiautomator
// Removed pending AndroidX fixes
androidTestImplementation "tools.fastlane:screengrab:2.0.0"
// This Falcon version is added to maven central now required for Screengrab
implementation 'com.jraska:falcon:2.2.0'
// androidTestImplementation "br.com.concretesolutions:kappuccino:1.2.1"
androidTestImplementation Deps.espresso_core, {

File diff suppressed because it is too large Load Diff

@ -17,7 +17,7 @@ activation:
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/1707#issuecomment-486972209
notification_emails:
- fenix-core@mozilla.com
- android-probes@mozilla.com
first-session:
description: |
@ -29,7 +29,7 @@ first-session:
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/8074#issuecomment-586512202
notification_emails:
- fenix-core@mozilla.com
- android-probes@mozilla.com
startup-timeline:
description: |

@ -9,18 +9,5 @@
<source src="../resources/audioSample.mp3">
</audio>
</div>
<div class="playbackState">
</div>
<script>
const audio = document.querySelector('audio');
audio.addEventListener('playing', (event) => {
document.querySelector('.playbackState').innerText="Media file is playing"
});
audio.addEventListener('pause', (event) => {
document.querySelector('.playbackState').innerText="Media file is paused"
});
</script>
</body>
</html>

@ -9,18 +9,5 @@
<source src="../resources/videoSample.webm">
</video>
</div>
<div class="playbackState">
</div>
<script>
const video = document.querySelector('video');
video.addEventListener('playing', (event) => {
document.querySelector('.playbackState').innerText="Media file is playing";
});
video.addEventListener('pause', (event) => {
document.querySelector('.playbackState').innerHTML="Media file is paused";
});
</script>
</body>
</html>

@ -4,12 +4,17 @@
package org.mozilla.fenix.helpers
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.browser.customtabs.CustomTabsIntent
import androidx.preference.PreferenceManager
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
@ -153,4 +158,32 @@ object TestHelper {
}
}
}
fun createCustomTabIntent(
pageUrl: String,
customMenuItemLabel: String = "",
customActionButtonDescription: String = ""
): Intent {
val appContext = InstrumentationRegistry.getInstrumentation()
.targetContext
.applicationContext
val pendingIntent = PendingIntent.getActivity(appContext, 0, Intent(), 0)
val customTabsIntent = CustomTabsIntent.Builder()
.addMenuItem(customMenuItemLabel, pendingIntent)
.setShareState(CustomTabsIntent.SHARE_STATE_ON)
.setActionButton(
createTestBitmap(),
customActionButtonDescription, pendingIntent, true
)
.build()
customTabsIntent.intent.data = Uri.parse(pageUrl)
return customTabsIntent.intent
}
private fun createTestBitmap(): Bitmap {
val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.GREEN)
return bitmap
}
}

@ -68,47 +68,38 @@ class MenuScreenShotTest : ScreenshotTest() {
}.openThreeDotMenu {
}.openSettings {
Screengrab.screenshot("SettingsRobot_settings-menu")
settingsAccountPreferences()
}.openTurnOnSyncMenu {
Screengrab.screenshot("AccountSettingsRobot_settings-account")
mDevice.pressBack()
settingsSearch()
}.goBack {
}.openSearchSubMenu {
Screengrab.screenshot("SettingsSubMenuSearchRobot_settings-search")
mDevice.pressBack()
settingsTheme()
}.goBack {
}.openCustomizeSubMenu {
Screengrab.screenshot("SettingsSubMenuThemeRobot_settings-theme")
mDevice.pressBack()
settingsAccessibility()
}.goBack {
}.openAccessibilitySubMenu {
Screengrab.screenshot("SettingsSubMenuAccessibilityRobot_settings-accessibility")
mDevice.pressBack()
settingsLanguage()
}.goBack {
}.openLanguageSubMenu {
Screengrab.screenshot("SettingsSubMenuAccessibilityRobot_settings-language")
mDevice.pressBack()
settingDefaultBrowser()
}.goBack {
// From about here we need to scroll up to ensure all settings options are visible.
}.openSetDefaultBrowserSubMenu {
Screengrab.screenshot("SettingsSubMenuDefaultBrowserRobot_settings-default-browser")
mDevice.pressBack()
// Disabled for Pixel 2
// settingsTP()
// Screengrab.screenshot("settings-enhanced-tp")
// mDevice.pressBack()
loginsAndPassword()
}.goBack {
// Disabled for Pixel 2
// }.openEnhancedTrackingProtectionSubMenu {
// Screengrab.screenshot("settings-enhanced-tp")
// }.goBack {
}.openLoginsAndPasswordSubMenu {
Screengrab.screenshot("SettingsSubMenuLoginsAndPasswords-settings-logins-passwords")
mDevice.pressBack()
}.goBack {
swipeToBottom()
Screengrab.screenshot("SettingsRobot_settings-scroll-to-bottom")
settingsTelemetry()
}.openSettingsSubMenuDataCollection {
Screengrab.screenshot("settings-telemetry")
mDevice.pressBack()
addOns()
}.goBack {
}.openAddonsManagerMenu {
Screengrab.screenshot("settings-addons")
}
}

@ -223,8 +223,7 @@ class BookmarksTest {
IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!)
}.openThreeDotMenu(defaultWebPage.url) {
}.clickOpenInNewTab {
verifyUrl(defaultWebPage.url.toString())
}.openTabDrawer {
verifyTabTrayIsOpened()
verifyNormalModeSelected()
}
}
@ -242,8 +241,7 @@ class BookmarksTest {
IdlingRegistry.getInstance().register(bookmarksListIdlingResource!!)
}.openThreeDotMenu(defaultWebPage.url) {
}.clickOpenInPrivateTab {
verifyUrl(defaultWebPage.url.toString())
}.openTabDrawer {
verifyTabTrayIsOpened()
verifyPrivateModeSelected()
}
}

@ -108,7 +108,6 @@ class DeepLinkTest {
fun openSettingsLogins() {
robot.openSettingsLogins {
verifyDefaultView()
verifyDefaultValueSyncLogins()
verifyDefaultValueAutofillLogins()
}
}

@ -149,8 +149,7 @@ class HistoryTest {
IdlingRegistry.getInstance().register(historyListIdlingResource!!)
}.openThreeDotMenu {
}.clickOpenInNormalTab {
verifyUrl(firstWebPage.url.toString())
}.openTabDrawer {
verifyTabTrayIsOpened()
verifyNormalModeSelected()
}
}
@ -170,8 +169,7 @@ class HistoryTest {
IdlingRegistry.getInstance().register(historyListIdlingResource!!)
}.openThreeDotMenu {
}.clickOpenInPrivateTab {
verifyUrl(firstWebPage.url.toString())
}.openTabDrawer {
verifyTabTrayIsOpened()
verifyPrivateModeSelected()
}
}

@ -4,13 +4,15 @@
package org.mozilla.fenix.ui
import androidx.test.uiautomator.UiSelector
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.mediasession.MediaSession
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.helpers.AndroidAssetDispatcher
import org.mozilla.fenix.helpers.HomeActivityTestRule
import org.mozilla.fenix.helpers.TestAssetHelper
@ -33,9 +35,14 @@ class MediaNotificationTest {
@get:Rule
val activityTestRule = HomeActivityTestRule()
private lateinit var browserStore: BrowserStore
@Before
fun setUp() {
// Initializing this as part of class construction, below the rule would throw a NPE
// So we are initializing this here instead of in all tests.
browserStore = activityTestRule.activity.components.core.store
mockWebServer = MockWebServer().apply {
dispatcher = AndroidAssetDispatcher()
start()
@ -45,16 +52,10 @@ class MediaNotificationTest {
@After
fun tearDown() {
mockWebServer.shutdown()
// verify if the notification tray is expanded and should be closed before the next test
val notificationShade =
mDevice.findObject(UiSelector().resourceId("com.android.systemui:id/notification_stack_scroller"))
if (notificationShade.exists())
mDevice.pressBack()
}
@Ignore("Still failing, due to https://github.com/mozilla-mobile/android-components/issues/9748")
@Test
@Ignore("https://github.com/mozilla-mobile/fenix/issues/15754")
fun videoPlaybackSystemNotificationTest() {
val videoTestPage = TestAssetHelper.getVideoPageAsset(mockWebServer)
@ -62,7 +63,7 @@ class MediaNotificationTest {
}.enterURLAndEnterToBrowser(videoTestPage.url) {
mDevice.waitForIdle()
clickMediaPlayerPlayButton()
waitForPlaybackToStart()
assertPlaybackState(browserStore, MediaSession.PlaybackState.PLAYING)
}.openNotificationShade {
verifySystemNotificationExists(videoTestPage.title)
clickMediaSystemNotificationControlButton("Pause")
@ -72,7 +73,7 @@ class MediaNotificationTest {
mDevice.pressBack()
browserScreen {
verifyMediaIsPaused()
assertPlaybackState(browserStore, MediaSession.PlaybackState.PAUSED)
}.openTabDrawer {
closeTab()
}
@ -88,60 +89,6 @@ class MediaNotificationTest {
}
@Test
@Ignore("https://github.com/mozilla-mobile/fenix/issues/15754")
fun audioPlaybackSystemNotificationTest() {
val audioTestPage = TestAssetHelper.getAudioPageAsset(mockWebServer)
navigationToolbar {
}.enterURLAndEnterToBrowser(audioTestPage.url) {
mDevice.waitForIdle()
clickMediaPlayerPlayButton()
waitForPlaybackToStart()
}.openNotificationShade {
verifySystemNotificationExists(audioTestPage.title)
clickMediaSystemNotificationControlButton("Pause")
verifyMediaSystemNotificationButtonState("Play")
}
mDevice.pressBack()
browserScreen {
verifyMediaIsPaused()
}.openTabDrawer {
closeTab()
}
mDevice.openNotification()
notificationShade {
verifySystemNotificationGone(audioTestPage.title)
}
// close notification shade before the next test
mDevice.pressBack()
}
@Test
@Ignore("https://github.com/mozilla-mobile/fenix/issues/15754")
fun tabMediaControlButtonTest() {
val audioTestPage = TestAssetHelper.getAudioPageAsset(mockWebServer)
navigationToolbar {
}.enterURLAndEnterToBrowser(audioTestPage.url) {
mDevice.waitForIdle()
clickMediaPlayerPlayButton()
waitForPlaybackToStart()
}.openTabDrawer {
verifyTabMediaControlButtonState("Pause")
clickTabMediaControlButton()
verifyTabMediaControlButtonState("Play")
}.openTab(audioTestPage.title) {
verifyMediaIsPaused()
}
}
@Test
@Ignore("https://github.com/mozilla-mobile/fenix/issues/15754")
fun mediaSystemNotificationInPrivateModeTest() {
val audioTestPage = TestAssetHelper.getAudioPageAsset(mockWebServer)
@ -151,7 +98,7 @@ class MediaNotificationTest {
}.enterURLAndEnterToBrowser(audioTestPage.url) {
mDevice.waitForIdle()
clickMediaPlayerPlayButton()
waitForPlaybackToStart()
assertPlaybackState(browserStore, MediaSession.PlaybackState.PLAYING)
}.openNotificationShade {
verifySystemNotificationExists("A site is playing media")
clickMediaSystemNotificationControlButton("Pause")
@ -161,7 +108,7 @@ class MediaNotificationTest {
mDevice.pressBack()
browserScreen {
verifyMediaIsPaused()
assertPlaybackState(browserStore, MediaSession.PlaybackState.PAUSED)
}.openTabDrawer {
closeTab()
verifySnackBarText("Private tab closed")

@ -32,6 +32,7 @@ class SearchTest {
verifySearchView()
verifyBrowserToolbar()
verifyScanButton()
verifySearchEngineButton()
}
}

@ -147,6 +147,12 @@ class SettingsPrivacyTest {
verifyDeleteBrowsingDataOnQuitSubMenuItems()
}.goBack {
// NOTIFICATIONS
verifyNotificationsButton()
}.openSettingsSubMenuNotifications {
verifySystemNotificationsView()
}.goBack {
// DATA COLLECTION
verifyDataCollectionButton()
}.openSettingsSubMenuDataCollection {
@ -170,7 +176,6 @@ class SettingsPrivacyTest {
TestHelper.scrollToElementByText("Logins and passwords")
}.openLoginsAndPasswordSubMenu {
verifyDefaultView()
verifyDefaultValueSyncLogins()
verifyDefaultValueAutofillLogins()
verifyDefaultValueExceptions()
}.openSavedLogins {
@ -203,7 +208,6 @@ class SettingsPrivacyTest {
TestHelper.scrollToElementByText("Logins and passwords")
}.openLoginsAndPasswordSubMenu {
verifyDefaultView()
verifyDefaultValueSyncLogins()
}.openSavedLogins {
verifySecurityPromptForLogins()
tapSetupLater()
@ -228,7 +232,6 @@ class SettingsPrivacyTest {
}.openSettings {
}.openLoginsAndPasswordSubMenu {
verifyDefaultView()
verifyDefaultValueSyncLogins()
}.openSavedLogins {
verifySecurityPromptForLogins()
tapSetupLater()

@ -9,8 +9,11 @@ import androidx.core.net.toUri
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.IdlingRegistry
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.UiDevice
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.mediasession.MediaSession
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
@ -18,23 +21,29 @@ import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.IntentReceiverActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.helpers.AndroidAssetDispatcher
import org.mozilla.fenix.helpers.HomeActivityTestRule
import org.mozilla.fenix.helpers.RecyclerViewIdlingResource
import org.mozilla.fenix.helpers.TestAssetHelper
import org.mozilla.fenix.helpers.TestHelper
import org.mozilla.fenix.helpers.TestHelper.createCustomTabIntent
import org.mozilla.fenix.helpers.TestHelper.deleteDownloadFromStorage
import org.mozilla.fenix.helpers.TestHelper.scrollToElementByText
import org.mozilla.fenix.helpers.ViewVisibilityIdlingResource
import org.mozilla.fenix.ui.robots.browserScreen
import org.mozilla.fenix.ui.robots.clickTabCrashedRestoreButton
import org.mozilla.fenix.ui.robots.clickUrlbar
import org.mozilla.fenix.ui.robots.customTabScreen
import org.mozilla.fenix.ui.robots.dismissTrackingOnboarding
import org.mozilla.fenix.ui.robots.downloadRobot
import org.mozilla.fenix.ui.robots.enhancedTrackingProtection
import org.mozilla.fenix.ui.robots.homeScreen
import org.mozilla.fenix.ui.robots.navigationToolbar
import org.mozilla.fenix.ui.robots.notificationShade
import org.mozilla.fenix.ui.robots.tabDrawer
import org.mozilla.fenix.ui.util.STRING_ONBOARDING_TRACKING_PROTECTION_HEADER
@ -52,8 +61,9 @@ class SmokeTest {
private var recentlyClosedTabsListIdlingResource: RecyclerViewIdlingResource? = null
private var readerViewNotification: ViewVisibilityIdlingResource? = null
private val downloadFileName = "Globe.svg"
val collectionName = "First Collection"
private val collectionName = "First Collection"
private var bookmarksListIdlingResource: RecyclerViewIdlingResource? = null
private val customMenuItem = "TestMenuItem"
// This finds the dialog fragment child of the homeFragment, otherwise the awesomeBar would return null
private fun getAwesomebarView(): View? {
@ -66,6 +76,12 @@ class SmokeTest {
@get:Rule
val activityTestRule = HomeActivityTestRule()
private lateinit var browserStore: BrowserStore
@get: Rule
val intentReceiverActivityTestRule = ActivityTestRule(
IntentReceiverActivity::class.java, true, false
)
@get:Rule
var mGrantPermissions = GrantPermissionRule.grant(
@ -75,6 +91,10 @@ class SmokeTest {
@Before
fun setUp() {
// Initializing this as part of class construction, below the rule would throw a NPE
// So we are initializing this here instead of in all related tests.
browserStore = activityTestRule.activity.components.core.store
mockWebServer = MockWebServer().apply {
dispatcher = AndroidAssetDispatcher()
start()
@ -112,7 +132,7 @@ class SmokeTest {
}
}
// copied over from HomeScreenTest
// Verifies the first run onboarding screen
@Test
fun firstRunScreenTest() {
homeScreen {
@ -155,7 +175,6 @@ class SmokeTest {
}
@Test
@Ignore("https://github.com/mozilla-mobile/fenix/issues/18603")
// Verifies the functionality of the onboarding Start Browsing button
fun startBrowsingButtonTest() {
homeScreen {
@ -525,7 +544,6 @@ class SmokeTest {
}
}
@Ignore("Failing, see: https://github.com/mozilla-mobile/fenix/issues/17847")
@Test
// Swipes the nav bar left/right to switch between tabs
fun swipeToSwitchTabTest() {
@ -1062,34 +1080,6 @@ class SmokeTest {
}
}
@Ignore("Feature is temporarily removed; disabling test. See https://github.com/mozilla-mobile/fenix/issues/18656")
@Test
fun selectTabsButtonVisibilityTest() {
homeScreen {
}.dismissOnboarding()
val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)
val secondWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 2)
navigationToolbar {
}.enterURLAndEnterToBrowser(firstWebPage.url) {
mDevice.waitForIdle()
}.openTabDrawer {
}.openNewTab {
}.submitQuery(secondWebPage.url.toString()) {
mDevice.waitForIdle()
}.openTabDrawer {
}.toggleToPrivateTabs {
}.openNewTab {
}.dismissSearchBar { }
homeScreen {
}.openTabDrawer {
}.toggleToNormalTabs {
verifySelectTabsButton()
}
}
@Test
fun privateTabsTrayWithOpenedTabTest() {
val website = TestAssetHelper.getGenericAsset(mockWebServer, 1)
@ -1230,4 +1220,104 @@ class SmokeTest {
verifyPageContent(website.content)
}
}
@Test
// Verifies the main menu of a custom tab with a custom menu item
fun customTabMenuItemsTest() {
val customTabPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)
intentReceiverActivityTestRule.launchActivity(
createCustomTabIntent(
customTabPage.url.toString(),
customMenuItem
)
)
customTabScreen {
browserScreen {
verifyPageContent(customTabPage.content)
}
}.openMainMenu {
verifyPoweredByTextIsDisplayed()
verifyCustomMenuItem(customMenuItem)
verifyDesktopSiteButtonExists()
verifyFindInPageButtonExists()
verifyOpenInBrowserButtonExists()
verifyBackButtonExists()
verifyForwardButtonExists()
verifyRefreshButtonExists()
}
}
@Test
// The test opens a link in a custom tab then sends it to the browser
fun openCustomTabInBrowserTest() {
val customTabPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)
intentReceiverActivityTestRule.launchActivity(
createCustomTabIntent(
customTabPage.url.toString()
)
)
customTabScreen {
browserScreen {
verifyPageContent(customTabPage.content)
}
}.openMainMenu {
}.clickOpenInBrowserButton {
verifyTabCounter("1")
}
}
@Test
fun audioPlaybackSystemNotificationTest() {
val audioTestPage = TestAssetHelper.getAudioPageAsset(mockWebServer)
navigationToolbar {
}.enterURLAndEnterToBrowser(audioTestPage.url) {
mDevice.waitForIdle()
clickMediaPlayerPlayButton()
assertPlaybackState(browserStore, MediaSession.PlaybackState.PLAYING)
}.openNotificationShade {
verifySystemNotificationExists(audioTestPage.title)
clickMediaSystemNotificationControlButton("Pause")
verifyMediaSystemNotificationButtonState("Play")
}
mDevice.pressBack()
browserScreen {
assertPlaybackState(browserStore, MediaSession.PlaybackState.PAUSED)
}.openTabDrawer {
closeTab()
}
mDevice.openNotification()
notificationShade {
verifySystemNotificationGone(audioTestPage.title)
}
// close notification shade before the next test
mDevice.pressBack()
}
@Test
fun tabMediaControlButtonTest() {
val audioTestPage = TestAssetHelper.getAudioPageAsset(mockWebServer)
navigationToolbar {
}.enterURLAndEnterToBrowser(audioTestPage.url) {
mDevice.waitForIdle()
clickMediaPlayerPlayButton()
assertPlaybackState(browserStore, MediaSession.PlaybackState.PLAYING)
}.openTabDrawer {
verifyTabMediaControlButtonState("Pause")
clickTabMediaControlButton()
verifyTabMediaControlButtonState("Play")
}.openTab(audioTestPage.title) {
assertPlaybackState(browserStore, MediaSession.PlaybackState.PAUSED)
}
}
}

@ -9,6 +9,7 @@ package org.mozilla.fenix.ui.robots
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import android.widget.EditText
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
@ -33,10 +34,13 @@ import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.mediasession.MediaSession
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.Matchers.not
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.helpers.Constants.LONG_CLICK_DURATION
@ -371,14 +375,33 @@ class BrowserRobot {
mediaPlayerPlayButton().click()
}
fun waitForPlaybackToStart() {
val playStateMessage = mDevice.findObject(UiSelector().text("Media file is playing"))
assertTrue(playStateMessage.waitForExists(waitingTime))
}
fun verifyMediaIsPaused() {
val pausedStateMessage = mDevice.findObject(UiSelector().text("Media file is paused"))
assertTrue(pausedStateMessage.waitForExists(waitingTime))
/**
* Get the current playback state of the currently selected tab.
* The result may be null if there if the currently playing media tab cannot be found in [store]
*
* @param store [BrowserStore] from which to get data about the current tab's state.
* @return nullable [MediaSession.PlaybackState] indicating the media playback state for the current tab.
*/
private fun getCurrentPlaybackState(store: BrowserStore): MediaSession.PlaybackState? {
return store.state.selectedTab?.mediaSessionState?.playbackState
}
/**
* Asserts that in [waitingTime] the playback state of the current tab will be [expectedState].
*
* @param store [BrowserStore] from which to get data about the current tab's state.
* @param expectedState [MediaSession.PlaybackState] the playback state that will be asserted
* @param waitingTime maximum time the test will wait for the playback state to become [expectedState]
* before failing the assertion.
*/
fun assertPlaybackState(store: BrowserStore, expectedState: MediaSession.PlaybackState) {
val startMills = SystemClock.uptimeMillis()
var currentMills: Long = 0
while (currentMills <= waitingTime) {
if (expectedState == getCurrentPlaybackState(store)) return
currentMills = SystemClock.uptimeMillis() - startMills
}
fail("Playback did not moved to state: $expectedState")
}
fun swipeNavBarRight(tabUrl: String) {

@ -0,0 +1,84 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.ui.robots
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.uiautomator.UiSelector
import junit.framework.TestCase.assertTrue
import mozilla.components.support.ktx.android.content.appName
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime
/**
* Implementation of the robot pattern for Custom tabs
*/
class CustomTabRobot {
fun verifyDesktopSiteButtonExists() {
desktopSiteButton().check(matches(isDisplayed()))
}
fun verifyFindInPageButtonExists() {
findInPageButton().check(matches(isDisplayed()))
}
fun verifyPoweredByTextIsDisplayed() {
mDevice.findObject(UiSelector().textContains("POWERED BY ${appContext.appName}"))
}
fun verifyOpenInBrowserButtonExists() {
openInBrowserButton().check(matches(isDisplayed()))
}
fun verifyBackButtonExists() = assertTrue(backButton().waitForExists(waitingTime))
fun verifyForwardButtonExists() = assertTrue(forwardButton().waitForExists(waitingTime))
fun verifyRefreshButtonExists() = assertTrue(refreshButton().waitForExists(waitingTime))
fun verifyCustomMenuItem(label: String) {
assertTrue(mDevice.findObject(UiSelector().text(label)).exists())
}
class Transition {
fun openMainMenu(interact: CustomTabRobot.() -> Unit): Transition {
mainMenuButton().waitForExists(waitingTime)
mainMenuButton().click()
CustomTabRobot().interact()
return Transition()
}
fun clickOpenInBrowserButton(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
openInBrowserButton().perform(click())
BrowserRobot().interact()
return BrowserRobot.Transition()
}
}
}
fun customTabScreen(interact: CustomTabRobot.() -> Unit): CustomTabRobot.Transition {
CustomTabRobot().interact()
return CustomTabRobot.Transition()
}
private fun mainMenuButton() = mDevice.findObject(UiSelector().description("Menu"))
private fun desktopSiteButton() = onView(withId(R.id.switch_widget))
private fun findInPageButton() = onView(withText("Find in page"))
private fun openInBrowserButton() = onView(withText("Open in ${appContext.appName}"))
private fun refreshButton() = mDevice.findObject(UiSelector().description("Refresh"))
private fun forwardButton() = mDevice.findObject(UiSelector().description("Forward"))
private fun backButton() = mDevice.findObject(UiSelector().description("Back"))

@ -11,7 +11,6 @@ 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.swipeLeft
@ -24,7 +23,6 @@ import androidx.test.espresso.matcher.ViewMatchers.Visibility
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.hasSibling
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.withHint
import androidx.test.espresso.matcher.ViewMatchers.withId
@ -33,10 +31,12 @@ import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.By.text
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject
import androidx.test.uiautomator.UiScrollable
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until
import androidx.test.uiautomator.Until.findObject
import junit.framework.TestCase.assertTrue
import mozilla.components.browser.state.state.searchEngines
import mozilla.components.support.ktx.android.content.appName
import org.hamcrest.CoreMatchers.allOf
@ -240,12 +240,6 @@ class HomeScreenRobot {
}
}
fun scrollToElementByText(text: String): UiScrollable {
val appView = UiScrollable(UiSelector().scrollable(true))
appView.scrollTextIntoView(text)
return appView
}
fun togglePrivateBrowsingModeOnOff() {
onView(ViewMatchers.withResourceName("privateBrowsingButton"))
.perform(click())
@ -256,12 +250,6 @@ class HomeScreenRobot {
fun swipeToTop() =
onView(withId(R.id.sessionControlRecyclerView)).perform(ViewActions.swipeDown())
fun swipeTabRight(title: String) =
tab(title).perform(ViewActions.swipeRight())
fun swipeTabLeft(title: String) =
tab(title).perform(ViewActions.swipeLeft())
fun verifySnackBarText(expectedText: String) {
val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
mDevice.waitNotNull(findObject(By.text(expectedText)), waitingTime)
@ -273,21 +261,6 @@ class HomeScreenRobot {
).perform(click())
}
fun verifyTabMediaControlButtonState(action: String) {
mDevice.waitNotNull(
findObject(
By
.res("org.mozilla.fenix.debug:id/play_pause_button")
.desc(action)
),
waitingTime
)
tabMediaControlButton().check(matches(withContentDescription(action)))
}
fun clickTabMediaControlButton() = tabMediaControlButton().click()
class Transition {
val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@ -314,6 +287,8 @@ class HomeScreenRobot {
}
fun openSearch(interact: SearchRobot.() -> Unit): SearchRobot.Transition {
mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar"))
.waitForExists(waitingTime)
navigationToolbar().perform(click())
SearchRobot().interact()
@ -325,7 +300,6 @@ class HomeScreenRobot {
}
fun clickStartBrowsingButton(interact: SearchRobot.() -> Unit): SearchRobot.Transition {
scrollToElementByText("Start browsing")
startBrowsingButton().click()
SearchRobot().interact()
@ -333,7 +307,11 @@ class HomeScreenRobot {
}
fun togglePrivateBrowsingMode() {
onView(ViewMatchers.withResourceName("privateBrowsingButton"))
mDevice.findObject(UiSelector().resourceId("$packageName:id/privateBrowsingButton"))
.waitForExists(
waitingTime
)
privateBrowsingButton()
.perform(click())
}
@ -342,10 +320,10 @@ class HomeScreenRobot {
for (i in 1..5) {
mDevice.findObject(UiSelector().resourceId("$packageName:id/privateBrowsingButton"))
.waitForExists(
waitingTime
waitingTime
)
onView(ViewMatchers.withResourceName("privateBrowsingButton"))
privateBrowsingButton()
.perform(click())
}
@ -357,25 +335,11 @@ class HomeScreenRobot {
onView(ViewMatchers.isRoot()).perform(ViewActions.pressBack())
}
fun openTabsListThreeDotMenu(interact: ThreeDotMenuMainRobot.() -> Unit): ThreeDotMenuMainRobot.Transition {
// tabsListThreeDotButton().perform(click())
ThreeDotMenuMainRobot().interact()
return ThreeDotMenuMainRobot.Transition()
}
fun closeAllPrivateTabs(interact: HomeScreenRobot.() -> Unit): Transition {
onView(withId(R.id.close_tabs_button))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
.perform(click())
HomeScreenRobot().interact()
return Transition()
}
fun openNavigationToolbar(interact: NavigationToolbarRobot.() -> Unit): NavigationToolbarRobot.Transition {
mDevice.findObject(UiSelector().resourceId("$packageName:id/toolbar"))
.waitForExists(waitingTime)
navigationToolbar().perform(click())
assertNavigationToolbar().perform(click())
NavigationToolbarRobot().interact()
return NavigationToolbarRobot.Transition()
}
@ -462,6 +426,13 @@ fun homeScreen(interact: HomeScreenRobot.() -> Unit): HomeScreenRobot.Transition
val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
private fun homeScreenList() =
UiScrollable(
UiSelector()
.resourceId("$packageName:id/sessionControlRecyclerView")
.scrollable(true)
).setAsVerticalList()
private fun assertKeyboardVisibility(isExpectedToBeVisible: Boolean) =
Assert.assertEquals(
isExpectedToBeVisible,
@ -470,14 +441,10 @@ private fun assertKeyboardVisibility(isExpectedToBeVisible: Boolean) =
.contains("mInputShown=true")
)
private fun navigationToolbar() =
onView(allOf(withText("Search or enter address")))
private fun closeTabButton() = onView(withId(R.id.close_tab_button))
private fun navigationToolbar() = onView(withId(R.id.toolbar))
private fun assertNavigationToolbar() =
onView(allOf(withText("Search or enter address")))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
navigationToolbar().check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
private fun assertFocusedNavigationToolbar() =
onView(allOf(withHint("Search or enter address")))
@ -493,8 +460,8 @@ private fun assertHomeMenu() = onView(ViewMatchers.withResourceName("menuButton"
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
private fun assertHomePrivateBrowsingButton() =
onView(ViewMatchers.withResourceName("privateBrowsingButton"))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
privateBrowsingButton()
.check(matches(isDisplayed()))
private fun assertHomeWordmark() = onView(ViewMatchers.withResourceName("wordmark"))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
@ -644,9 +611,7 @@ private fun assertPrivacyNoticeButton() {
}
private fun assertStartBrowsingButton() {
scrollToElementByText("Start browsing")
onView(withId(R.id.finish_button))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
assertTrue(startBrowsingButton().waitForExists(waitingTime))
}
// Pick your toolbar placement
@ -711,7 +676,7 @@ private fun assertShareTabsOverlay() {
onView(withId(R.id.share_tab_url)).check(matches(isDisplayed()))
}
private fun tabMediaControlButton() = onView(withId(R.id.play_pause_button))
private fun privateBrowsingButton() = onView(withId(R.id.privateBrowsingButton))
private fun collectionItem(title: String) =
onView(allOf(withId(R.id.label), withText(title)))
@ -730,15 +695,11 @@ private fun removeTabFromCollectionButton(title: String) =
private fun tabsCounter() = onView(withId(R.id.tab_button))
private fun tab(title: String) =
onView(
allOf(
withId(R.id.tab_title),
withText(title)
)
)
private fun startBrowsingButton(): ViewInteraction {
scrollToElementByText("Start browsing")
return onView(allOf(withText("Start browsing")))
private fun startBrowsingButton(): UiObject {
val startBrowsingButton = mDevice.findObject(UiSelector().resourceId("$packageName:id/finish_button"))
homeScreenList()
.scrollIntoView(startBrowsingButton)
homeScreenList()
.ensureFullyVisible(startBrowsingButton)
return startBrowsingButton
}

@ -49,6 +49,7 @@ class SearchRobot {
fun verifySearchView() = assertSearchView()
fun verifyBrowserToolbar() = assertBrowserToolbarEditView()
fun verifyScanButton() = assertScanButton()
fun verifySearchEngineButton() = assertSearchEngineButton()
fun verifySearchWithText() = assertSearchWithText()
fun verifySearchEngineResults(searchEngineName: String) =
assertSearchEngineResults(searchEngineName)
@ -215,7 +216,7 @@ private fun assertSearchEngineResults(searchEngineName: String) {
}
private fun assertSearchView() {
onView(allOf(withId(R.id.search_layout)))
onView(withId(R.id.search_wrapper)).check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
}
private fun assertBrowserToolbarEditView() =
@ -226,6 +227,10 @@ private fun assertScanButton() =
onView(allOf(withText("Scan")))
.check(matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
private fun assertSearchEngineButton() =
onView(withId(R.id.search_engines_shortcut_button))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
private fun assertSearchWithText() =
onView(allOf(withText("THIS TIME, SEARCH WITH:")))
.check(matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))

@ -9,10 +9,10 @@ package org.mozilla.fenix.ui.robots
import android.content.pm.PackageManager
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.intent.matcher.IntentMatchers.toPackage
@ -70,6 +70,7 @@ class SettingsRobot {
fun verifyDeleteBrowsingDataOnQuitButton() = assertDeleteBrowsingDataOnQuitButton()
fun verifyDeleteBrowsingDataOnQuitValue(state: String) =
assertDeleteBrowsingDataValue(state)
fun verifyNotificationsButton() = assertNotificationsButton()
fun verifyDataCollectionButton() = assertDataCollectionButton()
fun verifyOpenLinksInAppsButton() = assertOpenLinksInAppsButton()
fun verifyOpenLinksInAppsSwitchDefault() = assertOpenLinksInAppsValue()
@ -154,6 +155,24 @@ class SettingsRobot {
return SettingsSubMenuAccessibilityRobot.Transition()
}
fun openLanguageSubMenu(interact: SettingsSubMenuLanguageRobot.() -> Unit): SettingsSubMenuLanguageRobot.Transition {
fun languageButton() = onView(withText("Language"))
languageButton().click()
SettingsSubMenuLanguageRobot().interact()
return SettingsSubMenuLanguageRobot.Transition()
}
fun openSetDefaultBrowserSubMenu(interact: SettingsSubMenuSetDefaultBrowserRobot.() -> Unit): SettingsSubMenuSetDefaultBrowserRobot.Transition {
scrollToElementByText("Set as default browser")
fun setDefaultBrowserButton() = onView(withText("Set as default browser"))
setDefaultBrowserButton().click()
SettingsSubMenuSetDefaultBrowserRobot().interact()
return SettingsSubMenuSetDefaultBrowserRobot.Transition()
}
fun openEnhancedTrackingProtectionSubMenu(interact: SettingsSubMenuEnhancedTrackingProtectionRobot.() -> Unit): SettingsSubMenuEnhancedTrackingProtectionRobot.Transition {
scrollToElementByText("Enhanced Tracking Protection")
fun enhancedTrackingProtectionButton() =
@ -217,6 +236,15 @@ class SettingsRobot {
return SettingsSubMenuDeleteBrowsingDataOnQuitRobot.Transition()
}
fun openSettingsSubMenuNotifications(interact: SystemSettingsRobot.() -> Unit): SystemSettingsRobot.Transition {
scrollToElementByText("Notifications")
fun notificationsButton() = mDevice.findObject(textContains("Notifications"))
notificationsButton().click()
SystemSettingsRobot().interact()
return SystemSettingsRobot.Transition()
}
fun openSettingsSubMenuDataCollection(interact: SettingsSubMenuDataCollectionRobot.() -> Unit): SettingsSubMenuDataCollectionRobot.Transition {
scrollToElementByText("Data collection")
fun dataCollectionButton() = mDevice.findObject(textContains("Data collection"))
@ -365,8 +393,17 @@ private fun assertDeleteBrowsingDataValue(state: String) {
onView(withText(state)).check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
}
private fun assertDataCollectionButton() = onView(withText("Data collection"))
private fun assertNotificationsButton() {
scrollToElementByText("Notifications")
onView(withText("Notifications"))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
}
private fun assertDataCollectionButton() {
scrollToElementByText("Data collection")
onView(withText("Data collection"))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
}
private fun openLinksInAppsButton() = onView(withText("Open links in apps"))

@ -0,0 +1,29 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.ui.robots
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import org.hamcrest.CoreMatchers
class SettingsSubMenuLanguageRobot {
class Transition {
val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
fun goBack(interact: SettingsRobot.() -> Unit): SettingsRobot.Transition {
mDevice.waitForIdle()
goBackButton().perform(ViewActions.click())
SettingsRobot().interact()
return SettingsRobot.Transition()
}
}
}
private fun goBackButton() =
onView(CoreMatchers.allOf(ViewMatchers.withContentDescription("Navigate up")))

@ -26,7 +26,7 @@ import org.mozilla.fenix.helpers.ext.waitNotNull
class SettingsSubMenuLoginsAndPasswordRobot {
fun verifyDefaultView() {
mDevice.waitNotNull(Until.findObjects(By.text("Sync logins")), TestAssetHelper.waitingTime)
mDevice.waitNotNull(Until.findObjects(By.text("Sync logins across devices")), TestAssetHelper.waitingTime)
assertDefaultView()
}
@ -42,8 +42,6 @@ class SettingsSubMenuLoginsAndPasswordRobot {
fun verifyDefaultValueAutofillLogins() = assertDefaultValueAutofillLogins()
fun verifyDefaultValueSyncLogins() = assertDefaultValueSyncLogins()
class Transition {
val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@ -71,7 +69,7 @@ class SettingsSubMenuLoginsAndPasswordRobot {
}
fun openSyncLogins(interact: SettingsTurnOnSyncRobot.() -> Unit): SettingsTurnOnSyncRobot.Transition {
fun syncLoginsButton() = onView(ViewMatchers.withText("Sync logins"))
fun syncLoginsButton() = onView(ViewMatchers.withText("Sync logins across devices"))
syncLoginsButton().click()
SettingsTurnOnSyncRobot().interact()
@ -96,7 +94,7 @@ fun settingsSubMenuLoginsAndPassword(interact: SettingsSubMenuLoginsAndPasswordR
private fun goBackButton() =
onView(CoreMatchers.allOf(ViewMatchers.withContentDescription("Navigate up")))
private fun assertDefaultView() = onView(ViewMatchers.withText("Sync logins"))
private fun assertDefaultView() = onView(ViewMatchers.withText("Sync logins across devices"))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
private fun assertDefaultValueAutofillLogins() = onView(ViewMatchers.withText("Autofill"))

@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.ui.robots
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
class SettingsSubMenuSetDefaultBrowserRobot {
class Transition {
val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
fun goBack(interact: SettingsRobot.() -> Unit): SettingsRobot.Transition {
mDevice.waitForIdle()
// We are now in system settings / showing a default browser dialog.
// Really want to go back to the app. Not interested in up navigation like in other robots.
mDevice.pressBack()
SettingsRobot().interact()
return SettingsRobot.Transition()
}
}
}

@ -36,11 +36,11 @@ class SettingsTurnOnSyncRobot {
class Transition {
val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
fun goBack(interact: SettingsSubMenuLoginsAndPasswordRobot.() -> Unit): SettingsSubMenuLoginsAndPasswordRobot.Transition {
fun goBack(interact: SettingsSubMenuLoginsAndPasswordRobot.() -> Unit): SettingsRobot.Transition {
goBackButton().perform(ViewActions.click())
SettingsSubMenuLoginsAndPasswordRobot().interact()
return SettingsSubMenuLoginsAndPasswordRobot.Transition()
return SettingsRobot.Transition()
}
}
}

@ -6,8 +6,12 @@ package org.mozilla.fenix.ui.robots
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.uiautomator.UiSelector
import org.junit.Assert.assertTrue
import org.mozilla.fenix.helpers.TestAssetHelper.waitingTime
class SystemSettingsRobot {
fun verifySystemNotificationsView() = assertSystemNotificationsView()
fun verifyNotifications() {
Intents.intended(hasAction("android.settings.APP_NOTIFICATION_SETTINGS"))
}
@ -18,6 +22,12 @@ class SystemSettingsRobot {
class Transition {
// Difficult to know where this will go
fun goBack(interact: SettingsRobot.() -> Unit): SettingsRobot.Transition {
mDevice.pressBack()
SettingsRobot().interact()
return SettingsRobot.Transition()
}
}
}
@ -25,3 +35,11 @@ fun systemSettings(interact: SystemSettingsRobot.() -> Unit): SystemSettingsRobo
SystemSettingsRobot().interact()
return SystemSettingsRobot.Transition()
}
private fun assertSystemNotificationsView() {
mDevice.findObject(UiSelector().resourceId("com.android.settings:id/list"))
.waitForExists(waitingTime)
assertTrue(mDevice.findObject(UiSelector().textContains("Show notifications"))
.waitForExists(waitingTime)
)
}

@ -80,6 +80,7 @@ class TabDrawerRobot {
fun verifySelectTabsButton() = assertSelectTabsButton()
fun verifyTabTrayOverflowMenu(visibility: Boolean) = assertTabTrayOverflowButton(visibility)
fun verifyTabTrayIsOpened() = assertTabTrayDoesExist()
fun verifyTabTrayIsClosed() = assertTabTrayDoesNotExist()
fun verifyHalfExpandedRatio() = assertMinisculeHalfExpandedRatio()
fun verifyBehaviorState(expectedState: Int) = assertBehaviorState(expectedState)
@ -421,6 +422,11 @@ private fun assertTabTrayOverflowButton(visible: Boolean) =
onView(withId(R.id.tab_tray_overflow))
.check(matches(withEffectiveVisibility(visibleOrGone(visible))))
private fun assertTabTrayDoesExist() {
onView(withId(R.id.tab_wrapper))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
}
private fun assertTabTrayDoesNotExist() {
onView(withId(R.id.tab_wrapper))
.check(doesNotExist())

@ -38,18 +38,18 @@ class ThreeDotMenuBookmarksRobot {
return BookmarksRobot.Transition()
}
fun clickOpenInNewTab(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
fun clickOpenInNewTab(interact: TabDrawerRobot.() -> Unit): TabDrawerRobot.Transition {
openInNewTabButton().click()
BrowserRobot().interact()
return BrowserRobot.Transition()
TabDrawerRobot().interact()
return TabDrawerRobot.Transition()
}
fun clickOpenInPrivateTab(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
fun clickOpenInPrivateTab(interact: TabDrawerRobot.() -> Unit): TabDrawerRobot.Transition {
openInPrivateTabButton().click()
BrowserRobot().interact()
return BrowserRobot.Transition()
TabDrawerRobot().interact()
return TabDrawerRobot.Transition()
}
fun clickDelete(interact: BookmarksRobot.() -> Unit): BookmarksRobot.Transition {

@ -41,16 +41,16 @@ class ThreeDotMenuHistoryItemRobot {
return LibrarySubMenusMultipleSelectionToolbarRobot.Transition()
}
fun clickOpenInNormalTab(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
fun clickOpenInNormalTab(interact: TabDrawerRobot.() -> Unit): TabDrawerRobot.Transition {
openInNewNormalTabButton().click()
BrowserRobot().interact()
return BrowserRobot.Transition()
TabDrawerRobot().interact()
return TabDrawerRobot.Transition()
}
fun clickOpenInPrivateTab(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
fun clickOpenInPrivateTab(interact: TabDrawerRobot.() -> Unit): TabDrawerRobot.Transition {
openInNewPrivateTabButton().click()
BrowserRobot().interact()
return BrowserRobot.Transition()
TabDrawerRobot().interact()
return TabDrawerRobot.Transition()
}
fun clickDelete(interact: HistoryRobot.() -> Unit): HistoryRobot.Transition {

@ -0,0 +1,236 @@
{
"data": [{
"slug": "feature-text-variables-validation-android",
"appId": "org.mozilla.fenix",
"appName": "fenix",
"channel": "nightly",
"branches": [{
"slug": "control",
"ratio": 100,
"feature": {
"value": {},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "a1",
"ratio": 0,
"feature": {
"value": {
"settings-title": "settings_title",
"settings-title-punctuation": "…"
},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "a2",
"ratio": 0,
"feature": {
"value": {
"settings-title": "preferences_category_general",
"settings-title-punctuation": "!"
},
"enabled": true,
"featureId": "nimbus-validation"
}
}
],
"outcomes": [],
"arguments": {},
"probeSets": [],
"startDate": null,
"targeting": "true",
"featureIds": [
"nimbus-validation"
],
"application": "org.mozilla.firefox_beta",
"bucketConfig": {
"count": 0,
"start": 0,
"total": 10000,
"namespace": "nimbus-validation-2",
"randomizationUnit": "nimbus_id"
},
"schemaVersion": "1.5.0",
"userFacingName": "Nimbus Text Variables Validation",
"referenceBranch": "control",
"proposedDuration": 14,
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "Demonstration experiment to make trivial visible changes to text in Settings",
"last_modified": 1621443780172
},
{
"slug": "feature-icon-variables-validation-android",
"appId": "org.mozilla.fenix",
"appName": "fenix",
"channel": "nightly",
"branches": [{
"slug": "control",
"ratio": 100,
"feature": {
"value": {},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "treatment",
"ratio": 0,
"feature": {
"value": {
"settings-title": "Fancy Settings",
"settings-icon": "ic_edit"
},
"enabled": true,
"featureId": "nimbus-validation"
}
}
],
"outcomes": [],
"arguments": {},
"probeSets": [],
"startDate": null,
"targeting": "true",
"featureIds": [
"nimbus-validation"
],
"application": "org.mozilla.firefox_beta",
"bucketConfig": {
"count": 0,
"start": 0,
"total": 10000,
"namespace": "nimbus-validation-2",
"randomizationUnit": "nimbus_id"
},
"schemaVersion": "1.5.0",
"userFacingName": "Nimbus Icon Variables Validation",
"referenceBranch": "control",
"proposedDuration": 14,
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "Demonstration experiment to make trivial visible changes to icons in Settings",
"last_modified": 1621443780172
},
{
"slug": "feature-text-variables-validation-ios",
"appId": "org.mozilla.ios.Fennec",
"appName": "firefox_ios",
"channel": "nightly",
"branches": [{
"slug": "control",
"ratio": 100,
"feature": {
"value": {},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "a1",
"ratio": 0,
"feature": {
"value": {
"settings-title": "Menu/Menu.OpenSettingsAction.Title",
"settings-title-punctuation": "…"
},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "a2",
"ratio": 0,
"feature": {
"value": {
"settings-title": "Settings.General.SectionName",
"settings-title-punctuation": "!"
},
"enabled": true,
"featureId": "nimbus-validation"
}
}
],
"outcomes": [],
"arguments": {},
"probeSets": [],
"startDate": null,
"targeting": "true",
"featureIds": [
"nimbus-validation"
],
"application": "org.mozilla.ios.Fennec",
"bucketConfig": {
"count": 0,
"start": 0,
"total": 10000,
"namespace": "nimbus-validation-2",
"randomizationUnit": "nimbus_id"
},
"schemaVersion": "1.5.0",
"userFacingName": "Nimbus Text Variables Validation",
"referenceBranch": "control",
"proposedDuration": 14,
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "Demonstration experiment to make trivial visible changes to text in Settings",
"last_modified": 1621443780172
},
{
"slug": "feature-icon-variables-validation-ios",
"appId": "org.mozilla.ios.Fennec",
"appName": "firefox_ios",
"channel": "nightly",
"branches": [{
"slug": "control",
"ratio": 100,
"feature": {
"value": {},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "treatment",
"ratio": 0,
"feature": {
"value": {
"settings-title": "Fancy Settings",
"settings-icon": "menu-ViewMobile"
},
"enabled": true,
"featureId": "nimbus-validation"
}
}
],
"outcomes": [],
"arguments": {},
"probeSets": [],
"startDate": null,
"targeting": "true",
"featureIds": [
"nimbus-validation"
],
"application": "org.mozilla.ios.Fennec",
"bucketConfig": {
"count": 0,
"start": 0,
"total": 10000,
"namespace": "nimbus-validation-2",
"randomizationUnit": "nimbus_id"
},
"schemaVersion": "1.5.0",
"userFacingName": "Nimbus Icon Variables Validation",
"referenceBranch": "control",
"proposedDuration": 14,
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "Demonstration experiment to make trivial visible changes to icons in Settings",
"last_modified": 1621443780172
}
]
}

@ -26,4 +26,4 @@
android:targetPackage="org.mozilla.fenix.debug"
android:targetClass="org.mozilla.fenix.IntentReceiverActivity" />
</shortcut>
</shortcuts>
</shortcuts>

@ -1,91 +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/. */
import android.content.Context
import mozilla.components.browser.engine.gecko.autofill.GeckoLoginDelegateWrapper
import mozilla.components.browser.engine.gecko.ext.toContentBlockingSetting
import mozilla.components.browser.engine.gecko.glean.GeckoAdapter
import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy
import mozilla.components.concept.storage.LoginsStorage
import mozilla.components.lib.crash.handler.CrashHandlerService
import mozilla.components.service.sync.logins.GeckoLoginStorageDelegate
import org.mozilla.fenix.Config
import org.mozilla.fenix.ext.components
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoRuntimeSettings
import org.mozilla.geckoview.ContentBlocking
import org.mozilla.geckoview.ContentBlocking.SafeBrowsingProvider
object GeckoProvider {
private var runtime: GeckoRuntime? = null
const val CN_UPDATE_URL =
"https://sb.firefox.com.cn/downloads?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2"
const val CN_GET_HASH_URL =
"https://sb.firefox.com.cn/gethash?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2"
@Synchronized
fun getOrCreateRuntime(
context: Context,
storage: Lazy<LoginsStorage>,
trackingProtectionPolicy: TrackingProtectionPolicy
): GeckoRuntime {
if (runtime == null) {
runtime = createRuntime(context, storage, trackingProtectionPolicy)
}
return runtime!!
}
private fun createRuntime(
context: Context,
storage: Lazy<LoginsStorage>,
policy: TrackingProtectionPolicy
): GeckoRuntime {
val builder = GeckoRuntimeSettings.Builder()
val runtimeSettings = builder
.crashHandler(CrashHandlerService::class.java)
.telemetryDelegate(GeckoAdapter())
.contentBlocking(policy.toContentBlockingSetting())
.debugLogging(Config.channel.isDebug)
.aboutConfigEnabled(true)
.build()
val settings = context.components.settings
if (!settings.shouldUseAutoSize) {
runtimeSettings.automaticFontSizeAdjustment = false
val fontSize = settings.fontSizeFactor
runtimeSettings.fontSizeFactor = fontSize
}
// Add safebrowsing providers for China
if (Config.channel.isMozillaOnline) {
val mozcn = SafeBrowsingProvider
.withName("mozcn")
.version("2.2")
.lists("m6eb-phish-shavar", "m6ib-phish-shavar")
.updateUrl(CN_UPDATE_URL)
.getHashUrl(CN_GET_HASH_URL)
.build()
runtimeSettings.contentBlocking.setSafeBrowsingProviders(mozcn,
// Keep the existing configuration
ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER,
ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER)
runtimeSettings.contentBlocking.setSafeBrowsingPhishingTable(
"m6eb-phish-shavar",
"m6ib-phish-shavar",
// Existing configuration
"goog-phish-proto")
}
val geckoRuntime = GeckoRuntime.create(context, runtimeSettings)
val loginStorageDelegate = GeckoLoginStorageDelegate(storage)
@Suppress("Deprecation")
geckoRuntime.loginStorageDelegate = GeckoLoginDelegateWrapper(loginStorageDelegate)
return geckoRuntime
}
}

@ -63,6 +63,17 @@ import org.mozilla.fenix.session.VisibilityLifecycleCallback
import org.mozilla.fenix.telemetry.TelemetryLifecycleObserver
import org.mozilla.fenix.utils.BrowsersCache
import java.util.concurrent.TimeUnit
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.search.ext.buildSearchUrl
import mozilla.components.feature.search.ext.waitForSelectedOrDefaultSearchEngine
import mozilla.components.service.fxa.manager.SyncEnginesStorage
import org.mozilla.fenix.GleanMetrics.Addons
import org.mozilla.fenix.GleanMetrics.Preferences
import org.mozilla.fenix.GleanMetrics.SearchDefaultEngine
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MozillaProductDetector
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.utils.Settings
/**
*The main application class for Fenix. Records data to measure initialization performance.
@ -130,13 +141,11 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
buildInfo = GleanBuildInfo.buildInfo
)
// Set this early to guarantee it's in every ping from here on.
Metrics.distributionId.set(
when (Config.channel.isMozillaOnline) {
true -> "MozillaOnline"
false -> "Mozilla"
}
)
// We avoid blocking the main thread on startup by setting startup metrics on the background thread.
val store = components.core.store
GlobalScope.launch(Dispatchers.IO) {
setStartupMetrics(store, settings())
}
}
@CallSuper
@ -167,7 +176,6 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
initializeWebExtensionSupport()
restoreBrowserState()
restoreDownloads()
restoreLocale()
// Just to make sure it is impossible for any application-services pieces
// to invoke parts of itself that require complete megazord initialization
@ -220,10 +228,6 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
components.useCases.downloadUseCases.restoreDownloads()
}
private fun restoreLocale() {
components.useCases.localeUseCases.restore()
}
private fun initVisualCompletenessQueueAndQueueTasks() {
val queue = components.performance.visualCompletenessQueue.queue
@ -239,6 +243,7 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
components.core.historyStorage.warmUp()
components.core.bookmarksStorage.warmUp()
components.core.passwordsStorage.warmUp()
components.core.autofillStorage.warmUp()
}
SecurePrefsTelemetry(this@FenixApplication, components.analytics.experiments).startTests()
@ -269,6 +274,14 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
}
}
fun queueRestoreLocale() {
components.performance.visualCompletenessQueue.queue.runIfReadyOrQueue {
GlobalScope.launch(Dispatchers.IO) {
components.useCases.localeUseCases.restore()
}
}
}
initQueue()
// We init these items in the visual completeness queue to avoid them initing in the critical
@ -276,6 +289,7 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
queueInitStorageAndServices()
queueMetrics()
queueReviewPrompt()
queueRestoreLocale()
}
private fun startMetricsIfEnabled() {
@ -514,6 +528,181 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
}
}
/**
* This function is called right after Glean is initialized. Part of this function depends on
* shared preferences to be updated so the correct value is sent with the metrics ping.
*
* The reason we're using shared preferences to track these values is due to the limitations of
* the current metrics ping design. The values set here will be sent in every metrics ping even
* if these values have not changed since the last startup.
*/
@Suppress("ComplexMethod", "LongMethod")
@VisibleForTesting
internal fun setStartupMetrics(
browserStore: BrowserStore,
settings: Settings,
browsersCache: BrowsersCache = BrowsersCache,
mozillaProductDetector: MozillaProductDetector = MozillaProductDetector
) {
setPreferenceMetrics(settings)
with(Metrics) {
// Set this early to guarantee it's in every ping from here on.
distributionId.set(
when (Config.channel.isMozillaOnline) {
true -> "MozillaOnline"
false -> "Mozilla"
}
)
defaultBrowser.set(browsersCache.all(applicationContext).isDefaultBrowser)
mozillaProductDetector.getMozillaBrowserDefault(applicationContext)?.also {
defaultMozBrowser.set(it)
}
mozillaProducts.set(mozillaProductDetector.getInstalledMozillaProducts(applicationContext))
adjustCampaign.set(settings.adjustCampaignId)
adjustAdGroup.set(settings.adjustAdGroup)
adjustCreative.set(settings.adjustCreative)
adjustNetwork.set(settings.adjustNetwork)
searchWidgetInstalled.set(settings.searchWidgetInstalled)
val openTabsCount = settings.openTabsCount
hasOpenTabs.set(openTabsCount > 0)
if (openTabsCount > 0) {
tabsOpenCount.add(openTabsCount)
}
val topSitesSize = settings.topSitesSize
hasTopSites.set(topSitesSize > 0)
if (topSitesSize > 0) {
topSitesCount.add(topSitesSize)
}
if (settings.creditCardsSavedCount > 0) {
creditCardsSavedCount.add(settings.creditCardsSavedCount)
}
if (settings.creditCardsDeletedCount > 0) {
creditCardsDeletedCount.add(settings.creditCardsDeletedCount)
}
if (settings.creditCardsAutofilledCount > 0) {
creditCardsAutofillCount.add(settings.creditCardsAutofilledCount)
}
val installedAddonSize = settings.installedAddonsCount
Addons.hasInstalledAddons.set(installedAddonSize > 0)
if (installedAddonSize > 0) {
Addons.installedAddons.set(settings.installedAddonsList.split(','))
}
val enabledAddonSize = settings.enabledAddonsCount
Addons.hasEnabledAddons.set(enabledAddonSize > 0)
if (enabledAddonSize > 0) {
Addons.enabledAddons.set(settings.enabledAddonsList.split(','))
}
val desktopBookmarksSize = settings.desktopBookmarksSize
hasDesktopBookmarks.set(desktopBookmarksSize > 0)
if (desktopBookmarksSize > 0) {
desktopBookmarksCount.add(desktopBookmarksSize)
}
val mobileBookmarksSize = settings.mobileBookmarksSize
hasMobileBookmarks.set(mobileBookmarksSize > 0)
if (mobileBookmarksSize > 0) {
mobileBookmarksCount.add(mobileBookmarksSize)
}
toolbarPosition.set(
when (settings.toolbarPosition) {
ToolbarPosition.BOTTOM -> Event.ToolbarPositionChanged.Position.BOTTOM.name
ToolbarPosition.TOP -> Event.ToolbarPositionChanged.Position.TOP.name
}
)
tabViewSetting.set(settings.getTabViewPingString())
closeTabSetting.set(settings.getTabTimeoutPingString())
}
browserStore.waitForSelectedOrDefaultSearchEngine { searchEngine ->
if (searchEngine != null) {
SearchDefaultEngine.apply {
code.set(searchEngine.id)
name.set(searchEngine.name)
submissionUrl.set(searchEngine.buildSearchUrl(""))
}
}
}
}
@Suppress("ComplexMethod")
private fun setPreferenceMetrics(
settings: Settings
) {
with(Preferences) {
searchSuggestionsEnabled.set(settings.shouldShowSearchSuggestions)
remoteDebuggingEnabled.set(settings.isRemoteDebuggingEnabled)
telemetryEnabled.set(settings.isTelemetryEnabled)
browsingHistorySuggestion.set(settings.shouldShowHistorySuggestions)
bookmarksSuggestion.set(settings.shouldShowBookmarkSuggestions)
clipboardSuggestionsEnabled.set(settings.shouldShowClipboardSuggestions)
searchShortcutsEnabled.set(settings.shouldShowSearchShortcuts)
openLinksInPrivate.set(settings.openLinksInAPrivateTab)
privateSearchSuggestions.set(settings.shouldShowSearchSuggestionsInPrivate)
voiceSearchEnabled.set(settings.shouldShowVoiceSearch)
openLinksInAppEnabled.set(settings.openLinksInExternalApp)
signedInSync.set(settings.signedInFxaAccount)
val syncedItems = SyncEnginesStorage(applicationContext).getStatus().entries.filter {
it.value
}.map { it.key.nativeName }
syncItems.set(syncedItems)
toolbarPositionSetting.set(
when {
settings.shouldUseFixedTopToolbar -> "fixed_top"
settings.shouldUseBottomToolbar -> "bottom"
else -> "top"
}
)
enhancedTrackingProtection.set(
when {
!settings.shouldUseTrackingProtection -> ""
settings.useStandardTrackingProtection -> "standard"
settings.useStrictTrackingProtection -> "strict"
settings.useCustomTrackingProtection -> "custom"
else -> ""
}
)
val accessibilitySelection = mutableListOf<String>()
if (settings.switchServiceIsEnabled) {
accessibilitySelection.add("switch")
}
if (settings.touchExplorationIsEnabled) {
accessibilitySelection.add("touch exploration")
}
accessibilityServices.set(accessibilitySelection.toList())
userTheme.set(
when {
settings.shouldUseLightTheme -> "light"
settings.shouldUseDarkTheme -> "dark"
settings.shouldFollowDeviceTheme -> "system"
settings.shouldUseAutoBatteryTheme -> "battery"
else -> ""
}
)
}
}
protected fun recordOnInit() {
// This gets called by more than one process. Ideally we'd only run this in the main process
// but the code to check which process we're in crashes because the Context isn't valid yet.

@ -52,7 +52,6 @@ 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.ActivityResultHandler
import mozilla.components.support.base.feature.UserInteractionHandler
@ -83,6 +82,7 @@ import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.ext.measureNoInline
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.setNavigationIcon
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.intent.CrashReporterIntentProcessor
@ -303,9 +303,12 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
super.onResume()
// Even if screenshots are allowed, we hide private content in the recents screen in onPause
// so onResume we should go back to setting these flags with the user screenshot setting
// only when we are in private mode, so in onResume we should go back to setting these flags
// with the user screenshot setting only when we are in private mode.
// See https://github.com/mozilla-mobile/fenix/issues/11153
updateSecureWindowFlags(settings().lastKnownMode)
if (settings().lastKnownMode == BrowsingMode.Private) {
updateSecureWindowFlags(settings().lastKnownMode)
}
// Diagnostic breadcrumb for "Display already aquired" crash:
// https://github.com/mozilla-mobile/android-components/issues/7960
@ -373,6 +376,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
components.core.store.state.getNormalOrPrivateTabs(private = false).isNotEmpty()
// Even if screenshots are allowed, we want to hide private content in the recents screen
// only when we are in private mode
// See https://github.com/mozilla-mobile/fenix/issues/11153
if (settings().lastKnownMode.isPrivate) {
window.addFlags(FLAG_SECURE)
@ -719,6 +723,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
setSupportActionBar(navigationToolbar)
// Add ids to this that we don't want to have a toolbar back button
setupNavigationToolbar()
setNavigationIcon(R.drawable.ic_back_button)
isToolbarInflated = true
}
@ -856,10 +861,10 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
SessionState.Source.USER_ENTERED,
true,
mode.isPrivate,
searchEngine = engine.legacy()
searchEngine = engine
)
} else {
components.useCases.searchUseCases.defaultSearch.invoke(searchTermOrURL, engine.legacy())
components.useCases.searchUseCases.defaultSearch.invoke(searchTermOrURL, engine)
}
}

@ -0,0 +1,34 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix
import android.os.Bundle
import androidx.annotation.LayoutRes
import androidx.fragment.app.Fragment
import org.mozilla.fenix.ext.removeSecure
import org.mozilla.fenix.ext.secure
/**
* A [Fragment] implementation that can be used to secure screens displaying sensitive information
* by not allowing taking screenshots of their content.
*
* Fragments displaying such screens should extend [SecureFragment] instead of [Fragment] class.
*/
open class SecureFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId) {
constructor() : this(0) {
Fragment()
}
override fun onCreate(savedInstanceState: Bundle?) {
this.secure()
super.onCreate(savedInstanceState)
}
override fun onDestroy() {
this.removeSecure()
super.onDestroy()
}
}

@ -4,11 +4,14 @@
package org.mozilla.fenix.browser
import android.app.KeyguardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@ -16,8 +19,9 @@ import android.view.ViewGroup
import android.view.accessibility.AccessibilityManager
import androidx.annotation.CallSuper
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.net.toUri
import androidx.core.content.getSystemService
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@ -65,6 +69,7 @@ import mozilla.components.feature.intent.ext.EXTRA_SESSION_ID
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.PromptFeature.Companion.PIN_REQUEST
import mozilla.components.feature.prompts.share.ShareDelegate
import mozilla.components.feature.readerview.ReaderViewFeature
import mozilla.components.feature.search.SearchFeature
@ -130,8 +135,11 @@ import mozilla.components.feature.webauthn.WebAuthnFeature
import mozilla.components.support.base.feature.ActivityResultHandler
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import mozilla.components.support.ktx.android.view.enterToImmersiveMode
import mozilla.components.support.ktx.kotlin.getOrigin
import org.mozilla.fenix.GleanMetrics.PerfStartup
import org.mozilla.fenix.ext.measureNoInline
import org.mozilla.fenix.ext.secure
import org.mozilla.fenix.settings.biometric.BiometricPromptFeature
import mozilla.components.feature.session.behavior.ToolbarPosition as MozacToolbarPosition
/**
@ -180,6 +188,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
ViewBoundFeatureWrapper<MediaSessionFullscreenFeature>()
private val searchFeature = ViewBoundFeatureWrapper<SearchFeature>()
private val webAuthnFeature = ViewBoundFeatureWrapper<WebAuthnFeature>()
private val biometricPromptFeature = ViewBoundFeatureWrapper<BiometricPromptFeature>()
private var pipFeature: PictureInPictureFeature? = null
var customTabSessionId: String? = null
@ -533,6 +542,21 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
view = view
)
biometricPromptFeature.set(
feature = BiometricPromptFeature(
context = context,
fragment = this,
onAuthFailure = {
promptsFeature.get()?.onBiometricResult(isAuthenticated = false)
},
onAuthSuccess = {
promptsFeature.get()?.onBiometricResult(isAuthenticated = true)
}
),
owner = this,
view = view
)
promptsFeature.set(
feature = PromptFeature(
activity = activity,
@ -545,6 +569,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
isSaveLoginEnabled = {
context.settings().shouldPromptToSaveLogins
},
isCreditCardAutofillEnabled = {
context.settings().shouldAutofillCreditCardDetails
},
loginExceptionStorage = context.components.core.loginExceptionStorage,
shareDelegate = object : ShareDelegate {
override fun showShareSheet(
@ -571,6 +598,15 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
NavGraphDirections.actionGlobalSavedLoginsAuthFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
}
},
creditCardPickerView = creditCardSelectBar,
onManageCreditCards = {
val directions =
NavGraphDirections.actionGlobalCreditCardsSettingFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
},
onSelectCreditCard = {
showBiometricPrompt(context)
}
),
owner = this,
@ -722,6 +758,66 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
initializeEngineView(toolbarHeight)
}
/**
* Shows a biometric prompt and fallback to prompting for the password.
*/
private fun showBiometricPrompt(context: Context) {
if (BiometricPromptFeature.canUseFeature(context)) {
biometricPromptFeature.get()
?.requestAuthentication(getString(R.string.credit_cards_biometric_prompt_unlock_message))
return
}
// Fallback to prompting for password with the KeyguardManager
val manager = context.getSystemService<KeyguardManager>()
if (manager?.isKeyguardSecure == true) {
showPinVerification(manager)
} else {
// Warn that the device has not been secured
if (context.settings().shouldShowSecurityPinWarning) {
showPinDialogWarning(context)
} else {
promptsFeature.get()?.onBiometricResult(isAuthenticated = true)
}
}
}
/**
* Shows a pin request prompt. This is only used when BiometricPrompt is unavailable.
*/
@Suppress("Deprecation")
private fun showPinVerification(manager: KeyguardManager) {
val intent = manager.createConfirmDeviceCredentialIntent(
getString(R.string.credit_cards_biometric_prompt_message_pin),
getString(R.string.credit_cards_biometric_prompt_unlock_message)
)
requireActivity().startActivityForResult(intent, PIN_REQUEST)
}
/**
* Shows a dialog warning about setting up a device lock PIN.
*/
private fun showPinDialogWarning(context: Context) {
AlertDialog.Builder(context).apply {
setTitle(getString(R.string.credit_cards_warning_dialog_title))
setMessage(getString(R.string.credit_cards_warning_dialog_message))
setNegativeButton(getString(R.string.credit_cards_warning_dialog_later)) { _: DialogInterface, _ ->
promptsFeature.get()?.onBiometricResult(isAuthenticated = false)
}
setPositiveButton(getString(R.string.credit_cards_warning_dialog_set_up_now)) { it: DialogInterface, _ ->
it.dismiss()
promptsFeature.get()?.onBiometricResult(isAuthenticated = false)
startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
}
create()
}.show().secure(activity)
context.settings().incrementSecureWarningCount()
}
@VisibleForTesting
internal fun expandToolbarOnNavigation(store: BrowserStore) {
consumeFlow(store) { flow ->
@ -1089,9 +1185,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
private fun showQuickSettingsDialog() {
val tab = getCurrentTab() ?: return
viewLifecycleOwner.lifecycleScope.launch(Main) {
val sitePermissions: SitePermissions? = tab.content.url.toUri().host?.let { host ->
val storage = requireComponents.core.permissionStorage
storage.findSitePermissionsBy(host)
val sitePermissions: SitePermissions? = tab.content.url.getOrigin()?.let { origin ->
val storage = requireComponents.core.permissionStorage
storage.findSitePermissionsBy(origin)
}
view?.let {

@ -1,43 +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 androidx.annotation.CallSuper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.LifecycleAwareFeature
/**
* Helper class for creating small binding classes that are responsible for reacting to state
* changes.
*
* Taken with from Focus.
*/
abstract class AbstractBinding<in S : State>(
private val store: Store<S, out Action>
) : LifecycleAwareFeature {
private var scope: CoroutineScope? = null
@OptIn(ExperimentalCoroutinesApi::class)
@CallSuper
override fun start() {
scope = store.flowScoped { flow ->
onState(flow)
}
}
@CallSuper
override fun stop() {
scope?.cancel()
}
abstract suspend fun onState(flow: Flow<S>)
}

@ -17,12 +17,14 @@ import mozilla.components.service.nimbus.NimbusApi
import mozilla.components.service.nimbus.NimbusDisabled
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
import org.mozilla.fenix.components.metrics.AdjustMetricsService
import org.mozilla.fenix.components.metrics.GleanMetricsService
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.experiments.createNimbus
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.perf.lazyMonitored
@ -90,7 +92,7 @@ class Analytics(
val metrics: MetricController by lazyMonitored {
MetricController.create(
listOf(
GleanMetricsService(context, lazy { context.components.core.store }),
GleanMetricsService(context),
AdjustMetricsService(context as Application)
),
isDataTelemetryEnabled = { context.settings().isTelemetryEnabled },
@ -100,8 +102,11 @@ class Analytics(
}
val experiments: NimbusApi by lazyMonitored {
// No experiments for Iceraven
NimbusDisabled()
if (FeatureFlags.nimbusExperiments) {
createNimbus(context, BuildConfig.NIMBUS_ENDPOINT)
} else {
NimbusDisabled()
}
}
}

@ -32,9 +32,11 @@ import mozilla.components.service.fxa.manager.SCOPE_SESSION
import mozilla.components.service.fxa.manager.SCOPE_SYNC
import mozilla.components.service.fxa.manager.SyncEnginesStorage
import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider
import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage
import mozilla.components.service.sync.logins.SyncableLoginsStorage
import mozilla.components.support.utils.RunWhenReadyQueue
import org.mozilla.fenix.Config
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.perf.StrictModeManager
import org.mozilla.fenix.components.metrics.Event
@ -60,6 +62,7 @@ class BackgroundServices(
bookmarkStorage: Lazy<PlacesBookmarksStorage>,
passwordsStorage: Lazy<SyncableLoginsStorage>,
remoteTabsStorage: Lazy<RemoteTabsStorage>,
creditCardsStorage: Lazy<AutofillCreditCardsAddressesStorage>,
strictMode: StrictModeManager
) {
// Allows executing tasks which depend on the account manager, but do not need to eagerly initialize it.
@ -91,16 +94,33 @@ class BackgroundServices(
@VisibleForTesting
val supportedEngines =
setOf(SyncEngine.History, SyncEngine.Bookmarks, SyncEngine.Passwords, SyncEngine.Tabs)
private val syncConfig = SyncConfig(supportedEngines, PeriodicSyncConfig(periodMinutes = 240)) // four hours
setOfNotNull(
SyncEngine.History,
SyncEngine.Bookmarks,
SyncEngine.Passwords,
SyncEngine.Tabs,
SyncEngine.CreditCards,
if (FeatureFlags.addressesFeature) SyncEngine.Addresses else null
)
private val syncConfig =
SyncConfig(supportedEngines, PeriodicSyncConfig(periodMinutes = 240)) // four hours
private val creditCardKeyProvider by lazyMonitored { creditCardsStorage.value.crypto }
init {
/* Make the "history", "bookmark", "passwords", and "tabs" stores accessible to workers
spawned by the sync manager. */
// Make the "history", "bookmark", "passwords", "tabs", "credit cards" stores
// accessible to workers spawned by the sync manager.
GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Bookmarks to bookmarkStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Passwords to passwordsStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Tabs to remoteTabsStorage)
GlobalSyncableStoreProvider.configureStore(
storePair = SyncEngine.CreditCards to creditCardsStorage,
keyProvider = lazy { creditCardKeyProvider }
)
if (FeatureFlags.addressesFeature) {
GlobalSyncableStoreProvider.configureStore(SyncEngine.Addresses to creditCardsStorage)
}
}
private val telemetryAccountObserver = TelemetryAccountObserver(
@ -188,6 +208,7 @@ internal class TelemetryAccountObserver(
private val metricController: MetricController
) : AccountObserver {
override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
settings.signedInFxaAccount = true
when (authType) {
// User signed-in into an existing FxA account.
AuthType.Signin -> Event.SyncAuthSignIn
@ -220,5 +241,6 @@ internal class TelemetryAccountObserver(
override fun onLoggedOut() {
metricController.track(Event.SyncAuthSignOut)
settings.signedInFxaAccount = false
}
}

@ -23,6 +23,7 @@ import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.Config
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.autofill.AutofillConfirmActivity
import org.mozilla.fenix.autofill.AutofillUnlockActivity
import org.mozilla.fenix.components.metrics.AppStartupTelemetry
import org.mozilla.fenix.ext.components
@ -58,6 +59,7 @@ class Components(private val context: Context) {
core.lazyBookmarksStorage,
core.lazyPasswordsStorage,
core.lazyRemoteTabsStorage,
core.lazyAutofillStorage,
strictMode
)
}
@ -172,7 +174,7 @@ class Components(private val context: Context) {
storage = core.passwordsStorage,
publicSuffixList = publicSuffixList,
unlockActivity = AutofillUnlockActivity::class.java,
confirmActivity = AutofillConfiguration::class.java,
confirmActivity = AutofillConfirmActivity::class.java,
applicationName = context.getString(R.string.app_name),
httpClient = core.client
)

@ -4,7 +4,7 @@
package org.mozilla.fenix.components
import GeckoProvider
import org.mozilla.fenix.gecko.GeckoProvider
import android.content.Context
import android.content.res.Configuration
import android.os.Build
@ -108,6 +108,7 @@ class Core(
suspendMediaWhenInactive = false,
forceUserScalableContent = context.settings().forceEnableZoom,
loginAutofillEnabled = context.settings().shouldAutofillLogins,
enterpriseRootsEnabled = context.settings().allowThirdPartyRootCerts,
clearColor = ContextCompat.getColor(
context,
R.color.foundation_normal_theme
@ -119,6 +120,7 @@ class Core(
defaultSettings,
GeckoProvider.getOrCreateRuntime(
context,
lazyAutofillStorage,
lazyPasswordsStorage,
trackingProtectionPolicyFactory.createTrackingProtectionPolicy()
)
@ -154,6 +156,7 @@ class Core(
context,
GeckoProvider.getOrCreateRuntime(
context,
lazyAutofillStorage,
lazyPasswordsStorage,
trackingProtectionPolicyFactory.createTrackingProtectionPolicy()
)
@ -291,7 +294,7 @@ class Core(
val lazyHistoryStorage = lazyMonitored { PlacesHistoryStorage(context, crashReporter) }
val lazyBookmarksStorage = lazyMonitored { PlacesBookmarksStorage(context) }
val lazyPasswordsStorage = lazyMonitored { SyncableLoginsStorage(context, passwordsEncryptionKey) }
val lazyAutofillStorage = lazyMonitored { AutofillCreditCardsAddressesStorage(context) }
val lazyAutofillStorage = lazyMonitored { AutofillCreditCardsAddressesStorage(context, lazySecurePrefs) }
/**
* The storage component to sync and persist tabs in a Firefox Sync account.
@ -390,6 +393,7 @@ class Core(
* Shared Preferences that encrypt/decrypt using Android KeyStore and lib-dataprotect for 23+
* only on Nightly/Debug for now, otherwise simply stored.
* See https://github.com/mozilla-mobile/fenix/issues/8324
* Also, this needs revision. See https://github.com/mozilla-mobile/fenix/issues/19155
*/
private fun getSecureAbove22Preferences() =
SecureAbove22Preferences(
@ -398,6 +402,9 @@ class Core(
forceInsecure = !Config.channel.isNightlyOrDebug
)
// Temporary. See https://github.com/mozilla-mobile/fenix/issues/19155
private val lazySecurePrefs = lazyMonitored { getSecureAbove22Preferences() }
private val passwordsEncryptionKey by lazyMonitored {
getSecureAbove22Preferences().getString(PASSWORDS_KEY)
?: generateEncryptionKey(KEY_STRENGTH).also {

@ -15,7 +15,6 @@ 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.feature.search.ext.toDefaultSearchEngineProvider
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.feature.session.SettingsUseCases
import mozilla.components.feature.session.TrackingProtectionUseCases
@ -66,7 +65,6 @@ class UseCases(
val searchUseCases by lazyMonitored {
SearchUseCases(
store,
store.toDefaultSearchEngineProvider(),
tabsUseCases
)
}

@ -5,10 +5,6 @@
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
import mozilla.components.support.base.log.logger.Logger
@ -46,12 +42,10 @@ import org.mozilla.fenix.GleanMetrics.Metrics
import org.mozilla.fenix.GleanMetrics.Onboarding
import org.mozilla.fenix.GleanMetrics.Pings
import org.mozilla.fenix.GleanMetrics.Pocket
import org.mozilla.fenix.GleanMetrics.Preferences
import org.mozilla.fenix.GleanMetrics.PrivateBrowsingMode
import org.mozilla.fenix.GleanMetrics.PrivateBrowsingShortcut
import org.mozilla.fenix.GleanMetrics.ProgressiveWebApp
import org.mozilla.fenix.GleanMetrics.ReaderMode
import org.mozilla.fenix.GleanMetrics.SearchDefaultEngine
import org.mozilla.fenix.GleanMetrics.SearchShortcuts
import org.mozilla.fenix.GleanMetrics.SearchSuggestions
import org.mozilla.fenix.GleanMetrics.SearchWidget
@ -70,11 +64,7 @@ import org.mozilla.fenix.GleanMetrics.TopSites
import org.mozilla.fenix.GleanMetrics.TrackingProtection
import org.mozilla.fenix.GleanMetrics.UserSpecifiedSearchEngines
import org.mozilla.fenix.GleanMetrics.VoiceSearch
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.utils.BrowsersCache
import org.mozilla.fenix.utils.Settings
private class EventWrapper<T : Enum<T>>(
private val recorder: ((Map<T, String>?) -> Unit),
@ -855,11 +845,11 @@ private val Event.wrapper: EventWrapper<*>?
is Event.SyncAuthFromSharedReuse, Event.SyncAuthFromSharedCopy -> null
}
/**
* Service responsible for sending the activation and installation pings.
*/
class GleanMetricsService(
private val context: Context,
private val store: Lazy<BrowserStore>,
private val browsersCache: BrowsersCache = BrowsersCache,
private val mozillaProductDetector: MozillaProductDetector = MozillaProductDetector
private val context: Context
) : MetricsService {
override val type = MetricServiceType.Data
@ -887,181 +877,11 @@ class GleanMetricsService(
// can handle events being recorded before it's initialized.
Glean.registerPings(Pings)
// setStartupMetrics is not a fast function. It does not need to be done before we can consider
// ourselves initialized. So, let's do it, well, later.
setStartupMetrics(context.settings())
}
}
/**
* This function is called before the metrics ping is sent. Part of this function depends on
* shared preferences to be updated so the correct value is sent with the metrics ping.
*
* The reason we're using shared preferences to track some of these values is due to the
* limitations of the metrics ping. Events are only sent in a metrics ping if the user have made
* changes between each ping. However, in some cases we want current values to be sent even if
* the user have not changed anything between pings.
*/
internal fun setStartupMetrics(settings: Settings) {
setPreferenceMetrics()
with(Metrics) {
defaultBrowser.set(browsersCache.all(context).isDefaultBrowser)
mozillaProductDetector.getMozillaBrowserDefault(context)?.also {
defaultMozBrowser.set(it)
}
mozillaProducts.set(mozillaProductDetector.getInstalledMozillaProducts(context))
adjustCampaign.set(settings.adjustCampaignId)
adjustAdGroup.set(settings.adjustAdGroup)
adjustCreative.set(settings.adjustCreative)
adjustNetwork.set(settings.adjustNetwork)
searchWidgetInstalled.set(settings.searchWidgetInstalled)
val openTabsCount = settings.openTabsCount
hasOpenTabs.set(openTabsCount > 0)
if (openTabsCount > 0) {
tabsOpenCount.add(openTabsCount)
}
val topSitesSize = settings.topSitesSize
hasTopSites.set(topSitesSize > 0)
if (topSitesSize > 0) {
topSitesCount.add(topSitesSize)
}
val installedAddonSize = settings.installedAddonsCount
Addons.hasInstalledAddons.set(installedAddonSize > 0)
if (installedAddonSize > 0) {
Addons.installedAddons.set(settings.installedAddonsList.split(','))
}
val enabledAddonSize = settings.enabledAddonsCount
Addons.hasEnabledAddons.set(enabledAddonSize > 0)
if (enabledAddonSize > 0) {
Addons.enabledAddons.set(settings.enabledAddonsList.split(','))
}
val desktopBookmarksSize = settings.desktopBookmarksSize
hasDesktopBookmarks.set(desktopBookmarksSize > 0)
if (desktopBookmarksSize > 0) {
desktopBookmarksCount.add(desktopBookmarksSize)
}
val mobileBookmarksSize = settings.mobileBookmarksSize
hasMobileBookmarks.set(mobileBookmarksSize > 0)
if (mobileBookmarksSize > 0) {
mobileBookmarksCount.add(mobileBookmarksSize)
}
toolbarPosition.set(
when (settings.toolbarPosition) {
ToolbarPosition.BOTTOM -> Event.ToolbarPositionChanged.Position.BOTTOM.name
ToolbarPosition.TOP -> Event.ToolbarPositionChanged.Position.TOP.name
}
)
tabViewSetting.set(settings.getTabViewPingString())
closeTabSetting.set(settings.getTabTimeoutPingString())
}
store.value.waitForSelectedOrDefaultSearchEngine { searchEngine ->
if (searchEngine != null) {
SearchDefaultEngine.apply {
code.set(searchEngine.id)
name.set(searchEngine.name)
submissionUrl.set(searchEngine.legacy().buildSearchUrl(""))
}
}
activationPing.checkAndSend()
installationPing.checkAndSend()
}
}
private fun setPreferenceMetrics() {
// We purposefully make all of our preferences the string_list format to make data analysis
// simpler. While it makes things like booleans a bit more complicated, it means all our
// preferences can be analyzed with the same dashboard and compared.
with(Preferences) {
showSearchSuggestions.set(context.settings().shouldShowSearchSuggestions.toStringList())
remoteDebugging.set(context.settings().isRemoteDebuggingEnabled.toStringList())
telemetry.set(context.settings().isTelemetryEnabled.toStringList())
searchBrowsingHistory.set(context.settings().shouldShowHistorySuggestions.toStringList())
searchBookmarks.set(context.settings().shouldShowBookmarkSuggestions.toStringList())
showClipboardSuggestions.set(context.settings().shouldShowClipboardSuggestions.toStringList())
showSearchShortcuts.set(context.settings().shouldShowSearchShortcuts.toStringList())
openLinksInAPrivateTab.set(context.settings().openLinksInAPrivateTab.toStringList())
searchSuggestionsPrivate.set(context.settings().shouldShowSearchSuggestionsInPrivate.toStringList())
showVoiceSearch.set(context.settings().shouldShowVoiceSearch.toStringList())
openLinksInApp.set(context.settings().openLinksInExternalApp.toStringList())
val isLoggedIn =
context.components.backgroundServices.accountManager.accountProfile() != null
sync.set(isLoggedIn.toStringList())
val syncedItems = SyncEnginesStorage(context).getStatus().entries.filter {
it.value
}.map { it.key.nativeName }
syncItems.set(syncedItems)
val toolbarPositionSelection =
if (context.settings().shouldUseFixedTopToolbar) {
"fixed_top"
} else if (context.settings().shouldUseBottomToolbar) {
"bottom"
} else {
"top"
}
toolbarPosition.set(listOf(toolbarPositionSelection))
val etpSelection =
if (!context.settings().shouldUseTrackingProtection) {
""
} else if (context.settings().useStandardTrackingProtection) {
"standard"
} else if (context.settings().useStrictTrackingProtection) {
"strict"
} else if (context.settings().useCustomTrackingProtection) {
"custom"
} else {
""
}
trackingProtection.set(listOf(etpSelection))
val accessibilitySelection = mutableListOf<String>()
if (context.settings().switchServiceIsEnabled) {
accessibilitySelection.add("switch")
}
if (context.settings().touchExplorationIsEnabled) {
accessibilitySelection.add("touch exploration")
}
accessibilityServices.set(accessibilitySelection.toList())
val themeSelection =
if (context.settings().shouldUseLightTheme) {
"light"
} else if (context.settings().shouldUseDarkTheme) {
"dark"
} else if (context.settings().shouldFollowDeviceTheme) {
"system"
} else if (context.settings().shouldUseAutoBatteryTheme) {
"battery"
} else {
""
}
theme.set(listOf(themeSelection))
}
}
override fun stop() {
Glean.setUploadEnabled(false)
}

@ -20,7 +20,8 @@ import mozilla.components.feature.customtabs.CustomTabsFacts
import mozilla.components.feature.downloads.facts.DownloadsFacts
import mozilla.components.feature.findinpage.facts.FindInPageFacts
import mozilla.components.feature.media.facts.MediaFacts
import mozilla.components.feature.prompts.dialog.LoginDialogFacts
import mozilla.components.feature.prompts.facts.LoginDialogFacts
import mozilla.components.feature.prompts.facts.CreditCardAutofillDialogFacts
import mozilla.components.feature.pwa.ProgressiveWebAppFacts
import mozilla.components.feature.syncedtabs.facts.SyncedTabsFacts
import mozilla.components.feature.top.sites.facts.TopSitesFacts
@ -161,6 +162,10 @@ internal class ReleaseMetricController(
Component.FEATURE_PROMPTS to LoginDialogFacts.Items.CANCEL -> Event.LoginDialogPromptCancelled
Component.FEATURE_PROMPTS to LoginDialogFacts.Items.NEVER_SAVE -> Event.LoginDialogPromptNeverSave
Component.FEATURE_PROMPTS to LoginDialogFacts.Items.SAVE -> Event.LoginDialogPromptSave
Component.FEATURE_PROMPTS to CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_SUCCESS -> {
settings.creditCardsAutofilledCount += 1
null
}
Component.FEATURE_FINDINPAGE to FindInPageFacts.Items.CLOSE -> Event.FindInPageClosed
Component.FEATURE_FINDINPAGE to FindInPageFacts.Items.INPUT -> Event.FindInPageSearchCommitted
@ -223,7 +228,7 @@ internal class ReleaseMetricController(
metadata?.get("enabled")?.let { enabledAddons ->
if (enabledAddons is List<*>) {
settings.enabledAddonsCount = enabledAddons.size
settings.enabledAddonsList = enabledAddons.joinToString()
settings.enabledAddonsList = enabledAddons.joinToString(",")
}
}

@ -9,7 +9,7 @@ import android.os.Build
import mozilla.components.lib.dataprotect.SecurePrefsReliabilityExperiment
import mozilla.components.service.nimbus.NimbusApi
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.withExperiment
/**
@ -24,7 +24,7 @@ class SecurePrefsTelemetry(
// The Android Keystore is used to secure the shared prefs only on API 23+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// These tests should run only if the experiment is live
experiments.withExperiment(Experiments.ANDROID_KEYSTORE) { experimentBranch ->
experiments.withExperiment(FeatureId.ANDROID_KEYSTORE) { experimentBranch ->
// .. and this device is not in the control group.
if (experimentBranch == ExperimentBranch.TREATMENT) {
SecurePrefsReliabilityExperiment(appContext)()

@ -6,9 +6,8 @@ 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.ext.parseLegacySearchEngine
import mozilla.components.feature.search.middleware.SearchMiddleware
import org.mozilla.fenix.ext.components
import org.xmlpull.v1.XmlPullParserException
@ -50,18 +49,17 @@ internal class SearchMigration(
): 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())
loadSafely(id, xml?.byteInputStream()?.buffered())
}
}
}
private fun SearchEngineParser.loadSafely(id: String, stream: BufferedInputStream?): SearchEngine? {
@Suppress("DEPRECATION")
private fun loadSafely(id: String, stream: BufferedInputStream?): SearchEngine? {
return try {
stream?.let { load(id, it).migrate() }
stream?.let { parseLegacySearchEngine(id, it) }
} catch (e: IOException) {
null
} catch (e: XmlPullParserException) {

@ -41,7 +41,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.accounts.FenixAccountManager
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.asActivity
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
@ -632,7 +632,7 @@ open class DefaultToolbarMenu(
val experiments = context.components.analytics.experiments
val browsers = BrowsersCache.all(context)
return experiments.withExperiment(Experiments.DEFAULT_BROWSER) { experimentBranch ->
return experiments.withExperiment(FeatureId.DEFAULT_BROWSER) { experimentBranch ->
if (experimentBranch == ExperimentBranch.DEFAULT_BROWSER_TOOLBAR_MENU &&
!browsers.isFirefoxDefaultBrowser
) {

@ -106,9 +106,8 @@ class DynamicDownloadDialog(
metrics.track(Event.DownloadsItemOpened)
val fileWasOpened = AbstractFetchDownloadService.openFile(
context = context,
contentType = downloadState.contentType,
filePath = downloadState.filePath
applicationContext = context.applicationContext,
download = downloadState
)
if (!fileWasOpened) {

@ -4,14 +4,23 @@
package org.mozilla.fenix.experiments
class Experiments {
companion object {
const val A_A_NIMBUS_VALIDATION = "fenix-nimbus-validation-v3"
const val ANDROID_KEYSTORE = "fenix-android-keystore"
const val DEFAULT_BROWSER = "fenix-default-browser"
}
/**
* Enums to identify features in the app. These will likely grow and shrink depending
* on the experiments we want to perform.
*
* @property jsonName the kebab-case version of the feature id as represented in the Nimbus
* experiment JSON.
*/
enum class FeatureId(val jsonName: String) {
NIMBUS_VALIDATION("nimbus-validation"),
ANDROID_KEYSTORE("fenix-android-keystore"),
DEFAULT_BROWSER("fenix-default-browser")
}
/**
* Experiment branches are becoming less interesting, though we collect some well
* defined ones here.
*/
class ExperimentBranch {
companion object {
const val TREATMENT = "treatment"

@ -15,6 +15,7 @@ import mozilla.components.service.nimbus.NimbusDisabled
import mozilla.components.service.nimbus.NimbusServerSettings
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.R
import org.mozilla.fenix.components.isSentryEnabled
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
@ -66,6 +67,10 @@ fun createNimbus(context: Context, url: String?): NimbusApi =
globalUserParticipation = enabled
}
if (url.isNullOrBlank()) {
setExperimentsLocally(R.raw.initial_experiments)
}
// We may have downloaded experiments on a previous run, so let's start using them
// now. We didn't do this earlier, so as to make getExperimentBranch and friends returns
// the same thing throughout the session. This call does its work on the db thread.

@ -12,9 +12,12 @@ import android.app.role.RoleManager
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.SupportUtils
/**
@ -108,6 +111,20 @@ private fun Activity.navigateToDefaultBrowserAppsSettings() {
}
}
/**
* Sets the icon for the back (up) navigation button.
* @param icon The resource id of the icon.
*/
fun Activity.setNavigationIcon(
@DrawableRes icon: Int
) {
(this as? AppCompatActivity)?.supportActionBar?.let {
it.setDisplayHomeAsUpEnabled(true)
it.setHomeAsUpIndicator(icon)
it.setHomeActionContentDescription(R.string.action_bar_up_description)
}
}
const val REQUEST_CODE_BROWSER_ROLE = 1
const val SETTINGS_SELECT_OPTION_KEY = ":settings:fragment_args_key"
const val SETTINGS_SHOW_FRAGMENT_ARGS = ":settings:show_fragment_args"

@ -4,6 +4,7 @@
package org.mozilla.fenix.ext
import android.view.WindowManager
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
@ -35,6 +36,7 @@ fun Fragment.getPreferenceKey(@StringRes resourceId: Int): String = getString(re
*/
fun Fragment.showToolbar(title: String) {
(requireActivity() as AppCompatActivity).title = title
activity?.setNavigationIcon(R.drawable.ic_back_button)
(activity as NavHostActivity).getSupportActionBarAndInflateIfNecessary().show()
}
@ -58,15 +60,29 @@ fun Fragment.hideToolbar() {
}
/**
* Pops the backstack to force users to re-auth if they put the app in the background and return to it
* while being inside the saved logins flow
*
* Does nothing if the user is currently navigating to any of the [destinations] given as a parameter
* Pops the backstack to force users to re-auth if they put the app in the background and return to
* it while being inside a secured flow (e.g. logins or credit cards).
*
* Does nothing if the user is currently navigating to any of the [destinations] given as a
* parameter.
*/
fun Fragment.redirectToReAuth(destinations: List<Int>, currentDestination: Int?) {
fun Fragment.redirectToReAuth(
destinations: List<Int>,
currentDestination: Int?,
currentLocation: Int
) {
if (currentDestination !in destinations) {
findNavController().popBackStack(R.id.savedLoginsAuthFragment, false)
when (currentLocation) {
R.id.loginDetailFragment,
R.id.editLoginFragment,
R.id.savedLoginsFragment -> {
findNavController().popBackStack(R.id.savedLoginsAuthFragment, false)
}
R.id.creditCardEditorFragment,
R.id.creditCardsManagementFragment -> {
findNavController().popBackStack(R.id.creditCardsSettingFragment, false)
}
}
}
}
@ -89,3 +105,21 @@ fun Fragment.breadcrumb(
)
)
}
/**
* Sets the [WindowManager.LayoutParams.FLAG_SECURE] flag for the current activity window.
*/
fun Fragment.secure() {
this.activity?.window?.addFlags(
WindowManager.LayoutParams.FLAG_SECURE
)
}
/**
* Clears the [WindowManager.LayoutParams.FLAG_SECURE] flag for the current activity window.
*/
fun Fragment.removeSecure() {
this.activity?.window?.clearFlags(
WindowManager.LayoutParams.FLAG_SECURE
)
}

@ -6,37 +6,105 @@ package org.mozilla.fenix.ext
import mozilla.components.service.nimbus.NimbusApi
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.FeatureFlags
import org.mozilla.experiments.nimbus.Variables
import org.mozilla.fenix.experiments.FeatureId
/**
* Gets the branch of the given `experimentId` and transforms it with given closure.
* Gets the branch name of an experiment acting on the feature given `featureId`, and transforms it
* with given closure.
*
* If we're enrolled in the experiment, the transform is passed the branch id/slug as a `String`.
* You are probably looking for `withVariables`.
*
* If we're enrolled in an 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`.
*/
fun <T> NimbusApi.withExperiment(featureId: FeatureId, transform: (String?) -> T): T {
return transform(withExperiment(featureId))
}
/**
* The synonym for [getExperimentBranch] to complement [withExperiment(String, (String?) -> T))].
*
* Short-hand for ` org.mozilla.experiments.nimbus.NimbusApi.getExperimentBranch`.
*/
@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 {
fun NimbusApi.withExperiment(featureId: FeatureId) =
try {
getExperimentBranch(featureId.jsonName)
} catch (e: Throwable) {
Logger.error("Failed to getExperimentBranch(${featureId.jsonName})", e)
null
}
return transform(branch)
}
/**
* The degenerate case of `withExperiment(String, (String?) -> T))`, with an identity transform.
* Get the variables needed to configure the feature given by `featureId`.
*
* @param featureId The feature id that identifies the feature under experiment.
*
* @param sendExposureEvent Passing `true` to this parameter will record the exposure event
* automatically if the client is enrolled in an experiment for the given [featureId].
* Passing `false` here indicates that the application will manually record the exposure
* event by calling the `sendExposureEvent` function at the time of the exposure to the
* feature.
*
* See [sendExposureEvent] for more information on manually recording the event.
*
* @return a [Variables] object used to configure the feature.
*/
fun NimbusApi.getVariables(featureId: FeatureId, sendExposureEvent: Boolean = true) =
getVariables(featureId.jsonName, sendExposureEvent)
/**
* A synonym for `getVariables(featureId, sendExposureEvent)`.
*
* This exists as a complement to the `withVariable(featureId, sendExposureEvent, transform)` method.
*
* Short-hand for `mozilla.components.service.nimbus.NimbusApi.getExperimentBranch`.
* @param featureId the id of the feature as it appears in `Experimenter`
* @param sendExposureEvent by default `true`. This logs an event that the user was exposed to an experiment
* involving this feature.
* @return a `Variables` object providing typed accessors to a remotely configured JSON object.
*/
fun NimbusApi.withExperiment(experimentId: String) =
this.withExperiment(experimentId, ::identity)
fun NimbusApi.withVariables(featureId: FeatureId, sendExposureEvent: Boolean = true) =
getVariables(featureId, sendExposureEvent)
private fun <T> identity(value: T) = value
/**
* Get a `Variables` object for this feature and use that to configure the feature itself or a
* more type safe configuration object.
*
* @param featureId the id of the feature as it appears in `Experimenter`
* @param sendExposureEvent by default `true`. This logs an event that the user was exposed to an experiment
* involving this feature.
*/
fun <T> NimbusApi.withVariables(featureId: FeatureId, sendExposureEvent: Boolean = true, transform: (Variables) -> T) =
transform(getVariables(featureId, sendExposureEvent))
/**
* Records the `exposure` event in telemetry.
*
* This is a manual function to accomplish the same purpose as passing `true` as the
* `sendExposureEvent` property of the `getVariables` function. It is intended to be used
* when requesting feature variables must occur at a different time than the actual user's
* exposure to the feature within the app.
*
* - Examples:
* - If the `Variables` are needed at a different time than when the exposure to the feature
* actually happens, such as constructing a menu happening at a different time than the
* user seeing the menu.
* - If `getVariables` is required to be called multiple times for the same feature and it is
* desired to only record the exposure once, such as if `getVariables` were called
* with every keystroke.
*
* In the case where the use of this function is required, then the `getVariables` function
* should be called with `false` so that the exposure event is not recorded when the variables
* are fetched.
*
* This function is safe to call even when there is no active experiment for the feature. The SDK
* will ensure that an event is only recorded for active experiments.
*
* @param featureId string representing the id of the feature for which to record the exposure
* event.
*/
fun NimbusApi.recordExposureEvent(featureId: FeatureId) =
recordExposureEvent(featureId.jsonName)

@ -2,20 +2,25 @@
* 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.gecko
import android.content.Context
import mozilla.components.browser.engine.gecko.autofill.GeckoLoginDelegateWrapper
import mozilla.components.browser.engine.gecko.autofill.GeckoAutocompleteStorageDelegate
import mozilla.components.browser.engine.gecko.ext.toContentBlockingSetting
import mozilla.components.browser.engine.gecko.glean.GeckoAdapter
import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy
import mozilla.components.concept.storage.CreditCardsAddressesStorage
import mozilla.components.concept.storage.LoginsStorage
import mozilla.components.lib.crash.handler.CrashHandlerService
import mozilla.components.service.sync.autofill.GeckoCreditCardsAddressesStorageDelegate
import mozilla.components.service.sync.logins.GeckoLoginStorageDelegate
import org.mozilla.fenix.Config
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.geckoview.ContentBlocking
import org.mozilla.geckoview.ContentBlocking.SafeBrowsingProvider
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoRuntimeSettings
import org.mozilla.geckoview.ContentBlocking.SafeBrowsingProvider
object GeckoProvider {
private var runtime: GeckoRuntime? = null
@ -27,11 +32,13 @@ object GeckoProvider {
@Synchronized
fun getOrCreateRuntime(
context: Context,
storage: Lazy<LoginsStorage>,
autofillStorage: Lazy<CreditCardsAddressesStorage>,
loginStorage: Lazy<LoginsStorage>,
trackingProtectionPolicy: TrackingProtectionPolicy
): GeckoRuntime {
if (runtime == null) {
runtime = createRuntime(context, storage, trackingProtectionPolicy)
runtime =
createRuntime(context, autofillStorage, loginStorage, trackingProtectionPolicy)
}
return runtime!!
@ -39,7 +46,8 @@ object GeckoProvider {
private fun createRuntime(
context: Context,
storage: Lazy<LoginsStorage>,
autofillStorage: Lazy<CreditCardsAddressesStorage>,
loginStorage: Lazy<LoginsStorage>,
policy: TrackingProtectionPolicy
): GeckoRuntime {
val builder = GeckoRuntimeSettings.Builder()
@ -48,8 +56,8 @@ object GeckoProvider {
.crashHandler(CrashHandlerService::class.java)
.telemetryDelegate(GeckoAdapter())
.contentBlocking(policy.toContentBlockingSetting())
.aboutConfigEnabled(true)
.debugLogging(Config.channel.isDebug)
.aboutConfigEnabled(true)
.build()
val settings = context.components.settings
@ -82,9 +90,13 @@ object GeckoProvider {
}
val geckoRuntime = GeckoRuntime.create(context, runtimeSettings)
val loginStorageDelegate = GeckoLoginStorageDelegate(storage)
@Suppress("Deprecation")
geckoRuntime.loginStorageDelegate = GeckoLoginDelegateWrapper(loginStorageDelegate)
geckoRuntime.autocompleteStorageDelegate = GeckoAutocompleteStorageDelegate(
GeckoCreditCardsAddressesStorageDelegate(autofillStorage) {
context.settings().shouldAutofillCreditCardDetails
},
GeckoLoginStorageDelegate(loginStorage)
)
return geckoRuntime
}

@ -27,11 +27,10 @@ import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.FeatureFlags.tabsTrayRewrite
import org.mozilla.fenix.R
import org.mozilla.fenix.components.accounts.FenixAccountManager
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getVariables
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.withExperiment
import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.whatsnew.WhatsNew
@ -142,23 +141,9 @@ class HomeMenu(
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),
historyIcon,
R.drawable.ic_history,
primaryTextColor
) {
onItemTapped.invoke(Item.History)
@ -172,9 +157,11 @@ class HomeMenu(
onItemTapped.invoke(Item.Extensions)
}
// Use nimbus to set the icon and title.
val variables = experiments.getVariables(FeatureId.NIMBUS_VALIDATION)
val settingsItem = BrowserMenuImageText(
context.getString(R.string.browser_menu_settings),
R.drawable.ic_settings,
variables.getText("settings-title") ?: context.getString(R.string.browser_menu_settings),
variables.getDrawableResource("settings-icon") ?: R.drawable.ic_settings,
primaryTextColor
) {
onItemTapped.invoke(Item.Settings)
@ -252,23 +239,9 @@ class HomeMenu(
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),
historyIcon,
R.drawable.ic_history,
primaryTextColor
) {
onItemTapped.invoke(Item.History)
@ -310,9 +283,11 @@ class HomeMenu(
onItemTapped.invoke(Item.Help)
}
// Use nimbus to set the icon and title.
val variables = experiments.getVariables(FeatureId.NIMBUS_VALIDATION)
val settingsItem = BrowserMenuImageText(
context.getString(R.string.browser_menu_settings),
R.drawable.ic_settings,
variables.getText("settings-title") ?: context.getString(R.string.browser_menu_settings),
variables.getDrawableResource("settings-icon") ?: R.drawable.ic_settings,
primaryTextColor
) {
onItemTapped.invoke(Item.Settings)

@ -45,6 +45,10 @@ class TopSitePagerViewHolder(
view.top_sites_pager.apply {
adapter = topSitesPagerAdapter
registerOnPageChangeCallback(topSitesPageChangeCallback)
// Retain one more TopSites pages to ensure a new layout request will measure the first page also.
// Otherwise the second page with 3 TopSites will have the entire ViewPager only show
// the first row of TopSites, hiding half of those shown on the first page.
offscreenPageLimit = 1
}
}

@ -11,6 +11,7 @@ import android.view.View
import android.widget.PopupWindow
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import kotlinx.android.synthetic.main.top_site_item.*
import kotlinx.android.synthetic.main.top_site_item.view.*
import mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import mozilla.components.feature.top.sites.TopSite
@ -64,10 +65,11 @@ class TopSiteItemViewHolder(
fun bind(topSite: TopSite) {
top_site_title.text = topSite.title
pin_indicator.visibility = if (topSite.type == PINNED || topSite.type == DEFAULT) {
View.VISIBLE
if (topSite.type == PINNED || topSite.type == DEFAULT) {
val pinIndicator = getDrawable(itemView.context, R.drawable.ic_new_pin)
top_site_title.setCompoundDrawablesWithIntrinsicBounds(pinIndicator, null, null, null)
} else {
View.GONE
top_site_title.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null)
}
when (topSite.url) {
@ -91,6 +93,11 @@ class TopSiteItemViewHolder(
this.topSite = topSite
}
fun bind(topSitePayload: TopSitesAdapter.TopSitePayload) {
itemView.top_site_title.text = topSitePayload.newTitle
topSite = topSite.copy(title = topSitePayload.newTitle)
}
private fun onTouchEvent(
v: View,
event: MotionEvent,

@ -40,7 +40,7 @@ class TopSitesAdapter(
holder.bind((payloads[0] as TopSite))
}
is TopSitePayload -> {
holder.itemView.top_site_title.text = (payloads[0] as TopSitePayload).newTitle
holder.bind(payloads[0] as TopSitePayload)
}
}
}

@ -12,6 +12,7 @@ import kotlinx.android.synthetic.main.component_top_sites.view.*
import mozilla.components.feature.top.sites.TopSite
import org.mozilla.fenix.home.sessioncontrol.AdapterItem
import org.mozilla.fenix.home.sessioncontrol.TopSiteInteractor
import org.mozilla.fenix.home.sessioncontrol.viewholders.TopSitePagerViewHolder.Companion.TOP_SITES_PER_PAGE
import org.mozilla.fenix.home.sessioncontrol.viewholders.TopSiteViewHolder
class TopSitesPagerAdapter(
@ -36,7 +37,10 @@ class TopSitesPagerAdapter(
val adapter = holder.itemView.top_sites_list.adapter as TopSitesAdapter
val payload = payloads[0] as AdapterItem.TopSitePagerPayload
for (item in payload.changed) {
adapter.notifyItemChanged(item.first, item.second)
adapter.notifyItemChanged(
item.first % TOP_SITES_PER_PAGE,
TopSitesAdapter.TopSitePayload(item.second.title)
)
}
}
}

@ -44,5 +44,7 @@ open class LibraryPageView(
activity?.title = title
val toolbar = activity?.findViewById<Toolbar>(R.id.navigationToolbar)
toolbar?.setToolbarColors(foregroundColor, backgroundColor)
toolbar?.setNavigationIcon(R.drawable.ic_back_button)
toolbar?.navigationIcon?.setTint(foregroundColor)
}
}

@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.service.fxa.sync.SyncReason
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
@ -55,11 +56,13 @@ class DefaultBookmarkController(
private val scope: CoroutineScope,
private val store: BookmarkFragmentStore,
private val sharedViewModel: BookmarksSharedViewModel,
private val tabsUseCases: TabsUseCases?,
private val loadBookmarkNode: suspend (String) -> BookmarkNode?,
private val showSnackbar: (String) -> Unit,
private val deleteBookmarkNodes: (Set<BookmarkNode>, Event) -> Unit,
private val deleteBookmarkFolder: (Set<BookmarkNode>) -> Unit,
private val invokePendingDeletion: () -> Unit
private val invokePendingDeletion: () -> Unit,
private val showTabTray: () -> Unit
) : BookmarkController {
private val resources: Resources = activity.resources
@ -70,7 +73,7 @@ class DefaultBookmarkController(
}
override fun handleBookmarkTapped(item: BookmarkNode) {
openInNewTab(item.url!!, true, BrowserDirection.FromBookmarks, activity.browsingModeManager.mode)
openInNewTabAndShow(item.url!!, true, BrowserDirection.FromBookmarks, activity.browsingModeManager.mode)
}
override fun handleBookmarkExpand(folder: BookmarkNode) {
@ -126,7 +129,8 @@ class DefaultBookmarkController(
}
override fun handleOpeningBookmark(item: BookmarkNode, mode: BrowsingMode) {
openInNewTab(item.url!!, true, BrowserDirection.FromBookmarks, mode)
openInNewTab(item.url!!, mode)
showTabTray()
}
override fun handleBookmarkDeletion(nodes: Set<BookmarkNode>, eventType: Event) {
@ -169,7 +173,7 @@ class DefaultBookmarkController(
}
}
private fun openInNewTab(
private fun openInNewTabAndShow(
searchTermOrURL: String,
newTab: Boolean,
from: BrowserDirection,
@ -182,6 +186,18 @@ class DefaultBookmarkController(
}
}
private fun openInNewTab(
url: String,
mode: BrowsingMode
) {
invokePendingDeletion.invoke()
activity.browsingModeManager.mode = BrowsingMode.fromBoolean(mode == BrowsingMode.Private)
tabsUseCases?.let { tabsUseCases ->
val addTab = if (mode == BrowsingMode.Private) tabsUseCases.addPrivateTab else tabsUseCases.addTab
addTab.invoke(url)
}
}
private fun navigateToGivenDirection(directions: NavDirections) {
invokePendingDeletion.invoke()
navController.nav(R.id.bookmarkFragment, directions)

@ -92,11 +92,13 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
scope = viewLifecycleOwner.lifecycleScope,
store = bookmarkStore,
sharedViewModel = sharedViewModel,
tabsUseCases = activity?.components?.useCases?.tabsUseCases,
loadBookmarkNode = ::loadBookmarkNode,
showSnackbar = ::showSnackBarWithText,
deleteBookmarkNodes = ::deleteMulti,
deleteBookmarkFolder = ::showRemoveFolderDialog,
invokePendingDeletion = ::invokePendingDeletion
invokePendingDeletion = ::invokePendingDeletion,
showTabTray = ::showTabTray
),
metrics = metrics!!
)
@ -243,7 +245,7 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
private fun showTabTray() {
invokePendingDeletion()
navigateToBookmarkFragment(BookmarkFragmentDirections.actionGlobalTabTrayDialogFragment())
navigateToBookmarkFragment(BookmarkFragmentDirections.actionGlobalTabsTrayFragment())
}
private fun navigateToBookmarkFragment(directions: NavDirections) {

@ -98,12 +98,13 @@ class DownloadFragment : LibraryPageFragment<DownloadItem>(), UserInteractionHan
.sortedByDescending { it.createdTime } // sort from newest to oldest
.map {
DownloadItem(
it.id,
it.fileName,
it.filePath,
it.contentLength?.toString() ?: "0",
it.contentType,
it.status
id = it.id,
url = it.url,
fileName = it.fileName,
filePath = it.filePath,
size = it.contentLength?.toString() ?: "0",
contentType = it.contentType,
status = it.status
)
}.filter {
it.status == DownloadState.Status.COMPLETED
@ -249,10 +250,21 @@ class DownloadFragment : LibraryPageFragment<DownloadItem>(), UserInteractionHan
mode?.let { (activity as HomeActivity).browsingModeManager.mode = it }
context?.let {
val contentLength = if (item.size.isNotEmpty()) {
item.size.toLong()
} else {
0L
}
AbstractFetchDownloadService.openFile(
context = it,
contentType = item.contentType,
filePath = item.filePath
applicationContext = it.applicationContext,
download = DownloadState(
id = item.id,
url = item.url,
fileName = item.fileName,
contentType = item.contentType,
status = item.status,
contentLength = contentLength
)
)
}

@ -12,13 +12,16 @@ import mozilla.components.lib.state.Store
/**
* Class representing a downloads entry
* @property id Unique id of the download item
* @property url The full url to the content that should be downloaded
* @property fileName File name of the download item
* @property filePath Full path of the download item
* @property size The size in bytes of the download item
* @property contentType The type of file the download is
* @property status The status that represents every state that a download can be in
*/
data class DownloadItem(
val id: String,
val url: String,
val fileName: String?,
val filePath: String,
val size: String,

@ -21,7 +21,8 @@ import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
@Suppress("TooManyFunctions")
interface HistoryController {
fun handleOpen(item: HistoryItem, mode: BrowsingMode? = null)
fun handleOpen(item: HistoryItem)
fun handleOpenInNewTab(item: HistoryItem, mode: BrowsingMode)
fun handleSelect(item: HistoryItem)
fun handleDeselect(item: HistoryItem)
fun handleBackPressed(): Boolean
@ -42,15 +43,20 @@ class DefaultHistoryController(
private val snackbar: FenixSnackbar,
private val clipboardManager: ClipboardManager,
private val scope: CoroutineScope,
private val openToBrowser: (item: HistoryItem, mode: BrowsingMode?) -> Unit,
private val openToBrowser: (item: HistoryItem) -> Unit,
private val openInNewTab: (item: HistoryItem, mode: BrowsingMode) -> Unit,
private val displayDeleteAll: () -> Unit,
private val invalidateOptionsMenu: () -> Unit,
private val deleteHistoryItems: (Set<HistoryItem>) -> Unit,
private val syncHistory: suspend () -> Unit,
private val metrics: MetricController
) : HistoryController {
override fun handleOpen(item: HistoryItem, mode: BrowsingMode?) {
openToBrowser(item, mode)
override fun handleOpen(item: HistoryItem) {
openToBrowser(item)
}
override fun handleOpenInNewTab(item: HistoryItem, mode: BrowsingMode) {
openInNewTab(item, mode)
}
override fun handleSelect(item: HistoryItem) {

@ -80,22 +80,23 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
)
}
val historyController: HistoryController = DefaultHistoryController(
historyStore,
findNavController(),
resources,
FenixSnackbar.make(
store = historyStore,
navController = findNavController(),
resources = resources,
snackbar = FenixSnackbar.make(
view = view,
duration = FenixSnackbar.LENGTH_LONG,
isDisplayedWithBrowserToolbar = false
),
activity?.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager,
lifecycleScope,
::openItem,
::displayDeleteAllDialog,
::invalidateOptionsMenu,
::deleteHistoryItems,
::syncHistory,
requireComponents.analytics.metrics
clipboardManager = activity?.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager,
scope = lifecycleScope,
openToBrowser = ::openItem,
openInNewTab = ::openItemAndShowTray,
displayDeleteAll = ::displayDeleteAllDialog,
invalidateOptionsMenu = ::invalidateOptionsMenu,
deleteHistoryItems = ::deleteHistoryItems,
syncHistory = ::syncHistory,
metrics = requireComponents.analytics.metrics
)
historyInteractor = HistoryInteractor(
historyController
@ -104,6 +105,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
view.historyLayout,
historyInteractor
)
showToolbar(getString(R.string.library_history))
return view
}
@ -160,7 +162,6 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
override fun onResume() {
super.onResume()
showToolbar(getString(R.string.library_history))
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -216,7 +217,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
invokePendingDeletion()
findNavController().nav(
R.id.historyFragment,
HistoryFragmentDirections.actionGlobalTabTrayDialogFragment()
HistoryFragmentDirections.actionGlobalTabsTrayFragment()
)
}
@ -247,14 +248,8 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
_historyView = null
}
private fun openItem(item: HistoryItem, mode: BrowsingMode? = null) {
when (mode?.isPrivate) {
true -> requireComponents.analytics.metrics.track(Event.HistoryOpenedInPrivateTab)
false -> requireComponents.analytics.metrics.track(Event.HistoryOpenedInNewTab)
null -> requireComponents.analytics.metrics.track(Event.HistoryItemOpened)
}
mode?.let { (activity as HomeActivity).browsingModeManager.mode = it }
private fun openItem(item: HistoryItem) {
requireComponents.analytics.metrics.track(Event.HistoryItemOpened)
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = item.url,
@ -263,6 +258,22 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
)
}
private fun openItemAndShowTray(item: HistoryItem, mode: BrowsingMode) {
when (mode.isPrivate) {
true -> requireComponents.analytics.metrics.track(Event.HistoryOpenedInPrivateTab)
false -> requireComponents.analytics.metrics.track(Event.HistoryOpenedInNewTab)
}
val homeActivity = activity as HomeActivity
homeActivity.browsingModeManager.mode = mode
homeActivity.components.useCases.tabsUseCases.let { tabsUseCases ->
val addTab = if (mode == BrowsingMode.Private) tabsUseCases.addPrivateTab else tabsUseCases.addTab
addTab.invoke(item.url)
}
showTabTray()
}
private fun displayDeleteAllDialog() {
activity?.let { activity ->
AlertDialog.Builder(activity).apply {

@ -43,11 +43,11 @@ class HistoryInteractor(
}
override fun onOpenInNormalTab(item: HistoryItem) {
historyController.handleOpen(item, BrowsingMode.Normal)
historyController.handleOpenInNewTab(item, BrowsingMode.Normal)
}
override fun onOpenInPrivateTab(item: HistoryItem) {
historyController.handleOpen(item, BrowsingMode.Private)
historyController.handleOpenInNewTab(item, BrowsingMode.Private)
}
override fun onDeleteAll() {

@ -0,0 +1,94 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.R
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
import org.mozilla.fenix.nimbus.view.NimbusBranchesView
/**
* A fragment to show the branches of a Nimbus experiment.
*/
@Suppress("TooGenericExceptionCaught")
class NimbusBranchesFragment : Fragment() {
private lateinit var nimbusBranchesStore: NimbusBranchesStore
private lateinit var nimbusBranchesView: NimbusBranchesView
private lateinit var controller: NimbusBranchesController
private val args by navArgs<NimbusBranchesFragmentArgs>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view =
inflater.inflate(R.layout.mozac_service_nimbus_experiment_details, container, false)
nimbusBranchesStore = StoreProvider.get(this) {
NimbusBranchesStore(NimbusBranchesState(branches = emptyList()))
}
controller = NimbusBranchesController(
nimbusBranchesStore = nimbusBranchesStore,
experiments = requireContext().components.analytics.experiments,
experimentId = args.experimentId
)
nimbusBranchesView =
NimbusBranchesView(view.findViewById(R.id.nimbus_experiment_branches_list), controller)
loadExperimentBranches()
return view
}
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
consumeFrom(nimbusBranchesStore) { state ->
nimbusBranchesView.update(state)
}
}
override fun onResume() {
super.onResume()
showToolbar(args.experimentName)
}
private fun loadExperimentBranches() {
lifecycleScope.launch(Dispatchers.IO) {
try {
val experiments = requireContext().components.analytics.experiments
val branches = experiments.getExperimentBranches(args.experimentId) ?: emptyList()
val selectedBranch = experiments.getExperimentBranch(args.experimentId) ?: ""
nimbusBranchesStore.dispatch(
NimbusBranchesAction.UpdateBranches(
branches,
selectedBranch
)
)
} catch (e: Throwable) {
Logger.error("Failed to getActiveExperiments()", e)
}
}
}
}

@ -0,0 +1,79 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import org.mozilla.experiments.nimbus.Branch
/**
* The [Store] for holding the [NimbusBranchesState] and applying [NimbusBranchesAction]s.
*/
class NimbusBranchesStore(initialState: NimbusBranchesState) :
Store<NimbusBranchesState, NimbusBranchesAction>(
initialState, ::nimbusBranchesFragmentStateReducer
)
/**
* The state for [NimbusBranchesFragment].
*
* @property branches The list of [Branch]s to display in the branches list.
* @property selectedBranch The selected [Branch] slug for a Nimbus experiment.
* @property isLoading True if the branches are still being loaded from storage, otherwise false.
*/
data class NimbusBranchesState(
val branches: List<Branch>,
val selectedBranch: String = "",
val isLoading: Boolean = true
) : State
/**
* Actions to dispatch through the [NimbusBranchesStore] to modify the [NimbusBranchesState]
* through the [nimbusBranchesFragmentStateReducer].
*/
sealed class NimbusBranchesAction : Action {
/**
* Updates the list of Nimbus branches and selected branch.
*
* @param branches The list of [Branch]s to display in the branches list.
* @param selectedBranch The selected [Branch] slug for a Nimbus experiment.
*/
data class UpdateBranches(val branches: List<Branch>, val selectedBranch: String) :
NimbusBranchesAction()
/**
* Updates the selected branch.
*
* @param selectedBranch The selected [Branch] slug for a Nimbus experiment.
*/
data class UpdateSelectedBranch(val selectedBranch: String) : NimbusBranchesAction()
}
/**
* Reduces the Nimbus branches state from the current state with the provided [action] to
* be performed.
*
* @param state The current Nimbus branches state.
* @param action The action to be performed on the state.
* @return the new [NimbusBranchesState] with the [action] executed.
*/
private fun nimbusBranchesFragmentStateReducer(
state: NimbusBranchesState,
action: NimbusBranchesAction
): NimbusBranchesState {
return when (action) {
is NimbusBranchesAction.UpdateBranches -> {
state.copy(
branches = action.branches,
selectedBranch = action.selectedBranch,
isLoading = false
)
}
is NimbusBranchesAction.UpdateSelectedBranch -> {
state.copy(selectedBranch = action.selectedBranch)
}
}
}

@ -1,59 +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.nimbus
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.service.nimbus.ui.NimbusDetailAdapter
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.showToolbar
/**
* A fragment to show the details of a Nimbus experiment.
*/
class NimbusDetailsFragment : Fragment(R.layout.mozac_service_nimbus_experiment_details) {
private val args by navArgs<NimbusDetailsFragmentArgs>()
private var adapter: NimbusDetailAdapter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindRecyclerView(view)
}
override fun onResume() {
super.onResume()
showToolbar(args.experiment)
}
override fun onDestroyView() {
super.onDestroyView()
// Letting go of the resources to avoid memory leak.
adapter = null
}
private fun bindRecyclerView(view: View) {
val recyclerView = view.findViewById<RecyclerView>(R.id.nimbus_experiment_branches_list)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
val shouldRefresh = adapter != null
// Dummy data until we have the appropriate Nimbus API.
val branches = listOf(
"Control",
"Treatment"
)
if (!shouldRefresh) {
adapter = NimbusDetailAdapter(branches)
}
recyclerView.adapter = adapter
}
}

@ -22,6 +22,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.nimbus.view.NimbusExperimentsView
/**
* Fragment use for managing Nimbus experiments.
@ -60,7 +61,7 @@ class NimbusExperimentsFragment : Fragment(R.layout.mozac_service_nimbus_experim
lifecycleScope.launch(IO) {
try {
val experiments =
requireContext().components.analytics.experiments.getActiveExperiments()
requireContext().components.analytics.experiments.getAvailableExperiments()
lifecycleScope.launch(Main) {
runIfFragmentIsAttached {
@ -72,7 +73,7 @@ class NimbusExperimentsFragment : Fragment(R.layout.mozac_service_nimbus_experim
}
view.findViewById<TextView>(R.id.nimbus_experiments_empty_message).isVisible =
false
experiments.isEmpty()
recyclerView.adapter = adapter
}
}

@ -0,0 +1,32 @@
/* 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.nimbus.controller
import mozilla.components.service.nimbus.NimbusApi
import mozilla.components.service.nimbus.ui.NimbusBranchesAdapterDelegate
import org.mozilla.experiments.nimbus.Branch
import org.mozilla.fenix.nimbus.NimbusBranchesAction
import org.mozilla.fenix.nimbus.NimbusBranchesStore
/**
* [NimbusBranchesFragment] controller. This implements [NimbusBranchesAdapterDelegate] to handle
* interactions with a Nimbus branch item.
*
* @param nimbusBranchesStore An instance of [NimbusBranchesStore] for dispatching
* [NimbusBranchesAction]s.
* @param experiments An instance of [NimbusApi] for interacting with the Nimbus experiments.
* @param experimentId The string experiment-id or "slug" for a Nimbus experiment.
*/
class NimbusBranchesController(
private val nimbusBranchesStore: NimbusBranchesStore,
private val experiments: NimbusApi,
private val experimentId: String
) : NimbusBranchesAdapterDelegate {
override fun onBranchItemClicked(branch: Branch) {
experiments.optInWithBranch(experimentId, branch.slug)
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(branch.slug))
}
}

@ -0,0 +1,36 @@
/* 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.nimbus.view
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.extensions.LayoutContainer
import mozilla.components.service.nimbus.ui.NimbusBranchAdapter
import org.mozilla.fenix.nimbus.NimbusBranchesState
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
/**
* View used for managing a Nimbus experiment's branches.
*/
class NimbusBranchesView(
override val containerView: ViewGroup,
val controller: NimbusBranchesController
) : LayoutContainer {
private val nimbusAdapter = NimbusBranchAdapter(controller)
init {
val recyclerView: RecyclerView = containerView as RecyclerView
recyclerView.apply {
adapter = nimbusAdapter
layoutManager = LinearLayoutManager(containerView.context)
}
}
fun update(state: NimbusBranchesState) {
nimbusAdapter.updateData(state.branches, state.selectedBranch)
}
}

@ -2,12 +2,13 @@
* 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.nimbus
package org.mozilla.fenix.nimbus.view
import androidx.navigation.NavController
import mozilla.components.service.nimbus.ui.NimbusExperimentsAdapterDelegate
import org.mozilla.experiments.nimbus.EnrolledExperiment
import org.mozilla.experiments.nimbus.AvailableExperiment
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.nimbus.NimbusExperimentsFragmentDirections
/**
* View used for managing Nimbus experiments.
@ -16,10 +17,11 @@ class NimbusExperimentsView(
private val navController: NavController
) : NimbusExperimentsAdapterDelegate {
override fun onExperimentItemClicked(experiment: EnrolledExperiment) {
override fun onExperimentItemClicked(experiment: AvailableExperiment) {
val directions =
NimbusExperimentsFragmentDirections.actionNimbusExperimentsFragmentToNimbusDetailsFragment(
experiment.userFacingName
NimbusExperimentsFragmentDirections.actionNimbusExperimentsFragmentToNimbusBranchesFragment(
experimentId = experiment.slug,
experimentName = experiment.userFacingName
)
navController.navigateBlockingForAsyncNavGraph(directions)

@ -25,8 +25,6 @@ object StartupTimelineStateMachine {
* The states the application passes through during startup. We define these states to help us
* better understand Android startup. Note that these states are not 100% correlated to the
* cold/warm/hot states Google Play Vitals uses.
*
* TODO: link to extensive documentation on cold/warm/hot states when completed.
*/
sealed class StartupState {
/** The state when the application is starting up but is not in memory. */

@ -554,7 +554,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private fun updateClipboardSuggestion(searchState: SearchFragmentState, clipboardUrl: String?) {
val shouldShowView = searchState.showClipboardSuggestions &&
searchState.query.isEmpty() &&
!clipboardUrl.isNullOrEmpty()
!clipboardUrl.isNullOrEmpty() && !searchState.showSearchShortcuts
fill_link_from_clipboard.isVisible = shouldShowView
fill_link_divider.isVisible = shouldShowView

@ -9,7 +9,6 @@ import androidx.core.graphics.BlendModeColorFilterCompat.createBlendModeColorFil
import androidx.core.graphics.BlendModeCompat.SRC_IN
import androidx.core.graphics.drawable.toBitmap
import mozilla.components.browser.awesomebar.BrowserAwesomeBar
import mozilla.components.browser.search.DefaultSearchEngineProvider
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.searchEngines
import mozilla.components.concept.awesomebar.AwesomeBar
@ -21,8 +20,6 @@ import mozilla.components.feature.awesomebar.provider.SearchEngineSuggestionProv
import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider
import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider
import mozilla.components.feature.search.SearchUseCases
import mozilla.components.feature.search.ext.legacy
import mozilla.components.feature.search.ext.toDefaultSearchEngineProvider
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.feature.syncedtabs.DeviceIndicators
import mozilla.components.feature.syncedtabs.SyncedTabsStorageSuggestionProvider
@ -34,7 +31,6 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.search.SearchEngineSource
import org.mozilla.fenix.search.SearchFragmentState
import mozilla.components.browser.search.SearchEngine as LegacySearchEngine
/**
* View that contains and configures the BrowserAwesomeBar
@ -70,7 +66,7 @@ class AwesomeBarView(
private val searchUseCase = object : SearchUseCases.SearchUseCase {
override fun invoke(
searchTerms: String,
searchEngine: mozilla.components.browser.search.SearchEngine?,
searchEngine: SearchEngine?,
parentSessionId: String?
) {
interactor.onSearchTermsTapped(searchTerms)
@ -80,7 +76,7 @@ class AwesomeBarView(
private val shortcutSearchUseCase = object : SearchUseCases.SearchUseCase {
override fun invoke(
searchTerms: String,
searchEngine: mozilla.components.browser.search.SearchEngine?,
searchEngine: SearchEngine?,
parentSessionId: String?
) {
interactor.onSearchTermsTapped(searchTerms)
@ -151,7 +147,7 @@ class AwesomeBarView(
defaultSearchSuggestionProvider =
SearchSuggestionProvider(
context = activity,
defaultSearchEngineProvider = components.core.store.toDefaultSearchEngineProvider(),
store = components.core.store,
searchUseCase = searchUseCase,
fetchClient = components.core.client,
mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS,
@ -168,7 +164,7 @@ class AwesomeBarView(
defaultSearchActionProvider =
SearchActionProvider(
defaultSearchEngineProvider = components.core.store.toDefaultSearchEngineProvider(),
store = components.core.store,
searchUseCase = searchUseCase,
icon = searchBitmap,
showDescription = false
@ -330,17 +326,13 @@ class AwesomeBarView(
listOf(
SearchActionProvider(
defaultSearchEngineProvider = object : DefaultSearchEngineProvider {
override fun getDefaultSearchEngine(): LegacySearchEngine? =
engine.legacy()
override suspend fun retrieveDefaultSearchEngine(): LegacySearchEngine? =
engine.legacy()
},
searchEngine = engine,
store = components.core.store,
searchUseCase = shortcutSearchUseCase,
icon = searchBitmap
),
SearchSuggestionProvider(
engine.legacy(),
engine,
shortcutSearchUseCase,
components.core.client,
limit = 3,

@ -5,10 +5,12 @@
package org.mozilla.fenix.settings
import android.os.Bundle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
@ -28,16 +30,22 @@ class SecretSettingsFragment : PreferenceFragmentCompat() {
onPreferenceChangeListener = SharedPreferenceUpdater()
}
requirePreference<SwitchPreference>(R.string.pref_key_show_credit_cards_feature).apply {
isVisible = FeatureFlags.creditCardsFeature
isChecked = context.settings().creditCardsFeature
onPreferenceChangeListener = SharedPreferenceUpdater()
}
requirePreference<SwitchPreference>(R.string.pref_key_new_tabs_tray).apply {
isVisible = FeatureFlags.tabsTrayRewrite
isChecked = context.settings().tabsTrayRewrite
onPreferenceChangeListener = SharedPreferenceUpdater()
}
requirePreference<SwitchPreference>(R.string.pref_key_allow_third_party_root_certs).apply {
isVisible = true
isChecked = context.settings().allowThirdPartyRootCerts
onPreferenceChangeListener = object : SharedPreferenceUpdater() {
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean {
context.components.core.engine.settings.enterpriseRootsEnabled =
newValue as Boolean
return super.onPreferenceChange(preference, newValue)
}
}
}
}
}

@ -37,11 +37,12 @@ import mozilla.components.concept.sync.Profile
import mozilla.components.support.ktx.android.view.showKeyboard
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.Config
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.application
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
@ -51,6 +52,7 @@ import org.mozilla.fenix.ext.navigateToNotificationsSettings
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.REQUEST_CODE_BROWSER_ROLE
import org.mozilla.fenix.ext.getVariables
import org.mozilla.fenix.ext.openSetDefaultBrowserOption
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.withExperiment
@ -160,7 +162,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
override fun onResume() {
super.onResume()
showToolbar(getString(R.string.settings_title))
// Use nimbus to set the title, and a trivial addition
val experiments = requireContext().components.analytics.experiments
val variables = experiments.getVariables(FeatureId.NIMBUS_VALIDATION)
val title = variables.getText("settings-title") ?: getString(R.string.settings_title)
val suffix = variables.getString("settings-title-punctuation") ?: ""
showToolbar("$title$suffix")
// Account UI state is updated as part of `onCreate`. To not do it twice in a row, we only
// update it here if we're not going through the `onCreate->onStart->onResume` lifecycle chain.
@ -439,7 +447,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
with(requireContext().settings()) {
findPreference<Preference>(
getPreferenceKey(R.string.pref_key_credit_cards)
)?.isVisible = creditCardsFeature
)?.isVisible = FeatureFlags.creditCardsFeature
findPreference<Preference>(
getPreferenceKey(R.string.pref_key_nimbus_experiments)
)?.isVisible = showSecretDebugMenuThisSession
@ -588,7 +596,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private fun isDefaultBrowserExperimentBranch(): Boolean {
val experiments = context?.components?.analytics?.experiments
return experiments?.withExperiment(Experiments.DEFAULT_BROWSER) { experimentBranch ->
return experiments?.withExperiment(FeatureId.DEFAULT_BROWSER) { experimentBranch ->
(experimentBranch == ExperimentBranch.DEFAULT_BROWSER_SETTINGS_MENU)
} == true
}

@ -0,0 +1,50 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.SwitchCompat
import androidx.preference.PreferenceViewHolder
import androidx.preference.SwitchPreferenceCompat
import org.mozilla.fenix.R
/**
* Variation of [SwitchPreferenceCompat] that uses a custom widgetLayoutResource in order to implement
* visibility changes to it.
* */
class SyncPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : SwitchPreferenceCompat(context, attrs) {
private var switchView: SwitchCompat? = null
/**
* Whether or not switch's toggle widget is visible.
* */
var isSwitchWidgetVisible: Boolean = false
init {
widgetLayoutResource = R.layout.preference_sync
}
/**
* Updates the switch state.
* */
internal fun setSwitchCheckedState(isChecked: Boolean) {
switchView?.isChecked = isChecked
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
switchView = holder.findViewById(R.id.switch_widget) as SwitchCompat?
switchView?.isChecked = isChecked
switchView?.visibility = if (isSwitchWidgetVisible) View.VISIBLE else View.INVISIBLE
}
}

@ -5,7 +5,6 @@
package org.mozilla.fenix.settings
import androidx.lifecycle.LifecycleOwner
import androidx.preference.Preference
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import mozilla.components.concept.sync.AccountObserver
@ -14,33 +13,33 @@ import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.service.fxa.SyncEngine
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.manager.SyncEnginesStorage
import org.mozilla.fenix.R
/**
* A view to help manage the sync preference in the "Logins and passwords" and "Credit cards"
* settings. The provided [syncPreference] is used to navigate to the different fragments
* that manages the sync account authentication. A summary status will be also added
* that manages the sync account authentication. A toggle will be also added
* depending on the sync account status.
*
* @param syncPreference The sync [Preference] to update and handle navigation.
* @param syncPreference The sync [SyncPreference] to update and handle navigation.
* @param lifecycleOwner View lifecycle owner used to determine when to cancel UI jobs.
* @param accountManager An instance of [FxaAccountManager].
* @param syncEngine The sync engine that will be used for the sync status lookup.
* @param loggedOffTitle Text label for the setting when user is not logged in.
* @param loggedInTitle Text label for the setting when user is logged in.
* @param onSignInToSyncClicked A callback executed when the [syncPreference] is clicked with a
* preference status of "Sign in to Sync".
* @param onSyncStatusClicked A callback executed when the [syncPreference] is clicked with a
* preference status of "On" or "Off".
* @param onReconnectClicked A callback executed when the [syncPreference] is clicked with a
* preference status of "Reconnect".
*/
@Suppress("LongParameterList")
class SyncPreferenceView(
private val syncPreference: Preference,
private val syncPreference: SyncPreference,
lifecycleOwner: LifecycleOwner,
accountManager: FxaAccountManager,
private val syncEngine: SyncEngine,
private val loggedOffTitle: String,
private val loggedInTitle: String,
private val onSignInToSyncClicked: () -> Unit = {},
private val onSyncStatusClicked: () -> Unit = {},
private val onReconnectClicked: () -> Unit = {}
) {
@ -70,49 +69,54 @@ class SyncPreferenceView(
}
/**
* Shows the current status of the sync preference ("On"/"Off") for the logged in user.
* Shows a switch toggle for the sync preference when the user is logged in.
*/
private fun updateSyncPreferenceStatus() {
syncPreference.apply {
isSwitchWidgetVisible = true
val syncEnginesStatus = SyncEnginesStorage(context).getStatus()
val loginsSyncStatus = syncEnginesStatus.getOrElse(syncEngine) { false }
val syncStatus = syncEnginesStatus.getOrElse(syncEngine) { false }
summary = context.getString(
if (loginsSyncStatus) R.string.preferences_passwords_sync_logins_on
else R.string.preferences_passwords_sync_logins_off
)
title = loggedInTitle
isChecked = syncStatus
setOnPreferenceClickListener {
onSyncStatusClicked()
setOnPreferenceChangeListener { _, newValue ->
SyncEnginesStorage(context).setStatus(syncEngine, newValue as Boolean)
setSwitchCheckedState(newValue)
true
}
}
}
/**
* Display that the user can "Sign in to Sync" when the user is logged off.
* Display that the user can sync across devices when the user is logged off.
*/
private fun updateSyncPreferenceNeedsLogin() {
syncPreference.apply {
summary = context.getString(R.string.preferences_passwords_sync_logins_sign_in)
isSwitchWidgetVisible = false
title = loggedOffTitle
setOnPreferenceClickListener {
setOnPreferenceChangeListener { _, _ ->
onSignInToSyncClicked()
true
false
}
}
}
/**
* Displays that the user needs to "Reconnect" to fix their account problems with sync.
* Displays the logged off title to prompt the user to to re-authenticate their sync account.
*/
private fun updateSyncPreferenceNeedsReauth() {
syncPreference.apply {
summary = context.getString(R.string.preferences_passwords_sync_logins_reconnect)
isSwitchWidgetVisible = false
setOnPreferenceClickListener {
title = loggedOffTitle
setOnPreferenceChangeListener { _, _ ->
onReconnectClicked()
true
false
}
}
}

@ -25,6 +25,7 @@ import org.mozilla.fenix.crashes.CrashListActivity
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.about.AboutItemType.LICENSING_INFO
import org.mozilla.fenix.settings.about.AboutItemType.PRIVACY_NOTICE
@ -53,7 +54,7 @@ class AboutFragment : Fragment(), AboutPageListener {
appName = getString(R.string.app_name)
headerAppName =
if (Config.channel.isRelease) getString(R.string.daylight_app_name) else appName
activity?.title = getString(R.string.preferences_about, appName)
showToolbar(getString(R.string.preferences_about, appName))
return rootView
}

@ -35,6 +35,7 @@ import mozilla.components.service.fxa.sync.SyncReason
import mozilla.components.service.fxa.sync.SyncStatusObserver
import mozilla.components.service.fxa.sync.getLastSynced
import mozilla.components.support.ktx.android.content.getColorFromAttr
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
@ -164,44 +165,40 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
updateSyncEngineStates()
setDisabledWhileSyncing(accountManager.isSyncActive())
fun updateSyncEngineState(context: Context, engine: SyncEngine, newState: Boolean) {
SyncEnginesStorage(context).setStatus(engine, newState)
viewLifecycleOwner.lifecycleScope.launch {
context.components.backgroundServices.accountManager.syncNow(SyncReason.EngineChange)
}
}
fun SyncEngine.prefId(): Int = when (this) {
SyncEngine.History -> R.string.pref_key_sync_history
SyncEngine.Bookmarks -> R.string.pref_key_sync_bookmarks
SyncEngine.Passwords -> R.string.pref_key_sync_logins
SyncEngine.Tabs -> R.string.pref_key_sync_tabs
SyncEngine.CreditCards -> R.string.pref_key_sync_credit_cards
SyncEngine.Addresses -> R.string.pref_key_sync_address
else -> throw IllegalStateException("Accessing internal sync engines")
}
listOf(SyncEngine.History, SyncEngine.Bookmarks, SyncEngine.Tabs).forEach {
listOf(
SyncEngine.History,
SyncEngine.Bookmarks,
SyncEngine.Tabs,
SyncEngine.Addresses
).forEach {
requirePreference<CheckBoxPreference>(it.prefId()).apply {
setOnPreferenceChangeListener { _, newValue ->
updateSyncEngineState(context, it, newValue as Boolean)
updateSyncEngineState(it, newValue as Boolean)
true
}
}
}
// 'Passwords' listener is special, since we also display a pin protection warning.
requirePreference<CheckBoxPreference>(SyncEngine.Passwords.prefId()).apply {
setOnPreferenceChangeListener { _, newValue ->
val manager =
activity?.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (manager.isKeyguardSecure ||
newValue == false ||
!context.settings().shouldShowSecurityPinWarningSync
) {
updateSyncEngineState(context, SyncEngine.Passwords, newValue as Boolean)
} else {
showPinDialogWarning(newValue as Boolean)
// 'Passwords' and 'Credit card' listeners are special, since we also display a pin protection warning.
listOf(
SyncEngine.Passwords,
SyncEngine.CreditCards
).forEach {
requirePreference<CheckBoxPreference>(it.prefId()).apply {
setOnPreferenceChangeListener { _, newValue ->
updateSyncEngineStateWithPinWarning(it, newValue as Boolean)
true
}
true
}
}
@ -218,7 +215,57 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
)
}
private fun showPinDialogWarning(newValue: Boolean) {
/**
* Prompts the user if they do not have a password/pin set up to secure their device, and
* updates the state of the sync engine with the new checkbox value.
*
* Currently used for logins and credit cards.
*
* @param syncEngine the sync engine whose preference has changed.
* @param newValue the value denoting whether or not to sync the specified preference.
*/
private fun updateSyncEngineStateWithPinWarning(
syncEngine: SyncEngine,
newValue: Boolean
) {
val manager = activity?.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (manager.isKeyguardSecure ||
!newValue ||
!requireContext().settings().shouldShowSecurityPinWarningSync
) {
updateSyncEngineState(syncEngine, newValue)
} else {
showPinDialogWarning(syncEngine, newValue)
}
}
/**
* Updates the sync engine status with the new state of the preference and triggers a sync
* event.
*
* @param engine the sync engine whose preference has changed.
* @param newValue the new value of the sync preference, where true indicates sync for that
* preference and false indicates not synced.
*/
private fun updateSyncEngineState(engine: SyncEngine, newValue: Boolean) {
SyncEnginesStorage(requireContext()).setStatus(engine, newValue)
viewLifecycleOwner.lifecycleScope.launch {
requireContext().components.backgroundServices.accountManager.syncNow(SyncReason.EngineChange)
}
}
/**
* Creates and shows a warning dialog that prompts the user to create a pin/password to
* secure their device when none is detected. The user has the option to continue with
* updating their sync preferences (updates the [SyncEngine] state) or navigating to
* device security settings to create a pin/password.
*
* @param syncEngine the sync engine whose preference has changed.
* @param newValue the new value of the sync preference, where true indicates sync for that
* preference and false indicates not synced.
*/
private fun showPinDialogWarning(syncEngine: SyncEngine, newValue: Boolean) {
context?.let {
AlertDialog.Builder(it).apply {
setTitle(getString(R.string.logins_warning_dialog_title))
@ -227,11 +274,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
)
setNegativeButton(getString(R.string.logins_warning_dialog_later)) { _: DialogInterface, _ ->
SyncEnginesStorage(context).setStatus(SyncEngine.Passwords, newValue)
// Use fragment's lifecycle; the view may be gone by the time dialog is interacted with.
lifecycleScope.launch {
context.components.backgroundServices.accountManager.syncNow(SyncReason.EngineChange)
}
updateSyncEngineState(syncEngine, newValue)
}
setPositiveButton(getString(R.string.logins_warning_dialog_set_up_now)) { it: DialogInterface, _ ->
@ -247,12 +290,20 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
}
}
/**
* Updates the status of all [SyncEngine] states.
*/
private fun updateSyncEngineStates() {
val syncEnginesStatus = SyncEnginesStorage(requireContext()).getStatus()
requirePreference<CheckBoxPreference>(R.string.pref_key_sync_bookmarks).apply {
isEnabled = syncEnginesStatus.containsKey(SyncEngine.Bookmarks)
isChecked = syncEnginesStatus.getOrElse(SyncEngine.Bookmarks) { true }
}
requirePreference<CheckBoxPreference>(R.string.pref_key_sync_credit_cards).apply {
isVisible = FeatureFlags.creditCardsFeature
isEnabled = syncEnginesStatus.containsKey(SyncEngine.CreditCards)
isChecked = syncEnginesStatus.getOrElse(SyncEngine.CreditCards) { true }
}
requirePreference<CheckBoxPreference>(R.string.pref_key_sync_history).apply {
isEnabled = syncEnginesStatus.containsKey(SyncEngine.History)
isChecked = syncEnginesStatus.getOrElse(SyncEngine.History) { true }
@ -265,8 +316,17 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
isEnabled = syncEnginesStatus.containsKey(SyncEngine.Tabs)
isChecked = syncEnginesStatus.getOrElse(SyncEngine.Tabs) { true }
}
requirePreference<CheckBoxPreference>(R.string.pref_key_sync_address).apply {
isVisible = FeatureFlags.addressesFeature
isEnabled = syncEnginesStatus.containsKey(SyncEngine.Addresses)
isChecked = syncEnginesStatus.getOrElse(SyncEngine.Addresses) { true }
}
}
/**
* Manual sync triggered by the user. This also checks account authentication and refreshes the
* device list.
*/
private fun syncNow() {
viewLifecycleOwner.lifecycleScope.launch {
requireComponents.analytics.metrics.track(Event.SyncAccountSyncNow)
@ -281,8 +341,13 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
}
}
private fun syncDeviceName(newValue: String): Boolean {
if (newValue.trim().isEmpty()) {
/**
* Takes a non-empty value and sets the device name. May fail due to authentication.
*
* @param newDeviceName the new name of the device. Cannot be an empty string.
*/
private fun syncDeviceName(newDeviceName: String): Boolean {
if (newDeviceName.trim().isEmpty()) {
return false
}
// This may fail, and we'll have a disparity in the UI until `updateDeviceName` is called.
@ -290,7 +355,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
context?.let {
accountManager.authenticatedAccount()
?.deviceConstellation()
?.setDeviceName(newValue, it)
?.setDeviceName(newDeviceName, it)
}
}
return true
@ -409,6 +474,5 @@ class AccountSettingsFragment : PreferenceFragmentCompat() {
companion object {
private const val DEVICE_NAME_MAX_LENGTH = 128
private const val DEVICE_NAME_EDIT_TEXT_MIN_HEIGHT_DP = 48
}
}

@ -77,7 +77,7 @@ class LocaleViewHolder(
"ca" to "Català",
"cak" to "Kaqchikel",
"ceb" to "Cebuano",
"co" to "Corsu, ",
"co" to "Corsu",
"cs" to "čeština",
"cy" to "Cymraeg",
"da" to "dansk",
@ -99,7 +99,7 @@ class LocaleViewHolder(
"gn" to "Avañe'ẽ",
"gu-IN" to "ગુજરાતી",
"he" to "עברית",
"hi-IN" to "हिन्दी ",
"hi-IN" to "हिन्दी",
"hil" to "Ilonggo",
"hr" to "hrvatski jezik",
"hsb" to "Hornjoserbsce",
@ -216,7 +216,7 @@ class LocaleViewHolder(
"it" to "Italian",
"ja" to "Japanese",
"ka" to "Georgian",
"kab" to "Kabyle ",
"kab" to "Kabyle",
"kk" to "Kazakh",
"kmr" to "Kurmanji Kurdish",
"kn" to "Kannada",
@ -230,7 +230,7 @@ class LocaleViewHolder(
"ms" to "Malay",
"my" to "Burmese",
"nb-NO" to "Norwegian Bokmål",
"ne-NP" to "Nepali ",
"ne-NP" to "Nepali",
"nl" to "Dutch, Flemish",
"nn-NO" to "Norwegian Nynorsk",
"nv" to "Navajo, Navaho",

@ -0,0 +1,120 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.biometric
import android.app.Activity.RESULT_OK
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.view.View
import androidx.core.content.getSystemService
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.settings.requirePreference
/**
* Helper for creating and implementing the [BiometricPromptFeature]. Currently used
* for logins and credit cards.
*/
abstract class BiometricPromptPreferenceFragment : PreferenceFragmentCompat() {
private val biometricPromptFeature = ViewBoundFeatureWrapper<BiometricPromptFeature>()
/**
* Gets the string to be used for [BiometricPromptFeature.requestAuthentication] prompting to
* unlock the device.
*/
abstract fun unlockMessage(): String
/**
* Navigate when authentication is successful.
*/
abstract fun navigateOnSuccess()
/**
* Shows a dialog warning to set up a pin/password when the device is not secured. This is
* only used when BiometricPrompt is unavailable on the device.
*/
abstract fun showPinDialogWarning(context: Context)
/**
* Toggle preferences to enable or disable navigation during authentication flows.
*
* @param prefList a list of [Preference]s to toggle.
* @param enabled whether or not the preferences should be enabled.
*/
fun togglePrefsEnabled(prefList: List<Int>, enabled: Boolean) {
for (preference in prefList) {
requirePreference<Preference>(preference).isEnabled = enabled
}
}
/**
* Creates a prompt to verify the device's pin/password and start activity based on the result.
* This is only used when BiometricPrompt is unavailable on the device.
*/
@Suppress("Deprecation")
abstract fun showPinVerification(manager: KeyguardManager)
/**
* Sets the biometric prompt feature.
*
* @param view the view that the prompt will be associate with.
* @param prefList a list of [Preference]s to toggle.
*/
fun setBiometricPrompt(view: View, prefList: List<Int>) {
biometricPromptFeature.set(
feature = BiometricPromptFeature(
context = requireContext(),
fragment = this,
onAuthFailure = {
togglePrefsEnabled(prefList, true)
},
onAuthSuccess = ::navigateOnSuccess
),
owner = this,
view = view
)
}
/**
* Use [BiometricPromptFeature] or [KeyguardManager] to confirm device security.
*
* @param prefList a list of [Preference]s to disable while authentication is happening.
*/
fun verifyCredentialsOrShowSetupWarning(context: Context, prefList: List<Int>) {
// Use the BiometricPrompt if available
if (BiometricPromptFeature.canUseFeature(context)) {
togglePrefsEnabled(prefList, false)
biometricPromptFeature.get()?.requestAuthentication(unlockMessage())
return
}
// Fallback to prompting for password with the KeyguardManager
val manager = context.getSystemService<KeyguardManager>()
if (manager?.isKeyguardSecure == true) {
showPinVerification(manager)
} else {
// Warn that the device has not been secured
if (context.settings().shouldShowSecurityPinWarning) {
showPinDialogWarning(context)
} else {
navigateOnSuccess()
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PIN_REQUEST && resultCode == RESULT_OK) {
navigateOnSuccess()
}
}
companion object {
const val PIN_REQUEST = 303
}
}

@ -9,15 +9,14 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.fragment_credit_card_editor.*
import mozilla.components.concept.storage.UpdatableCreditCardFields
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.SecureFragment
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.creditcards.controller.DefaultCreditCardEditorController
import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor
@ -27,7 +26,11 @@ import org.mozilla.fenix.settings.creditcards.view.CreditCardEditorView
/**
* Display a credit card editor for adding and editing a credit card.
*/
class CreditCardEditorFragment : Fragment(R.layout.fragment_credit_card_editor) {
class CreditCardEditorFragment : SecureFragment(R.layout.fragment_credit_card_editor) {
private lateinit var creditCardEditorState: CreditCardEditorState
private lateinit var creditCardEditorView: CreditCardEditorView
private lateinit var menu: Menu
private val args by navArgs<CreditCardEditorFragmentArgs>()
@ -50,48 +53,53 @@ class CreditCardEditorFragment : Fragment(R.layout.fragment_credit_card_editor)
showToolbar(getString(R.string.credit_cards_edit_card))
}
val storage = requireContext().components.core.autofillStorage
interactor = DefaultCreditCardEditorInteractor(
controller = DefaultCreditCardEditorController(
storage = requireContext().components.core.autofillStorage,
storage = storage,
lifecycleScope = lifecycleScope,
navController = findNavController()
navController = findNavController(),
settings = requireContext().settings()
)
)
val creditCardEditorState =
args.creditCard?.toCreditCardEditorState() ?: getInitialCreditCardEditorState()
CreditCardEditorView(view, interactor).bind(creditCardEditorState)
creditCardEditorState =
args.creditCard?.toCreditCardEditorState(storage) ?: getInitialCreditCardEditorState()
creditCardEditorView = CreditCardEditorView(view, interactor)
creditCardEditorView.bind(creditCardEditorState)
}
/**
* Close any open dialogs or menus and reauthenticate if the fragment is paused and
* the user is not navigating to [CreditCardsManagementFragment].
*/
override fun onPause() {
menu.close()
redirectToReAuth(
listOf(R.id.creditCardsManagementFragment),
findNavController().currentDestination?.id,
R.id.creditCardEditorFragment
)
super.onPause()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.credit_card_editor, menu)
this.menu = menu
menu.findItem(R.id.delete_credit_card_button).isVisible = isEditing
}
@Suppress("MagicNumber")
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.delete_credit_card_button -> {
args.creditCard?.let { interactor.onDeleteCardButtonClicked(it.guid) }
true
}
R.id.save_credit_card_button -> {
view?.hideKeyboard()
val creditCard = args.creditCard
val creditCardFields = UpdatableCreditCardFields(
billingName = name_on_card_input.text.toString(),
cardNumber = card_number_input.text.toString(),
expiryMonth = (expiry_month_drop_down.selectedItemPosition + 1).toLong(),
expiryYear = expiry_year_drop_down.selectedItem.toString().toLong(),
cardType = CARD_TYPE_PLACEHOLDER
)
if (creditCard != null) {
interactor.onUpdateCreditCard(creditCard.guid, creditCardFields)
} else {
interactor.onSaveCreditCard(creditCardFields)
}
creditCardEditorView.saveCreditCard(creditCardEditorState)
true
}
else -> false
@ -100,9 +108,5 @@ class CreditCardEditorFragment : Fragment(R.layout.fragment_credit_card_editor)
companion object {
// Number of years to show in the expiry year dropdown.
const val NUMBER_OF_YEARS_TO_SHOW = 10
// Placeholder for the card type. This will be replaced when we can identify the card type.
// This is dependent on https://github.com/mozilla-mobile/android-components/issues/9813.
const val CARD_TYPE_PLACEHOLDER = ""
}
}

@ -5,6 +5,7 @@
package org.mozilla.fenix.settings.creditcards
import mozilla.components.concept.storage.CreditCard
import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage
import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment.Companion.NUMBER_OF_YEARS_TO_SHOW
import java.util.Calendar
@ -30,7 +31,10 @@ data class CreditCardEditorState(
/**
* Returns a [CreditCardEditorState] from the given [CreditCard].
*/
fun CreditCard.toCreditCardEditorState(): CreditCardEditorState {
fun CreditCard.toCreditCardEditorState(storage: AutofillCreditCardsAddressesStorage): CreditCardEditorState {
val crypto = storage.getCreditCardCrypto()
val key = crypto.key()
val cardNumber = crypto.decrypt(key, encryptedCardNumber)?.number ?: ""
val startYear = expiryYear.toInt()
val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW

@ -8,7 +8,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import kotlinx.android.synthetic.main.fragment_saved_cards.view.*
@ -17,8 +16,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.R
import org.mozilla.fenix.SecureFragment
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.creditcards.controller.DefaultCreditCardsManagementController
import org.mozilla.fenix.settings.creditcards.interactor.CreditCardsManagementInteractor
@ -28,7 +29,7 @@ import org.mozilla.fenix.settings.creditcards.view.CreditCardsManagementView
/**
* Displays a list of saved credit cards.
*/
class CreditCardsManagementFragment : Fragment() {
class CreditCardsManagementFragment : SecureFragment() {
private lateinit var creditCardsStore: CreditCardsFragmentStore
private lateinit var interactor: CreditCardsManagementInteractor
@ -61,6 +62,11 @@ class CreditCardsManagementFragment : Fragment() {
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
consumeFrom(creditCardsStore) { state ->
if (!state.isLoading && state.creditCards.isEmpty()) {
findNavController().popBackStack()
return@consumeFrom
}
creditCardsView.update(state)
}
}
@ -70,6 +76,20 @@ class CreditCardsManagementFragment : Fragment() {
showToolbar(getString(R.string.credit_cards_saved_cards))
}
/**
* When the fragment is paused, navigate back to the settings page to reauthenticate.
*/
override fun onPause() {
// Don't redirect if the user is navigating to the credit card editor fragment.
redirectToReAuth(
listOf(R.id.creditCardEditorFragment),
findNavController().currentDestination?.id,
R.id.creditCardsManagementFragment
)
super.onPause()
}
/**
* Fetches all the credit cards from the autofill storage and updates the
* [CreditCardsFragmentStore] with the list of credit cards.

@ -4,27 +4,115 @@
package org.mozilla.fenix.settings.creditcards
import android.app.KeyguardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.service.fxa.SyncEngine
import org.mozilla.fenix.NavGraphDirections
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.secure
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.SharedPreferenceUpdater
import org.mozilla.fenix.settings.SyncPreferenceView
import org.mozilla.fenix.settings.biometric.BiometricPromptFeature
import org.mozilla.fenix.settings.biometric.BiometricPromptPreferenceFragment
import org.mozilla.fenix.settings.requirePreference
/**
* "Credit cards" settings fragment displays a list of settings related to autofilling, adding and
* syncing credit cards.
* syncing credit cards. Authentication for saved credit cards uses [BiometricPromptFeature]
* or [KeyguardManager].
*/
class CreditCardsSettingFragment : PreferenceFragmentCompat() {
@SuppressWarnings("TooManyFunctions")
class CreditCardsSettingFragment : BiometricPromptPreferenceFragment() {
private lateinit var creditCardsStore: CreditCardsFragmentStore
private var isCreditCardsListLoaded: Boolean = false
/**
* List of preferences to be enabled or disabled during authentication.
*/
private val creditCardPreferences: List<Int> = listOf(
R.string.pref_key_credit_cards_save_and_autofill_cards,
R.string.pref_key_credit_cards_sync_cards_across_devices,
R.string.pref_key_credit_cards_manage_cards
)
override fun unlockMessage() = getString(R.string.credit_cards_biometric_prompt_message)
override fun navigateOnSuccess() {
runIfFragmentIsAttached {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
// Workaround for likely biometric library bug
// https://github.com/mozilla-mobile/fenix/issues/8438
delay(SHORT_DELAY_MS)
navigateToCreditCardManagementFragment()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
creditCardsStore = StoreProvider.get(this) {
CreditCardsFragmentStore(CreditCardsListState(creditCards = emptyList()))
}
loadCreditCards()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.credit_cards_preferences, rootKey)
requirePreference<SwitchPreference>(R.string.pref_key_credit_cards_save_and_autofill_cards).apply {
isChecked = context.settings().shouldAutofillCreditCardDetails
onPreferenceChangeListener = SharedPreferenceUpdater()
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
loadCreditCards()
return super.onCreateView(inflater, container, savedInstanceState)
}
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
consumeFrom(creditCardsStore) { state ->
updateCardManagementPreference(state.creditCards.isNotEmpty(), findNavController())
}
setBiometricPrompt(view, creditCardPreferences)
}
override fun onPause() {
super.onPause()
isCreditCardsListLoaded = false
}
override fun onResume() {
@ -36,42 +124,126 @@ class CreditCardsSettingFragment : PreferenceFragmentCompat() {
syncPreference = requirePreference(R.string.pref_key_credit_cards_sync_cards_across_devices),
lifecycleOwner = viewLifecycleOwner,
accountManager = requireComponents.backgroundServices.accountManager,
syncEngine = SyncEngine.Passwords,
syncEngine = SyncEngine.CreditCards,
loggedOffTitle = requireContext()
.getString(R.string.preferences_credit_cards_sync_cards_across_devices),
loggedInTitle = requireContext()
.getString(R.string.preferences_credit_cards_sync_cards),
onSignInToSyncClicked = {
val directions =
CreditCardsSettingFragmentDirections.actionCreditCardsSettingFragmentToTurnOnSyncFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
},
onSyncStatusClicked = {
val directions =
CreditCardsSettingFragmentDirections.actionGlobalAccountSettingsFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
findNavController().navigateBlockingForAsyncNavGraph(
NavGraphDirections.actionGlobalTurnOnSync()
)
},
onReconnectClicked = {
val directions =
findNavController().navigateBlockingForAsyncNavGraph(
CreditCardsSettingFragmentDirections.actionGlobalAccountProblemFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
)
}
)
togglePrefsEnabled(creditCardPreferences, true)
}
@Suppress("MaxLineLength")
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
getPreferenceKey(R.string.pref_key_credit_cards_add_credit_card) -> {
val directions =
/**
* Updates preferences visibility depending on credit cards being already saved or not.
*/
@VisibleForTesting
internal fun updateCardManagementPreference(
hasCreditCards: Boolean,
navController: NavController
) {
val manageSavedCardsPreference =
requirePreference<Preference>(R.string.pref_key_credit_cards_manage_cards)
if (hasCreditCards) {
manageSavedCardsPreference.icon = null
manageSavedCardsPreference.title =
getString(R.string.preferences_credit_cards_manage_saved_cards)
} else {
manageSavedCardsPreference.setIcon(R.drawable.ic_new)
manageSavedCardsPreference.title =
getString(R.string.preferences_credit_cards_add_credit_card)
}
manageSavedCardsPreference.setOnPreferenceClickListener {
if (hasCreditCards) {
verifyCredentialsOrShowSetupWarning(requireContext(), creditCardPreferences)
} else {
navController.navigateBlockingForAsyncNavGraph(
CreditCardsSettingFragmentDirections
.actionCreditCardsSettingFragmentToCreditCardEditorFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
)
}
getPreferenceKey(R.string.pref_key_credit_cards_manage_saved_cards) -> {
val directions =
CreditCardsSettingFragmentDirections
.actionCreditCardsSettingFragmentToCreditCardsManagementFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
super.onPreferenceTreeClick(it)
}
}
/**
* Fetches all the credit cards from autofillStorage and updates the [CreditCardsListState]
* with the list of credit cards.
*/
private fun loadCreditCards() {
if (isCreditCardsListLoaded) {
return
}
lifecycleScope.launch(Dispatchers.IO) {
val creditCards = requireComponents.core.autofillStorage.getAllCreditCards()
lifecycleScope.launch(Dispatchers.Main) {
creditCardsStore.dispatch(CreditCardsAction.UpdateCreditCards(creditCards))
}
}
return super.onPreferenceTreeClick(preference)
isCreditCardsListLoaded = true
}
/**
* Shows a dialog warning to set up a pin/password when the device is not secured. This is
* only used when BiometricPrompt is unavailable on the device.
*/
override fun showPinDialogWarning(context: Context) {
AlertDialog.Builder(context).apply {
setTitle(getString(R.string.credit_cards_warning_dialog_title))
setMessage(getString(R.string.credit_cards_warning_dialog_message))
setNegativeButton(getString(R.string.credit_cards_warning_dialog_later)) { _: DialogInterface, _ ->
navigateToCreditCardManagementFragment()
}
setPositiveButton(getString(R.string.credit_cards_warning_dialog_set_up_now)) { it: DialogInterface, _ ->
it.dismiss()
val intent = Intent(Settings.ACTION_SECURITY_SETTINGS)
startActivity(intent)
}
create()
}.show().secure(activity)
context.settings().incrementSecureWarningCount()
}
/**
* Shows a prompt to verify the device's pin/password and start activity based on the result.
* This is only used when BiometricPrompt is unavailable on the device.
*
* @param manager The device [KeyguardManager]
*/
@Suppress("Deprecation")
override fun showPinVerification(manager: KeyguardManager) {
val intent = manager.createConfirmDeviceCredentialIntent(
getString(R.string.credit_cards_biometric_prompt_message_pin),
getString(R.string.credit_cards_biometric_prompt_message)
)
startActivityForResult(intent, BiometricPromptPreferenceFragment.PIN_REQUEST)
}
private fun navigateToCreditCardManagementFragment() {
val directions =
CreditCardsSettingFragmentDirections
.actionCreditCardsSettingFragmentToCreditCardsManagementFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
}
companion object {
const val SHORT_DELAY_MS = 100L
}
}

@ -0,0 +1,57 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.creditcards
import androidx.annotation.VisibleForTesting
import mozilla.components.support.utils.creditCardIIN
// Number of last digits to be shown when credit card number is obfuscated.
private const val LAST_VISIBLE_DIGITS_COUNT = 4
/**
* Strips characters other than digits from a string.
* Used to strip a credit card number user input of spaces and separators.
*/
fun String.toCreditCardNumber(): String {
return this.filter { it.isDigit() }
}
/**
* Returns the last 4 digits from a formatted credit card number string.
*/
fun String.last4Digits(): String {
return this.takeLast(LAST_VISIBLE_DIGITS_COUNT)
}
/**
* Returns true if the provided string is a valid credit card by checking if it has a matching
* credit card issuer network passes the Luhn Algorithm, and false otherwise.
*/
fun String.validateCreditCardNumber(): Boolean {
val creditCardNumber = this.toCreditCardNumber()
if (creditCardNumber != this || creditCardNumber.creditCardIIN() == null) {
return false
}
return luhnAlgorithmValidation(creditCardNumber)
}
/**
* Implementation of Luhn Algorithm validation (https://en.wikipedia.org/wiki/Luhn_algorithm)
*/
@Suppress("MagicNumber")
@VisibleForTesting
internal fun luhnAlgorithmValidation(creditCardNumber: String): Boolean {
var checksum = 0
val reversedCardNumber = creditCardNumber.reversed()
for (index in reversedCardNumber.indices) {
val digit = Character.getNumericValue(reversedCardNumber[index])
checksum += if (index % 2 == 0) digit else (digit * 2).let { (it / 10) + (it % 10) }
}
return (checksum % 10) == 0
}

@ -9,10 +9,12 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mozilla.components.concept.storage.NewCreditCardFields
import mozilla.components.concept.storage.UpdatableCreditCardFields
import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage
import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment
import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor
import org.mozilla.fenix.utils.Settings
/**
* [CreditCardEditorFragment] controller. An interface that handles the view manipulation of the
@ -33,7 +35,7 @@ interface CreditCardEditorController {
/**
* @see [CreditCardEditorInteractor.onSaveCreditCard]
*/
fun handleSaveCreditCard(creditCardFields: UpdatableCreditCardFields)
fun handleSaveCreditCard(creditCardFields: NewCreditCardFields)
/**
* @see [CreditCardEditorInteractor.onUpdateCreditCard]
@ -48,12 +50,14 @@ interface CreditCardEditorController {
* credit cards.
* @param lifecycleScope [CoroutineScope] scope to launch coroutines.
* @param navController [NavController] used for navigation.
* @param settings [Settings] application settings.
* @param ioDispatcher [CoroutineDispatcher] used for executing async tasks. Defaults to [Dispatchers.IO].
*/
class DefaultCreditCardEditorController(
private val storage: AutofillCreditCardsAddressesStorage,
private val lifecycleScope: CoroutineScope,
private val navController: NavController,
private val settings: Settings,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : CreditCardEditorController {
@ -62,6 +66,8 @@ class DefaultCreditCardEditorController(
}
override fun handleDeleteCreditCard(guid: String) {
settings.creditCardsDeletedCount += 1
lifecycleScope.launch(ioDispatcher) {
storage.deleteCreditCard(guid)
@ -71,7 +77,9 @@ class DefaultCreditCardEditorController(
}
}
override fun handleSaveCreditCard(creditCardFields: UpdatableCreditCardFields) {
override fun handleSaveCreditCard(creditCardFields: NewCreditCardFields) {
settings.creditCardsSavedCount += 1
lifecycleScope.launch(ioDispatcher) {
storage.addCreditCard(creditCardFields)

@ -21,6 +21,11 @@ interface CreditCardsManagementController {
* @see [CreditCardsManagementInteractor.onSelectCreditCard]
*/
fun handleCreditCardClicked(creditCard: CreditCard)
/**
* @see [CreditCardsManagementInteractor.onAddCreditCardClick]
*/
fun handleAddCreditCardClicked()
}
/**
@ -31,6 +36,14 @@ class DefaultCreditCardsManagementController(
) : CreditCardsManagementController {
override fun handleCreditCardClicked(creditCard: CreditCard) {
navigateToCreditCardEditor(creditCard)
}
override fun handleAddCreditCardClicked() {
navigateToCreditCardEditor()
}
private fun navigateToCreditCardEditor(creditCard: CreditCard? = null) {
navController.navigateBlockingForAsyncNavGraph(
CreditCardsManagementFragmentDirections
.actionCreditCardsManagementFragmentToCreditCardEditorFragment(

@ -4,6 +4,7 @@
package org.mozilla.fenix.settings.creditcards.interactor
import mozilla.components.concept.storage.NewCreditCardFields
import mozilla.components.concept.storage.UpdatableCreditCardFields
import org.mozilla.fenix.settings.creditcards.controller.CreditCardEditorController
@ -30,9 +31,9 @@ interface CreditCardEditorInteractor {
* Saves the provided credit card field into the credit card storage. Called when a user
* taps on the save menu item or "Save" button.
*
* @param creditCardFields A [UpdatableCreditCardFields] record to add.
* @param creditCardFields A [NewCreditCardFields] record to add.
*/
fun onSaveCreditCard(creditCardFields: UpdatableCreditCardFields)
fun onSaveCreditCard(creditCardFields: NewCreditCardFields)
/**
* Updates the provided credit card with the new credit card fields. Called when a user
@ -62,7 +63,7 @@ class DefaultCreditCardEditorInteractor(
controller.handleDeleteCreditCard(guid)
}
override fun onSaveCreditCard(creditCardFields: UpdatableCreditCardFields) {
override fun onSaveCreditCard(creditCardFields: NewCreditCardFields) {
controller.handleSaveCreditCard(creditCardFields)
}

@ -19,6 +19,12 @@ interface CreditCardsManagementInteractor {
* @param creditCard The selected [CreditCard] to edit.
*/
fun onSelectCreditCard(creditCard: CreditCard)
/**
* Navigates to the credit card editor to add a new credit card. Called when a user
* taps on 'Add credit card' button.
*/
fun onAddCreditCardClick()
}
/**
@ -34,4 +40,8 @@ class DefaultCreditCardsManagementInteractor(
override fun onSelectCreditCard(creditCard: CreditCard) {
controller.handleCreditCardClicked(creditCard)
}
override fun onAddCreditCardClick() {
controller.handleAddCreditCardClicked()
}
}

@ -4,17 +4,25 @@
package org.mozilla.fenix.settings.creditcards.view
import android.R
import android.view.View
import android.widget.ArrayAdapter
import androidx.annotation.VisibleForTesting
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_credit_card_editor.*
import mozilla.components.concept.storage.CreditCardNumber
import mozilla.components.concept.storage.NewCreditCardFields
import mozilla.components.concept.storage.UpdatableCreditCardFields
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.ktx.android.view.hideKeyboard
import mozilla.components.support.utils.creditCardIIN
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.toEditable
import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment.Companion.CARD_TYPE_PLACEHOLDER
import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment
import org.mozilla.fenix.settings.creditcards.CreditCardEditorState
import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor
import org.mozilla.fenix.settings.creditcards.last4Digits
import org.mozilla.fenix.settings.creditcards.toCreditCardNumber
import org.mozilla.fenix.settings.creditcards.validateCreditCardNumber
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@ -46,21 +54,7 @@ class CreditCardEditorView(
}
save_button.setOnClickListener {
containerView.hideKeyboard()
val creditCardFields = UpdatableCreditCardFields(
billingName = name_on_card_input.text.toString(),
cardNumber = card_number_input.text.toString(),
expiryMonth = (expiry_month_drop_down.selectedItemPosition + 1).toLong(),
expiryYear = expiry_year_drop_down.selectedItem.toString().toLong(),
cardType = CARD_TYPE_PLACEHOLDER
)
if (state.isEditing) {
interactor.onUpdateCreditCard(state.guid, creditCardFields)
} else {
interactor.onSaveCreditCard(creditCardFields)
}
saveCreditCard(state)
}
card_number_input.text = state.cardNumber.toEditable()
@ -70,6 +64,64 @@ class CreditCardEditorView(
bindExpiryYearDropDown(state.expiryYears)
}
/**
* Saves a new credit card or updates an existing one with data from the user input.
*
* @param state The state of the [CreditCardEditorFragment] containing the edited credit card
* information.
*/
internal fun saveCreditCard(state: CreditCardEditorState) {
containerView.hideKeyboard()
if (validateCreditCard()) {
val cardNumber = card_number_input.text.toString().toCreditCardNumber()
if (state.isEditing) {
val fields = UpdatableCreditCardFields(
billingName = name_on_card_input.text.toString(),
cardNumber = CreditCardNumber.Plaintext(cardNumber),
cardNumberLast4 = cardNumber.last4Digits(),
expiryMonth = (expiry_month_drop_down.selectedItemPosition + 1).toLong(),
expiryYear = expiry_year_drop_down.selectedItem.toString().toLong(),
cardType = cardNumber.creditCardIIN()?.creditCardIssuerNetwork?.name ?: ""
)
interactor.onUpdateCreditCard(state.guid, fields)
} else {
val fields = NewCreditCardFields(
billingName = name_on_card_input.text.toString(),
plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber),
cardNumberLast4 = cardNumber.last4Digits(),
expiryMonth = (expiry_month_drop_down.selectedItemPosition + 1).toLong(),
expiryYear = expiry_year_drop_down.selectedItem.toString().toLong(),
cardType = cardNumber.creditCardIIN()?.creditCardIssuerNetwork?.name ?: ""
)
interactor.onSaveCreditCard(fields)
}
}
}
/**
* Validates the credit card information entered by the user.
*
* @return true if the credit card is valid, false otherwise.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun validateCreditCard(): Boolean {
var isValid = true
if (card_number_input.text.toString().validateCreditCardNumber()) {
card_number_layout.error = null
card_number_title.setTextColor(containerView.context.getColorFromAttr(R.attr.primaryText))
} else {
card_number_layout.error =
containerView.context.getString(R.string.credit_cards_number_validation_error_message)
card_number_title.setTextColor(containerView.context.getColorFromAttr(R.attr.destructive))
isValid = false
}
return isValid
}
/**
* Setup the expiry month dropdown by formatting and populating it with the months in a calendar
* year, and set the selection to the provided expiry month.
@ -78,7 +130,10 @@ class CreditCardEditorView(
*/
private fun bindExpiryMonthDropDown(expiryMonth: Int) {
val adapter =
ArrayAdapter<String>(containerView.context, R.layout.simple_spinner_dropdown_item)
ArrayAdapter<String>(
containerView.context,
android.R.layout.simple_spinner_dropdown_item
)
val dateFormat = SimpleDateFormat("MMMM (MM)", Locale.getDefault())
val calendar = Calendar.getInstance()
@ -101,7 +156,10 @@ class CreditCardEditorView(
*/
private fun bindExpiryYearDropDown(expiryYears: Pair<Int, Int>) {
val adapter =
ArrayAdapter<String>(containerView.context, R.layout.simple_spinner_dropdown_item)
ArrayAdapter<String>(
containerView.context,
android.R.layout.simple_spinner_dropdown_item
)
val (startYear, endYear) = expiryYears
for (year in startYear until endYear) {

@ -7,6 +7,7 @@ package org.mozilla.fenix.settings.creditcards.view
import android.view.View
import kotlinx.android.synthetic.main.credit_card_list_item.*
import mozilla.components.concept.storage.CreditCard
import mozilla.components.support.utils.creditCardIssuerNetwork
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.creditcards.interactor.CreditCardsManagementInteractor
import org.mozilla.fenix.utils.view.ViewHolder
@ -23,7 +24,9 @@ class CreditCardItemViewHolder(
) : ViewHolder(view) {
fun bind(creditCard: CreditCard) {
credit_card_number.text = creditCard.cardNumber
credit_card_logo.setImageResource(creditCard.cardType.creditCardIssuerNetwork().icon)
credit_card_number.text = creditCard.obfuscatedCardNumber
bindCreditCardExpiryDate(creditCard)

@ -31,6 +31,8 @@ class CreditCardsManagementView(
adapter = creditCardsAdapter
layoutManager = LinearLayoutManager(containerView.context)
}
add_credit_card_button.setOnClickListener { interactor.onAddCreditCardClick() }
}
/**

@ -266,7 +266,8 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
override fun onPause() {
redirectToReAuth(
listOf(R.id.loginDetailFragment),
findNavController().currentDestination?.id
findNavController().currentDestination?.id,
R.id.editLoginFragment
)
super.onPause()
}

@ -131,7 +131,8 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
menu.close()
redirectToReAuth(
listOf(R.id.editLoginFragment, R.id.savedLoginsFragment),
findNavController().currentDestination?.id
findNavController().currentDestination?.id,
R.id.loginDetailFragment
)
super.onPause()
}

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

Loading…
Cancel
Save