Skip to content
This repository was archived by the owner on Nov 1, 2022. It is now read-only.

Commit

Permalink
Close #12267: Add support for Save to PDF in GeckoEngineSession
Browse files Browse the repository at this point in the history
Adds support for Save to PDF from the GeckoSession by plugging the
API into `onExternalResponse` to provide the same flow as a typical
file download experience would be.

Co-authored-by: Olivia Hall <ohall@mozilla.com>
  • Loading branch information
jonalmeida and ohall-m committed Aug 10, 2022
1 parent 164fb0a commit 15343a7
Show file tree
Hide file tree
Showing 11 changed files with 184 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import mozilla.components.concept.engine.window.WindowRequest
import mozilla.components.concept.fetch.Headers.Names.CONTENT_DISPOSITION
import mozilla.components.concept.fetch.Headers.Names.CONTENT_LENGTH
import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Response
import mozilla.components.concept.storage.PageVisit
import mozilla.components.concept.storage.RedirectSource
import mozilla.components.concept.storage.VisitType
Expand All @@ -52,6 +54,8 @@ import mozilla.components.support.ktx.kotlin.isPhone
import mozilla.components.support.ktx.kotlin.sanitizeFileName
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
import mozilla.components.support.utils.DownloadUtils
import mozilla.components.support.utils.DownloadUtils.RESPONSE_CODE_SUCCESS
import mozilla.components.support.utils.DownloadUtils.makePdfContentDisposition
import org.json.JSONObject
import org.mozilla.geckoview.AllowOrDeny
import org.mozilla.geckoview.ContentBlocking
Expand Down Expand Up @@ -95,6 +99,7 @@ class GeckoEngineSession(

internal lateinit var geckoSession: GeckoSession
internal var currentUrl: String? = null
internal var currentTitle: String? = null
internal var lastLoadRequestUri: String? = null
internal var pageLoadingUrl: String? = null
internal var appRedirectUrl: String? = null
Expand Down Expand Up @@ -197,6 +202,61 @@ class GeckoEngineSession(
}
}

/**
* See [EngineSession.requestPdfToDownload]
*/
override fun requestPdfToDownload() {
geckoSession.saveAsPdf().then(
{ inputStream ->
if (inputStream == null) {
logger.error("No input stream available for Save to PDF.")
return@then GeckoResult<Void>()
}

val url = this.currentUrl ?: ""
val contentType = "application/pdf"
val disposition = currentTitle?.let { makePdfContentDisposition(it) }
// A successful status code suffices because the PDF is generated on device.
val responseStatus = RESPONSE_CODE_SUCCESS
// We do not know the size at this point; send 0 so consumers do not display it.
val contentLength = 0L
// NB: If the title is an empty string, there is a chance the PDF will not have a name.
// See https://github.com/mozilla-mobile/android-components/issues/12276
val fileName = DownloadUtils.guessFileName(
disposition,
destinationDirectory = null,
url = url,
mimeType = contentType
)

val response = Response(
url = url,
status = responseStatus,
headers = MutableHeaders(),
body = Response.Body(inputStream)
)

notifyObservers {
onExternalResource(
url = url,
contentLength = contentLength,
contentType = contentType,
fileName = fileName,
response = response,
isPrivate = privateMode
)
}

GeckoResult()
},
{ throwable ->
// Log the error. There is nothing we can do otherwise.
logger.error("Save to PDF failed.", throwable)
GeckoResult()
}
)
}

/**
* See [EngineSession.stopLoading]
*/
Expand Down Expand Up @@ -883,6 +943,7 @@ class GeckoEngineSession(
}
}
}
this@GeckoEngineSession.currentTitle = title
notifyObservers { onTitleChange(title ?: "") }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import mozilla.components.support.test.mock
import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.rule.runTestOnMain
import mozilla.components.support.test.whenever
import mozilla.components.support.utils.DownloadUtils.RESPONSE_CODE_SUCCESS
import mozilla.components.support.utils.ThreadUtils
import mozilla.components.test.ReflectionUtils
import org.json.JSONObject
Expand All @@ -56,6 +57,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand Down Expand Up @@ -3359,6 +3361,38 @@ class GeckoEngineSessionTest {
assertSame(GeckoEngineSession.BLOCKED_SCHEMES, engineSession.getBlockedSchemes())
}

