Bug 1836780 - Add UI For Printing Page Content

This bug adds a print button on the main Fenix toolbar menu and a print
button on the share menu.

This bug adds a main toolbar extra telemetry of
print_content. Additional telemetry and Nimbus instrumentation will be
added in bug 1837517.
fenix/116.0
Olivia Hall 11 months ago committed by mergify[bot]
parent 40d5446c4b
commit b8ada367ae

@ -143,13 +143,14 @@ events:
bookmarks, desktop_view_off, desktop_view_on, downloads,
find_in_page, forward, history, new_tab, open_in_app, open_in_fenix,
quit, reader_mode_appearance, reload, remove_from_top_sites,
save_to_collection, set_default_browser, settings, share, stop, and
sync_account.
save_to_collection, set_default_browser, settings, share, stop,
sync_account, and print_content.
type: string
bugs:
- https://github.com/mozilla-mobile/fenix/issues/1024
- https://github.com/mozilla-mobile/fenix/issues/19923
- https://bugzilla.mozilla.org/show_bug.cgi?id=1808689
- https://bugzilla.mozilla.org/show_bug.cgi?id=1836780
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/1214#issue-264756708
- https://github.com/mozilla-mobile/fenix/pull/5098#issuecomment-529658996
@ -159,6 +160,7 @@ events:
- https://github.com/mozilla-mobile/fenix/pull/19924#issuecomment-861423789
- https://github.com/mozilla-mobile/fenix/pull/21316#issuecomment-944615938
- https://github.com/mozilla-mobile/fenix/pull/27295
- https://bugzilla.mozilla.org/show_bug.cgi?id=1837517#c3
data_sensitivity:
- interaction
notification_emails:

