For #3709: Add save to PDF UI.

This commit is contained in:
Arturo Mejia 2022-10-03 15:46:53 -04:00
parent 5391b4cbc3
commit 5cce4b5f15
15 changed files with 324 additions and 39 deletions

View File

@ -449,6 +449,22 @@ events:
notification_emails:
- android-probes@mozilla.com
expires: 113
save_to_pdf_tapped:
type: event
description: |
A user tapped the save to pdf option in the share sheet.
bugs:
- https://github.com/mozilla-mobile/fenix/issues/3709
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/27257
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: 122
metadata:
tags:
- Sharing
onboarding:
syn_cfr_shown:

View File

@ -118,4 +118,9 @@ object FeatureFlags {
* Enables the wallpaper v2 enhancements.
*/
const val wallpaperV2Enabled = true
/**
* Enables the save to PDF feature.
*/
val saveToPDF = Config.channel.isNightlyOrDebug
}

View File

@ -211,6 +211,7 @@ class DefaultBrowserToolbarMenuController(
}
is ToolbarMenu.Item.Share -> {
val directions = NavGraphDirections.actionGlobalShareFragment(
sessionId = currentSession?.id,
data = arrayOf(
ShareData(
url = getProperUrl(currentSession),

View File

@ -583,6 +583,7 @@ class DefaultSessionControlController(
private fun showShareFragment(shareSubject: String, data: List<ShareData>) {
val directions = HomeFragmentDirections.actionGlobalShareFragment(
sessionId = store.state.selectedTabId,
shareSubject = shareSubject,
data = data.toTypedArray(),
)

View File

@ -0,0 +1,16 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.share
/**
* Callbacks for possible user interactions on the [SaveToPDFItem]
*/
interface SaveToPDFInteractor {
/**
* Generates a PDF from the given [tabId].
* @param tabId The ID of the tab to save as PDF.
*/
fun onSaveToPDF(tabId: String?)
}

View File

@ -0,0 +1,70 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.share
import android.content.res.Configuration
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.theme.FirefoxTheme
/**
* A save to PDF item.
*
* @param onClick event handler when the save to PDF item is clicked.
*/
@Composable
fun SaveToPDFItem(
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_download),
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.share_save_to_pdf),
style = FirefoxTheme.typography.subtitle1,
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun SaveToPDFItemPreview() {
FirefoxTheme {
SaveToPDFItem {}
}
}

View File

@ -29,9 +29,11 @@ import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.sync.Device
import mozilla.components.concept.sync.TabData
import mozilla.components.feature.accounts.push.SendTabUseCases
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.feature.share.RecentAppsStorage
import mozilla.components.service.glean.private.NoExtras
import mozilla.components.support.ktx.kotlin.isExtensionUrl
import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.GleanMetrics.SyncAccount
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
@ -47,6 +49,11 @@ interface ShareController {
fun handleReauth()
fun handleShareClosed()
fun handleShareToApp(app: AppShareOption)
/**
* Handles when a save to PDF action was requested.
*/
fun handleSaveToPDF(tabId: String?)
fun handleAddNewDevice()
fun handleShareToDevice(device: Device)
fun handleShareToAllDevices(devices: List<Device>)
@ -68,12 +75,13 @@ interface ShareController {
* @param navController - [NavController] used for navigation.
* @param dismiss - callback signalling sharing can be closed.
*/
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LongParameterList")
class DefaultShareController(
private val context: Context,
private val shareSubject: String?,
private val shareData: List<ShareData>,
private val sendTabUseCases: SendTabUseCases,
private val saveToPdfUseCase: SessionUseCases.SaveToPdfUseCase,
private val snackbar: FenixSnackbar,
private val navController: NavController,
private val recentAppsStorage: RecentAppsStorage,
@ -130,6 +138,12 @@ class DefaultShareController(
dismiss(result)
}
override fun handleSaveToPDF(tabId: String?) {
Events.saveToPdfTapped.record(NoExtras())
handleShareClosed()
saveToPdfUseCase.invoke(tabId)
}
override fun handleAddNewDevice() {
val directions = ShareFragmentDirections.actionShareFragmentToAddNewDeviceFragment()
navController.navigate(directions)

View File

@ -10,6 +10,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.view.isVisible
import androidx.fragment.app.clearFragmentResult
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
@ -22,11 +24,13 @@ 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
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.theme.FirefoxTheme
class ShareFragment : AppCompatDialogFragment() {
@ -80,6 +84,7 @@ class ShareFragment : AppCompatDialogFragment() {
),
navController = findNavController(),
sendTabUseCases = SendTabUseCases(accountManager),
saveToPdfUseCase = requireComponents.useCases.sessionUseCases.saveToPdf,
recentAppsStorage = RecentAppsStorage(requireContext()),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
) { result ->
@ -111,6 +116,20 @@ class ShareFragment : AppCompatDialogFragment() {
}
shareToAppsView = ShareToAppsView(binding.appsShareLayout, shareInteractor)
if (FeatureFlags.saveToPDF) {
binding.dividerLineAppsShareAndPdfSection.isVisible = true
binding.savePdf.apply {
isVisible = true
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
FirefoxTheme {
SaveToPDFItem {
shareInteractor.onSaveToPDF(tabId = args.sessionId)
}
}
}
}
}
return binding.root
}

View File

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

View File

@ -34,18 +34,18 @@
android:background="@drawable/bottom_sheet_dialog_fragment_background"
app:layout_constraintBottom_toBottomOf="parent">
<FrameLayout
android:id="@+id/appsShareLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" />
<FrameLayout
android:id="@+id/devicesShareLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/divider_line" />
<FrameLayout
android:id="@+id/appsShareLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/divider_line" />
<View
android:id="@+id/divider_line"
android:layout_width="match_parent"
@ -54,6 +54,23 @@
android:background="?borderPrimary"
app:layout_constraintBottom_toTopOf="@id/appsShareLayout" />
<View
android:id="@+id/divider_line_apps_share_and_pdf_section"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?borderPrimary"
app:layout_constraintTop_toBottomOf="@id/appsShareLayout" />
<androidx.compose.ui.platform.ComposeView
android:visibility="gone"
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.constraintlayout.widget.Group
android:id="@+id/devicesShareGroup"
android:layout_width="wrap_content"

View File

@ -895,7 +895,7 @@
app:destination="@id/addNewDeviceFragment" />
<argument
android:name="sessionId"
android:defaultValue="null"
android:defaultValue="@null"
app:argType="string"
app:nullable="true" />
<argument

View File

@ -1030,6 +1030,8 @@
<!-- Content description (not visible, for screen readers etc.):
"Share" button. Opens the share menu when pressed. -->
<string name="share_button_content_description">Share</string>
<!-- Text for the Save to PDF feature in the share menu -->
<string name="share_save_to_pdf">Save as PDF</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 -->

View File

@ -623,6 +623,7 @@ class DefaultBrowserToolbarMenuControllerTest {
navController.navigate(
directionsEq(
NavGraphDirections.actionGlobalShareFragment(
sessionId = browserStore.state.selectedTabId,
data = arrayOf(ShareData(url = "https://mozilla.org", title = "Mozilla")),
showPage = true,
),
@ -656,6 +657,7 @@ class DefaultBrowserToolbarMenuControllerTest {
navController.navigate(
directionsEq(
NavGraphDirections.actionGlobalShareFragment(
sessionId = browserStore.state.selectedTabId,
data = arrayOf(ShareData(url = "https://mozilla.org", title = "Mozilla")),
showPage = true,
),

View File

@ -24,6 +24,7 @@ import mozilla.components.concept.sync.Device
import mozilla.components.concept.sync.DeviceType
import mozilla.components.concept.sync.TabData
import mozilla.components.feature.accounts.push.SendTabUseCases
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.feature.share.RecentAppsStorage
import mozilla.components.service.glean.testing.GleanTestRule
import mozilla.components.support.test.robolectric.testContext
@ -37,6 +38,7 @@ import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.GleanMetrics.SyncAccount
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
@ -61,6 +63,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 snackbar = mockk<FenixSnackbar>(relaxed = true)
private val navController = mockk<NavController>(relaxed = true)
private val dismiss = mockk<(ShareController.Result) -> Unit>(relaxed = true)
@ -74,7 +77,7 @@ class ShareControllerTest {
private val testDispatcher = coroutinesTestRule.testDispatcher
private val testCoroutineScope = coroutinesTestRule.scope
private val controller = DefaultShareController(
context, shareSubject, shareData, sendTabUseCases, snackbar, navController,
context, shareSubject, shareData, sendTabUseCases, saveToPdfUseCase, snackbar, navController,
recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
)
@ -96,7 +99,7 @@ class ShareControllerTest {
// need to use an Activity Context.
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(
activityContext, shareSubject, shareData, mockk(),
activityContext, shareSubject, shareData, mockk(), mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
)
every { activityContext.startActivity(capture(shareIntent)) } just Runs
@ -133,8 +136,17 @@ class ShareControllerTest {
// need to use an Activity Context.
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(
activityContext, shareSubject, shareData, mockk(),
snackbar, mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = activityContext,
shareSubject = shareSubject,
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
snackbar = snackbar,
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
every { activityContext.startActivity(capture(shareIntent)) } throws SecurityException()
@ -161,8 +173,17 @@ class ShareControllerTest {
// need to use an Activity Context.
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(
activityContext, shareSubject, shareData, mockk(),
snackbar, mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = activityContext,
shareSubject = shareSubject,
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
snackbar = snackbar,
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
every { activityContext.startActivity(capture(shareIntent)) } throws ActivityNotFoundException()
@ -178,12 +199,47 @@ class ShareControllerTest {
}
}
@Test
fun `WHEN handleSaveToPDF THEN send telemetry, close the dialog and save the page to pdf`() {
val testController = DefaultShareController(
context = mockk(),
shareSubject = shareSubject,
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = saveToPdfUseCase,
snackbar = snackbar,
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
testController.handleSaveToPDF("tabID")
verify {
saveToPdfUseCase.invoke("tabID")
dismiss(ShareController.Result.DISMISSED)
}
assertNotNull(Events.saveToPdfTapped.testGetValue())
}
@Test
fun `getShareSubject should return the shareSubject when shareSubject is not null`() {
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(
activityContext, shareSubject, shareData, mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = activityContext,
shareSubject = shareSubject,
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
assertEquals(shareSubject, testController.getShareSubject())
@ -193,8 +249,17 @@ class ShareControllerTest {
fun `getShareSubject should return a combination of non-null titles when shareSubject is null`() {
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(
activityContext, null, shareData, mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = activityContext,
shareSubject = null,
shareData = shareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
assertEquals("title0, title1", testController.getShareSubject())
@ -208,8 +273,17 @@ class ShareControllerTest {
ShareData(url = "url1", title = "title1"),
)
val testController = DefaultShareController(
activityContext, null, partialTitlesShareData, mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = activityContext,
shareSubject = null,
shareData = partialTitlesShareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
assertEquals("title1", testController.getShareSubject())
@ -223,8 +297,17 @@ class ShareControllerTest {
ShareData(url = "url1", title = null),
)
val testController = DefaultShareController(
activityContext, null, noTitleShareData, mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = activityContext,
shareSubject = null,
shareData = noTitleShareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
assertEquals("", testController.getShareSubject())
@ -238,8 +321,17 @@ class ShareControllerTest {
ShareData(url = "url1", title = ""),
)
val testController = DefaultShareController(
activityContext, null, noTitleShareData, mockk(),
mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = activityContext,
shareSubject = null,
shareData = noTitleShareData,
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
assertEquals("", testController.getShareSubject())
@ -384,16 +476,17 @@ class ShareControllerTest {
@Test
fun `getSuccessMessage should return different strings depending on the number of shared tabs`() {
val controllerWithOneSharedTab = DefaultShareController(
context,
shareSubject,
listOf(ShareData(url = "url0", title = "title0")),
mockk(),
mockk(),
mockk(),
mockk(),
mockk(),
mockk(),
mockk(),
context = context,
shareSubject = shareSubject,
shareData = listOf(ShareData(url = "url0", title = "title0")),
sendTabUseCases = mockk(),
saveToPdfUseCase = mockk(),
snackbar = mockk(),
navController = mockk(),
recentAppsStorage = mockk(),
viewLifecycleScope = mockk(),
dispatcher = mockk(),
dismiss = mockk(),
)
val controllerWithMoreSharedTabs = controller
val expectedTabSharedMessage = context.getString(R.string.sync_sent_tab_snackbar)
@ -420,8 +513,17 @@ class ShareControllerTest {
ShareData(url = "url1"),
)
val controller = DefaultShareController(
context, shareSubject, shareData, sendTabUseCases, snackbar, navController,
recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = context,
shareSubject = shareSubject,
shareData = shareData,
sendTabUseCases = sendTabUseCases,
saveToPdfUseCase = mockk(),
snackbar = snackbar,
navController = navController,
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
val expectedShareText = "${shareData[0].url}\n\nurl0\n\n${shareData[2].url}"
@ -436,8 +538,17 @@ class ShareControllerTest {
@Test
fun `getShareSubject will return a concatenation of tab titles if 'shareSubject' is null`() {
val controller = DefaultShareController(
context, null, shareData, sendTabUseCases, snackbar, navController,
recentAppStorage, testCoroutineScope, testDispatcher, dismiss,
context = context,
shareSubject = null,
shareData = shareData,
sendTabUseCases = sendTabUseCases,
saveToPdfUseCase = mockk(),
snackbar = snackbar,
navController = navController,
recentAppsStorage = recentAppStorage,
viewLifecycleScope = testCoroutineScope,
dispatcher = testDispatcher,
dismiss = dismiss,
)
assertEquals("title0, title1", controller.getShareSubject())

View File

@ -68,4 +68,11 @@ class ShareInteractorTest {
verify { controller.handleShareToApp(app) }
}
@Test
fun `WHEN onSaveToPDF is call THEN call handleSaveToPDF`() {
interactor.onSaveToPDF("tabID")
verify { controller.handleSaveToPDF("tabID") }
}
}