Revision control

Copy as Markdown

/* 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.tv.firefox.ext
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.webkit.ValueCallback
import android.webkit.WebBackForwardList
import android.webkit.WebView
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import mozilla.components.browser.engine.system.SystemEngineSession
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.EngineView
import org.mozilla.tv.firefox.ext.Js.BODY_ELEMENT_FOCUSED
import org.mozilla.tv.firefox.ext.Js.CACHE_JS
import org.mozilla.tv.firefox.ext.Js.JS_OBSERVE_PLAYBACK_STATE
import org.mozilla.tv.firefox.ext.Js.NO_ELEMENT_FOCUSED
import org.mozilla.tv.firefox.ext.Js.PAUSE_VIDEO
import org.mozilla.tv.firefox.ext.Js.RESTORE_JS
import org.mozilla.tv.firefox.ext.Js.SIDEBAR_FOCUSED
import org.mozilla.tv.firefox.utils.Direction
import org.mozilla.tv.firefox.utils.URLs
import org.mozilla.tv.firefox.webrender.FocusedDOMElementCache
import java.util.WeakHashMap
// Extension methods on the EngineView class. This is used for additional features that are not part
// of the upstream browser-engine(-system) component yet.
// lazy init lets us test this file without adding Robolectric.
private val uiHandler by lazy { Handler(Looper.getMainLooper()) }
/**
* Firefox for Fire TV needs to configure every WebView appropriately.
*/
fun EngineView.setupForApp() {
// Also increase text size to fill the viewport (this mirrors the behaviour of Firefox,
// Chrome does this in the current Chrome Dev, but not Chrome release).
// TODO #33: TEXT_AUTOSIZING does not exist in AmazonWebSettings
// webView.settings.setLayoutAlgorithm(AmazonWebSettings.LayoutAlgorithm.TEXT_AUTOSIZING);
// WebView can be null temporarily after clearData(); however, activity.recreate() would
// instantiate a new WebView instance
webView?.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
// For why we're modifying the focusedDOMElement, see FocusedDOMElementCacheInterface.
//
// This will cache focus whenever the app is backgrounded or the device goes to sleep,
// as well as whenever another view takes focus.
focusedDOMElement.cache()
} else {
// Trying to restore immediately doesn't work - perhaps the WebView hasn't actually
// received focus yet? Posting to the end of the UI queue seems to solve the problem.
uiHandler.post { focusedDOMElement.restore() }
}
}
}
/**
* For certain functionality Firefox for Fire TV needs to inject JavaScript into the web content. The engine component
* does not have such an API yet. It's questionable whether the component will get this raw API as WebView doesn't
* offer a matching API (WebExtensions are likely going to be the preferred way). We may move the functionality that
* requires JS injection to browser-engine-system.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun EngineView.evalJS(javascript: String, callback: ValueCallback<String>? = null) {
webView?.evaluateJavascript(javascript, callback)
}
fun EngineView.pauseAllVideoPlaybacks() {
evalJS(PAUSE_VIDEO)
}
fun EngineView.cacheDomElement() {
evalJS(CACHE_JS)
}
fun EngineView.restoreDomElement() {
evalJS(RESTORE_JS)
}
fun EngineView.observePlaybackState() {
evalJS(JS_OBSERVE_PLAYBACK_STATE)
}
fun EngineView.observeScrollPosition() {
evalJS(Js.MP4TranslationWorkaround.OBSERVE_SCROLL_POSITION)
}
fun EngineView.updateFullscreenScrollPosition() {
evalJS(Js.MP4TranslationWorkaround.UPDATE_FULLSCREEN_SCROLL_POSITION)
}
fun EngineView.addSubmitListenerToInputElements() {
evalJS(Js.ADD_SUBMIT_LISTENER_TO_ALL_INPUTS)
}
private fun EngineView.evalJSWithTargetVideo(getExpressionToEval: (videoId: String) -> String) {
val ID_TARGET_VIDEO = "targetVideo"
val GET_TARGET_VIDEO_OR_RETURN = """
|var videos = Array.from(document.querySelectorAll('video'));
|if (videos.length === 0) { return; }
|
|var $ID_TARGET_VIDEO = videos.find(function (video) { return !video.paused });
|if (!$ID_TARGET_VIDEO) {
| $ID_TARGET_VIDEO = videos[0];
|}
""".trimMargin()
val expressionToEval = getExpressionToEval(ID_TARGET_VIDEO)
evalJS("""
|(function() {
| $GET_TARGET_VIDEO_OR_RETURN
| $expressionToEval
|})();
""".trimMargin())
}
fun EngineView.playTargetVideo() {
evalJSWithTargetVideo { videoId -> "$videoId.play();" }
}
fun EngineView.pauseTargetVideo(isInterruptedByVoiceCommand: Boolean) {
fun getJS(videoId: String) = if (!isInterruptedByVoiceCommand) {
"$videoId.pause();"
} else {
// The video is paused for us during a voice command: my theory is that WebView
// pauses/resumes videos when audio focus is revoked/granted to it (while it's given
// to the voice command). Unfortunately, afaict there is no way to prevent WebView
// from resuming these paused videos so we have to pause it after it resumes.
// Unfortunately, there is no callback for this (or audio focus changes) so we
// inject JS to pause the video immediately after it starts again.
//
// We timeout the if-playing-starts-pause listener so, if for some reason this
// listener isn't called immediately, it doesn't pause the video after the user
// attempts to play it in the future (e.g. user says "pause" while video is already
// paused and then requests a play).
"""
| var playingEvent = 'playing';
| var initialExecuteMillis = new Date();
|
| function onPlay() {
| var now = new Date();
| var millisPassed = now.getTime() - initialExecuteMillis.getTime();
| if (millisPassed < 1000) {
| $videoId.pause();
| }
|
| $videoId.removeEventListener(playingEvent, onPlay);
| }
|
| $videoId.addEventListener(playingEvent, onPlay);
""".trimMargin()
}
evalJSWithTargetVideo(::getJS)
}
fun EngineView.seekTargetVideoToPosition(absolutePositionSeconds: Long) {
evalJSWithTargetVideo { videoId -> "$videoId.currentTime = $absolutePositionSeconds;" }
}
fun EngineView.checkYoutubeBack(callback: ValueCallback<String>) {
val shouldWeExitPage = """
(function () {
return $NO_ELEMENT_FOCUSED ||
$BODY_ELEMENT_FOCUSED ||
$SIDEBAR_FOCUSED;
})();
""".trimIndent()
evalJS(shouldWeExitPage, callback)
}
/**
* This functionality is not supported by browser-engine-system yet. See [EngineView.evalJS] comment for details.
*/
@SuppressLint("JavascriptInterface")
fun EngineView.addJavascriptInterface(obj: Any, name: String) {
webView?.addJavascriptInterface(obj, name)
}
/**
* This functionality is not supported by browser-engine-system yet. See [EngineView.evalJS] comment for details.
*/
fun EngineView.removeJavascriptInterface(interfaceName: String) {
webView?.removeJavascriptInterface(interfaceName)
}
fun EngineView.scrollByClamped(vx: Int, vy: Int) {
webView?.apply {
fun clampScroll(scroll: Int, canScroll: (direction: Int) -> Boolean) = if (scroll != 0 && canScroll(scroll)) {
scroll
} else {
0
}
// This is not a true clamp: it can only stop us from
// continuing to scroll if we've already overscrolled.
val scrollX = clampScroll(vx) { canScrollHorizontally(it) }
val scrollY = clampScroll(vy) { canScrollVertically(it) }
scrollBy(scrollX, scrollY)
}
}
fun EngineView.couldScrollInDirection(direction: Direction): Boolean =
when (direction) {
Direction.UP -> webView?.canScrollVertically(-1)
Direction.DOWN -> webView?.canScrollVertically(1)
Direction.LEFT -> webView?.canScrollHorizontally(-1)
Direction.RIGHT -> webView?.canScrollHorizontally(1)
} == true
fun EngineView.handleYoutubeBack(indexToGoBackTo: Int) {
val goBackSteps = backForwardList.currentIndex - indexToGoBackTo
webView!!.goBackOrForward(-goBackSteps)
}
val EngineView.backForwardList: WebBackForwardList
get() = webView!!.copyBackForwardList()
fun EngineView.maybeGoBackBeforeFxaSignIn() {
val webView = webView ?: return
val backForwardList = backForwardList.toList()
// We get the last index because it is guaranteed to be from the latest sign in attempt.
// If we got the first index, which seems like it'd be the first page in the latest sign
// in attempt, it's possible that the user has visited the Firefox Accounts page directly
// in the past and we'll pop more history items than we intend to.
val lastIndexOfLastSignInAttempt = backForwardList.indexOfLast {
it.originalUrl.startsWith(URLs.FIREFOX_ACCOUNTS)
}
val numStepsToGoBack = backForwardList.subList(0, lastIndexOfLastSignInAttempt + 1)
.reversed()
.takeWhile { it.originalUrl.startsWith(URLs.FIREFOX_ACCOUNTS) }
.size
val goBackOrForwardSteps = -numStepsToGoBack // negative num to go back.
// If this value is invalid (which it shouldn't be), this is a no-op.
webView.goBackOrForward(goBackOrForwardSteps)
}
val EngineView.focusedDOMElement: FocusedDOMElementCache
get() = getOrPutExtension(this).domElementCache
fun EngineView.saveState(): Bundle {
val bundle = Bundle()
getOrPutExtension(this).webView?.saveState(bundle)
return bundle
}
fun EngineView.restoreState(state: Bundle) {
getOrPutExtension(this).webView?.restoreState(state)
}
fun EngineView.canGoBackTwice(): Boolean {
return getOrPutExtension(this).webView?.canGoBackOrForward(-2) ?: false
}
fun EngineView.onPauseIfNotNull() {
if (webView != null)
this.onPause()
}
fun EngineView.onResumeIfNotNull() {
if (webView != null)
this.onResume()
}
// This method is only for adding extension methods here (as a workaround). Do not expose WebView to the app.
@VisibleForTesting(otherwise = PRIVATE) val EngineView.webView: WebView?
get() = getOrPutExtension(this).webView
private val extensions = WeakHashMap<EngineView, EngineViewExtension>()
private fun getOrPutExtension(engineView: EngineView): EngineViewExtension {
extensions[engineView]?.let { return it }
return EngineViewExtension(engineView).also {
extensions.clear()
extensions[engineView] = it
}
}
/**
* Cache of additional properties on [EngineView].
*/
private class EngineViewExtension(private val engineView: EngineView) {
val domElementCache: FocusedDOMElementCache = FocusedDOMElementCache(engineView)
private val sessionManager: SessionManager = engineView.asView().context.webRenderComponents.sessionManager
/**
* Extract the wrapped WebView from the EngineSession. This is a temporary workaround until all required functionality has
* been implemented in the upstream component.
*/
val webView: WebView?
get() =
if (sessionManager.size > 0) {
(sessionManager.getOrCreateEngineSession() as SystemEngineSession).webView
} else {
// After clearing all session we temporarily don't have a selected session
// and [SessionRepo.clear()] destroyed the existing webview - see [SystemEngineView.onDestroy()]
null
}
}