@Ignore("WIP test")
@Test
fun `WHEN requestPdfToDownload THEN notify observers`() {
var observedResponse: Response? = null
val engineSession = GeckoEngineSession(
mock(),
geckoSessionProvider = geckoSessionProvider
).apply {
currentUrl = "https://mozilla.org"
currentTitle = "Mozilla"
}
whenever(geckoSession.saveAsPdf()).thenReturn(GeckoResult.fromValue(mock()))

engineSession.register(object : EngineSession.Observer {
override fun onExternalResource(
url: String,
fileName: String?,
contentLength: Long?,
contentType: String?,
cookie: String?,
userAgent: String?,
isPrivate: Boolean,
response: Response?
) {
observedResponse = response
}
})
engineSession.requestPdfToDownload()

assertEquals(RESPONSE_CODE_SUCCESS, observedResponse!!.status)
}

private fun mockGeckoSession(): GeckoSession {
val session = mock<GeckoSession>()
whenever(session.settings).thenReturn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ class SystemEngineSession(
webView.loadData(data, mimeType, encoding)
}

override fun requestPdfToDownload() {
throw UnsupportedOperationException("PDF support is not available in this engine")
}

/**
* See [EngineSession.stopLoading]
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,13 @@ sealed class EngineAction : BrowserAction() {
override val tabId: String
) : EngineAction(), ActionWithTab

/**
* Navigates back in the tab with the given [tabId].
*/
data class SaveToPdfAction(
override val tabId: String,
) : EngineAction(), ActionWithTab

/**
* Clears browsing data for the tab with the given [tabId].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ internal class EngineDelegateMiddleware(
is EngineAction.GoToHistoryIndexAction -> goToHistoryIndex(context.store, action)
is EngineAction.ToggleDesktopModeAction -> toggleDesktopMode(context.store, action)
is EngineAction.ExitFullScreenModeAction -> exitFullScreen(context.store, action)
is EngineAction.SaveToPdfAction -> saveToPdf(context.store, action)
is EngineAction.ClearDataAction -> clearData(context.store, action)
is EngineAction.PurgeHistoryAction -> purgeHistory(context.state)
else -> next(action)
Expand Down Expand Up @@ -133,6 +134,14 @@ internal class EngineDelegateMiddleware(
?.exitFullScreenMode()
}

private fun saveToPdf(
store: Store<BrowserState, BrowserAction>,
action: EngineAction.SaveToPdfAction
) = scope.launch {
getEngineSessionOrDispatch(store, action)
?.requestPdfToDownload()
}

private fun clearData(
store: Store<BrowserState, BrowserAction>,
action: EngineAction.ClearDataAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ internal object EngineStateReducer {
is EngineAction.GoToHistoryIndexAction,
is EngineAction.ToggleDesktopModeAction,
is EngineAction.ExitFullScreenModeAction,
is EngineAction.SaveToPdfAction,
is EngineAction.KillEngineSessionAction,
is EngineAction.ClearDataAction -> {
throw IllegalStateException("You need to add EngineMiddleware to your BrowserStore. ($action)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,13 @@ abstract class EngineSession(
*/
abstract fun loadData(data: String, mimeType: String = "text/html", encoding: String = "UTF-8")

/**
* Requests the [EngineSession] to download the current session's contents as a PDF.
*
* A typical implementation would have the same flow that feeds into [EngineSession.Observer.onExternalResource].
*/
abstract fun requestPdfToDownload()

/**
* Stops loading the current session.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,8 @@ open class DummyEngineSession : EngineSession() {

override fun loadData(data: String, mimeType: String, encoding: String) {}

override fun requestPdfToDownload() {}

override fun stopLoading() {}

override fun reload(flags: LoadUrlFlags) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,31 @@ class SessionUseCases(
}
}

/**
* A use case for requesting a given tab to generate a PDF from it's content.
*/
class SaveToPdfUseCase internal constructor(
private val store: BrowserStore
) {
/**
* Request a PDF to be generated from the given [tabId].
*
* If the tab is not loaded, [BrowserStore] will ensure the session has been created and
* loaded, however, this does not guarantee the page contents will be correctly painted
* into the PDF. Typically, a session is required to have been painted on the screen (by
* being the selected tab) for a PDF to be generated successfully.
*/
operator fun invoke(
tabId: String? = store.state.selectedTabId
) {
if (tabId == null) {
return
}

store.dispatch(EngineAction.SaveToPdfAction(tabId))
}
}

val loadUrl: DefaultLoadUrlUseCase by lazy { DefaultLoadUrlUseCase(store, onNoTab) }
val loadData: LoadDataUseCase by lazy { LoadDataUseCase(store, onNoTab) }
val reload: ReloadUrlUseCase by lazy { ReloadUrlUseCase(store) }
Expand All @@ -413,6 +438,7 @@ class SessionUseCases(
val goToHistoryIndex: GoToHistoryIndexUseCase by lazy { GoToHistoryIndexUseCase(store) }
val requestDesktopSite: RequestDesktopSiteUseCase by lazy { RequestDesktopSiteUseCase(store) }
val exitFullscreen: ExitFullScreenUseCase by lazy { ExitFullScreenUseCase(store) }
val saveToPdf: SaveToPdfUseCase by lazy { SaveToPdfUseCase(store) }
val crashRecovery: CrashRecoveryUseCase by lazy { CrashRecoveryUseCase(store) }
val purgeHistory: PurgeHistoryUseCase by lazy { PurgeHistoryUseCase(store) }
val updateLastAccess: UpdateLastAccessUseCase by lazy { UpdateLastAccessUseCase(store) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,22 @@ object DownloadUtils {
"application/unknown"
)

/**
* Maximum number of characters for the title length.
*
* Android OS is Linux-based and therefore would have the limitations of the linux filesystem
* it uses under the hood. To the best of our knowledge, Android only supports EXT3, EXT4,
* exFAT, and EROFS filesystems. From these three, the maximum file name length is 255.
*
* @see <a href="https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits"/>
*/
private const val MAX_FILE_NAME_LENGTH = 255

/**
* The HTTP response code for a successful request.
*/
const val RESPONSE_CODE_SUCCESS = 200

/**
* Guess the name of the file that should be downloaded.
*
Expand Down Expand Up @@ -212,6 +228,20 @@ object DownloadUtils {
return potentialFileName.name
}

/**
* Create a Content Disposition formatted string with the receiver used as the filename and
* file extension set as PDF.
*
* This is primarily useful for connecting the "Save to PDF" feature response to downloads.
*/
fun makePdfContentDisposition(filename: String): String {
return filename
.take(MAX_FILE_NAME_LENGTH)
.run {
"attachment; filename=$this.pdf;"
}
}

private fun extractFileNameFromUrl(contentDisposition: String?, url: String?): String {
var filename: String? = null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ open class DefaultComponents(private val applicationContext: Context) {
SimpleBrowserMenuItem("Find In Page") {
FindInPageIntegration.launch?.invoke()
},
SimpleBrowserMenuItem("Save to PDF") {
sessionUseCases.saveToPdf.invoke()
},
SimpleBrowserMenuItem("Restore after crash") {
sessionUseCases.crashRecovery.invoke()
},
Expand Down

1 comment on commit 15343a7

@firefoxci-taskcluster
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh oh! Looks like an error! Details

Failed to fetch task artifact public/github/customCheckRunText.md for GitHub integration.
Make sure the artifact exists on the worker or other location.

Please sign in to comment.