@ -27,6 +27,7 @@ import androidx.test.uiautomator.Until
import org.hamcrest.Matchers.allOf
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.Constants.LONG_CLICK_DURATION
import org.mozilla.fenix.helpers.Constants.RETRY_COUNT
@ -101,10 +102,17 @@ class ThreeDotMenuMainRobot {
addToHomeScreenButton,
addToShortcutsButton,
saveToCollectionButton,
settingsButton(),
)
assertCheckedItemWithResIdAndTextExists(addBookmarkButton)
assertCheckedItemWithResIdAndTextExists(desktopSiteToggle(isRequestDesktopSiteEnabled))
// Swipe to second part of menu
expandMenu()
assertItemContainingTextExists(
settingsButton(),
)
if (FeatureFlags.print) {
assertItemContainingTextExists(printContentButton)
}
assertItemWithDescriptionExists(
backButton,
forwardButton,
@ -582,6 +590,7 @@ private val reportSiteIssueButton = itemContainingText("Report Site Issue")
private val addToHomeScreenButton = itemContainingText(getStringResource(R.string.browser_menu_add_to_homescreen))
private val addToShortcutsButton = itemContainingText(getStringResource(R.string.browser_menu_add_to_shortcuts))
private val saveToCollectionButton = itemContainingText(getStringResource(R.string.browser_menu_save_to_collection_2))
private val printContentButton = itemContainingText(getStringResource(R.string.menu_print))
private val backButton = itemWithDescription(getStringResource(R.string.browser_menu_back))
private val forwardButton = itemWithDescription(getStringResource(R.string.browser_menu_forward))
private val shareButton = itemWithDescription(getStringResource(R.string.share_button_content_description))

@ -62,4 +62,9 @@ object FeatureFlags {
* and managing search shortcuts in the quick search menu.
*/
val unifiedSearchSettings = Config.channel.isNightlyOrDebug
/**
* Enables printing from the share and primary menu.
*/
val print = Config.channel.isNightlyOrDebug
}

@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.selectedTab
@ -333,6 +334,11 @@ class DefaultBrowserToolbarMenuController(
navController.nav(R.id.browserFragment, directions)
}
}
is ToolbarMenu.Item.PrintContent -> {
store.state.selectedTab?.let {
store.dispatch(EngineAction.PrintContentAction(it.id))
}
}
is ToolbarMenu.Item.Bookmark -> {
store.state.selectedTab?.let {
getProperUrl(it)?.let { url -> bookmarkTapped(url, it.content.title) }
@ -443,6 +449,8 @@ class DefaultBrowserToolbarMenuController(
Events.browserMenuAction.record(Events.BrowserMenuActionExtra("save_to_collection"))
is ToolbarMenu.Item.AddToTopSites ->
Events.browserMenuAction.record(Events.BrowserMenuActionExtra("add_to_top_sites"))
is ToolbarMenu.Item.PrintContent ->
Events.browserMenuAction.record(Events.BrowserMenuActionExtra("print_content"))
is ToolbarMenu.Item.AddToHomeScreen ->
Events.browserMenuAction.record(Events.BrowserMenuActionExtra("add_to_homescreen"))
is ToolbarMenu.Item.SyncAccount -> {

@ -35,6 +35,7 @@ import mozilla.components.feature.webcompat.reporter.WebCompatReporterFeature
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.components.accounts.FenixAccountManager
import org.mozilla.fenix.ext.components
@ -301,6 +302,14 @@ open class DefaultToolbarMenu(
onItemTapped.invoke(ToolbarMenu.Item.SaveToCollection)
}
private val printPageItem = BrowserMenuImageText(
label = context.getString(R.string.menu_print),
imageResource = R.drawable.ic_print,
iconTintColorResource = primaryTextColor(),
) {
onItemTapped.invoke(ToolbarMenu.Item.PrintContent)
}
@VisibleForTesting
internal val settingsItem = BrowserMenuHighlightableItem(
label = context.getString(R.string.browser_menu_settings),
@ -381,6 +390,7 @@ open class DefaultToolbarMenu(
installToHomescreen.apply { visible = ::canInstall },
addRemoveTopSitesItem,
saveToCollectionItem,
if (FeatureFlags.print) printPageItem else null,
BrowserMenuDivider(),
settingsItem,
if (shouldDeleteDataOnQuit) deleteDataOnQuit else null,

@ -20,6 +20,11 @@ interface ToolbarMenu {
object Stop : Item()
object OpenInFenix : Item()
object SaveToCollection : Item()
/**
* Prints the currently displayed page content.
*/
object PrintContent : Item()
object AddToTopSites : Item()
object RemoveFromTopSites : Item()
object InstallPwaToHomeScreen : Item()

@ -0,0 +1,69 @@
/* 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.share
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.annotation.LightDarkPreview
import org.mozilla.fenix.theme.FirefoxTheme
/**
* A Print item.
*
* @param onClick event handler when the print item is clicked.
*/
@Composable
fun PrintItem(
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.height(56.dp)
.fillMaxWidth()
.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.width(16.dp))
Icon(
painter = painterResource(R.drawable.ic_print),
contentDescription = stringResource(
R.string.content_description_close_button,
),
tint = FirefoxTheme.colors.iconPrimary,
)
Spacer(Modifier.width(32.dp))
Text(
color = FirefoxTheme.colors.textPrimary,
text = stringResource(R.string.menu_print),
style = FirefoxTheme.typography.subtitle1,
)
}
}
@Composable
@Preview
@LightDarkPreview
private fun PrintItemPreview() {
FirefoxTheme {
PrintItem {}
}
}

@ -13,4 +13,10 @@ interface SaveToPDFInteractor {
* @param tabId The ID of the tab to save as PDF.
*/
fun onSaveToPDF(tabId: String?)
/**
* Prints from the given [tabId].
* @param tabId The ID of the tab to print.
*/
fun onPrint(tabId: String?)
}

@ -66,6 +66,20 @@ class SaveToPDFMiddleware(
postTelemetryFailed(ctx.state.findTab(action.tabId), action.throwable)
}
is EngineAction.PrintContentAction -> {
next(action)
// Reserved for telemetry in bug 1837517
}
is EngineAction.PrintContentCompletedAction -> {
// No-op, reserved for telemetry in bug 1837517
}
is EngineAction.PrintContentExceptionAction -> {
// Bug 1840894 - will update this toast to a snackbar with new snackbar error component
ThreadUtils.runOnUiThread {
Toast.makeText(context, R.string.unable_to_print_error, LENGTH_LONG).show()
}
}
else -> {
next(action)
}

@ -61,6 +61,11 @@ interface ShareController {
fun handleShareToAllDevices(devices: List<Device>)
fun handleSignIn()
/**
* Handles when a print action was requested.
*/
fun handlePrint(tabId: String?)
enum class Result {
DISMISSED, SHARE_ERROR, SUCCESS
}
@ -85,6 +90,7 @@ class DefaultShareController(
private val shareData: List<ShareData>,
private val sendTabUseCases: SendTabUseCases,
private val saveToPdfUseCase: SessionUseCases.SaveToPdfUseCase,
private val printUseCase: SessionUseCases.PrintContentUseCase,
private val snackbar: FenixSnackbar,
private val navController: NavController,
private val recentAppsStorage: RecentAppsStorage,
@ -150,6 +156,11 @@ class DefaultShareController(
saveToPdfUseCase.invoke(tabId)
}
override fun handlePrint(tabId: String?) {
handleShareClosed()
printUseCase.invoke(tabId)
}
override fun handleAddNewDevice() {
val directions = ShareFragmentDirections.actionShareFragmentToAddNewDeviceFragment()
navController.navigate(directions)

@ -22,6 +22,7 @@ import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.feature.accounts.push.SendTabUseCases
import mozilla.components.feature.share.RecentAppsStorage
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.databinding.FragmentShareBinding
@ -83,6 +84,7 @@ class ShareFragment : AppCompatDialogFragment() {
navController = findNavController(),
sendTabUseCases = SendTabUseCases(accountManager),
saveToPdfUseCase = requireComponents.useCases.sessionUseCases.saveToPdf,
printUseCase = requireComponents.useCases.sessionUseCases.printContent,
recentAppsStorage = RecentAppsStorage(requireContext()),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
) { result ->
@ -122,6 +124,15 @@ class ShareFragment : AppCompatDialogFragment() {
}
}
if (FeatureFlags.print) {
binding.print.setContent {
FirefoxTheme(theme = Theme.getTheme(allowPrivateTheme = false)) {
PrintItem {
shareInteractor.onPrint(tabId = args.sessionId)
}
}
}
}
return binding.root
}

@ -12,7 +12,10 @@ import org.mozilla.fenix.share.listadapters.AppShareOption
*/
class ShareInteractor(
private val controller: ShareController,
) : ShareCloseInteractor, ShareToAccountDevicesInteractor, ShareToAppsInteractor, SaveToPDFInteractor {
) : ShareCloseInteractor,
ShareToAccountDevicesInteractor,
ShareToAppsInteractor,
SaveToPDFInteractor {
override fun onReauth() {
controller.handleReauth()
}
@ -44,4 +47,8 @@ class ShareInteractor(
override fun onSaveToPDF(tabId: String?) {
controller.handleSaveToPDF(tabId)
}
override fun onPrint(tabId: String?) {
controller.handlePrint(tabId)
}
}

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/textPrimary"
android:pathData="M7 3.5C7 2.11975 8.11975 1 9.5 1H14.5C15.8802 1 17 2.11975 17 3.5V7H19.5C20.881 7 22 8.119 22 9.5V15.5C22 16.881 20.881 18 19.5 18H17V19.5C17 20.8802 15.8802 22 14.5 22H9.5C8.11975 22 7 20.8802 7 19.5V18H4.5C3.119 18 2 16.881 2 15.5V9.5C2 8.119 3.119 7 4.5 7H7V3.5ZM8.75 7H15.25V3.5C15.25 3.08625 14.9138 2.75 14.5 2.75H9.5C9.08625 2.75 8.75 3.08625 8.75 3.5V7ZM8.75 14V19.5C8.75 19.9137 9.08625 20.25 9.5 20.25H14.5C14.9138 20.25 15.25 19.9137 15.25 19.5V14H8.75ZM19 10H17V12H19V10Z" />
</vector>

@ -66,9 +66,15 @@
android:id="@+id/save_pdf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/divider_line_apps_share_and_pdf_section" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/print"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/save_pdf" />
<androidx.constraintlayout.widget.Group
android:id="@+id/devicesShareGroup"
android:layout_width="wrap_content"

@ -1163,6 +1163,10 @@
<string name="share_save_to_pdf">Save as PDF</string>
<!-- Text for error message when generating a PDF file Text for error message when generating a PDF file. -->
<string name="unable_to_save_to_pdf_error">Unable to generate PDF</string>
<!-- Text for error message when printing a page and it fails. -->
<string name="unable_to_print_error">Unable to print</string>
<!-- Text for the print feature in the share and browser menu -->
<string name="menu_print">Print</string>
<!-- Sub-header in the dialog to share a link to another sync device -->
<string name="share_device_subheader">Send to device</string>
<!-- Sub-header in the dialog to share a link to an app from the full list -->

@ -752,6 +752,21 @@ class DefaultBrowserToolbarMenuControllerTest {
verify { navController.navigate(directionsEq(directions), null) }
}
@Test
fun `WHEN print menu item is pressed THEN request print`() = runTest {
val item = ToolbarMenu.Item.PrintContent
val controller = createController(scope = this, store = browserStore)
assertNull(Events.browserMenuAction.testGetValue())
controller.handleToolbarItemInteraction(item)
assertNotNull(Events.browserMenuAction.testGetValue())
val snapshot = Events.browserMenuAction.testGetValue()!!
assertEquals(1, snapshot.size)
assertEquals("print_content", snapshot.single().extra?.getValue("item"))
}
@Test
fun `WHEN New Tab menu item is pressed THEN navigate to a new tab home`() = runTest {
val item = ToolbarMenu.Item.NewTab

@ -66,6 +66,7 @@ class ShareControllerTest {
private val textToShare = "${shareData[0].url}\n\n${shareData[1].url}"
private val sendTabUseCases = mockk<SendTabUseCases>(relaxed = true)
private val saveToPdfUseCase = mockk<SessionUseCases.SaveToPdfUseCase>(relaxed = true)
private val printUseCase = mockk<SessionUseCases.PrintContentUseCase>(relaxed = true)
private val snackbar = mockk<FenixSnackbar>(relaxed = true)
private val navController = mockk<NavController>(relaxed = true)
private val dismiss = mockk<(ShareController.Result) -> Unit>(relaxed = true)
@ -79,7 +80,7 @@ class ShareControllerTest {
private val testDispatcher = coroutinesTestRule.testDispatcher
private val testCoroutineScope = coroutinesTestRule.scope
private val controller = DefaultShareController(
context, shareSubject, shareData, sendTabUseCases, saveToPdfUseCase, snackbar, navController,
context, shareSubject, shareData, sendTabUseCases, saveToPdfUseCase, printUseCase, snackbar, navController,
recentAppStorage, testCoroutineScope, testDispatcher, FenixFxAEntryPoint.ShareMenu, dismiss,
)
@ -104,7 +105,7 @@ class ShareControllerTest {
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(
activityContext, shareSubject, shareData, mockk(), mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
mockk(), mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
FenixFxAEntryPoint.ShareMenu, dismiss,
)
every { activityContext.startActivity(capture(shareIntent)) } just Runs
@ -149,7 +150,7 @@ class ShareControllerTest {
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(
activityContext, shareSubject, shareData, mockk(), mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
mockk(), mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
FenixFxAEntryPoint.ShareMenu, dismiss,
)
@ -176,7 +177,7 @@ class ShareControllerTest {
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(
activityContext, shareSubject, shareData, mockk(), mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
mockk(), mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
FenixFxAEntryPoint.ShareMenu, dismiss,
)
@ -206,6 +207,7 @@ class ShareControllerTest {
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = snackbar,
navController = mockk(),
recentAppsStorage = recentAppStorage,
@ -243,6 +245,7 @@ class ShareControllerTest {
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = snackbar,
navController = mockk(),
recentAppsStorage = recentAppStorage,
@ -272,6 +275,7 @@ class ShareControllerTest {
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = saveToPdfUseCase,
printUseCase = mockk(),
snackbar = snackbar,
navController = mockk(),
recentAppsStorage = recentAppStorage,
@ -288,6 +292,31 @@ class ShareControllerTest {
}
}
@Test
fun `WHEN handlePrint close the dialog and print the page`() {
val testController = DefaultShareController(
context = mockk(),
shareSubject = shareSubject,
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = printUseCase,
snackbar = snackbar,
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
testController.handlePrint("tabID")
verify {
printUseCase.invoke("tabID")
dismiss(ShareController.Result.DISMISSED)
}
}
@Test
fun `getShareSubject should return the shareSubject when shareSubject is not null`() {
val activityContext: Context = mockk<Activity>()
@ -297,6 +326,7 @@ class ShareControllerTest {
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
@ -317,6 +347,7 @@ class ShareControllerTest {
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
@ -341,6 +372,7 @@ class ShareControllerTest {
shareData = partialTitlesShareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
@ -365,6 +397,7 @@ class ShareControllerTest {
shareData = noTitleShareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
@ -389,6 +422,7 @@ class ShareControllerTest {
shareData = noTitleShareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
@ -553,6 +587,7 @@ class ShareControllerTest {
shareData = listOf(ShareData(url = "url0", title = "title0")),
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = mockk(),
@ -590,6 +625,7 @@ class ShareControllerTest {
shareData = shareData,
sendTabUseCases = sendTabUseCases,
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = snackbar,
navController = navController,
recentAppsStorage = recentAppStorage,
@ -615,6 +651,7 @@ class ShareControllerTest {
shareData = shareData,
sendTabUseCases = sendTabUseCases,
saveToPdfUseCase = mockk(),
printUseCase = mockk(),
snackbar = snackbar,
navController = navController,
recentAppsStorage = recentAppStorage,

@ -75,4 +75,10 @@ class ShareInteractorTest {
verify { controller.handleSaveToPDF("tabID") }
}
@Test
fun `WHEN onPrint is call THEN call handlePrint`() {
interactor.onPrint("tabID")
verify { controller.handlePrint("tabID") }
}
}

Loading…
Cancel
Save