EngineDelegateMiddleware.kt

/* 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 mozilla.components.browser.state.engine.middleware

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.ActionWithTab
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.TranslationsAction
import mozilla.components.browser.state.action.lookupTabIn
import mozilla.components.browser.state.action.toBrowserAction
import mozilla.components.browser.state.selector.allTabs
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.concept.engine.EngineSession
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.lib.state.Store

/**
 * [Middleware] responsible for delegating calls to the appropriate [EngineSession] instance for
 * actions like [EngineAction.LoadUrlAction].
 */
internal class EngineDelegateMiddleware(
    private val scope: CoroutineScope,
) : Middleware<BrowserState, BrowserAction> {
    override fun invoke(
        context: MiddlewareContext<BrowserState, BrowserAction>,
        next: (BrowserAction) -> Unit,
        action: BrowserAction,
    ) {
        when (action) {
            is EngineAction.LoadUrlAction -> loadUrl(context.store, action)
            is EngineAction.LoadDataAction -> loadData(context.store, action)
            is EngineAction.ReloadAction -> reload(context.store, action)
            is EngineAction.GoBackAction -> goBack(context.store, action)
            is EngineAction.GoForwardAction -> goForward(context.store, action)
            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.PrintContentAction -> printContent(context.store, action)
            is EngineAction.ClearDataAction -> clearData(context.store, action)
            is EngineAction.PurgeHistoryAction -> purgeHistory(context.state)
            is TranslationsAction.TranslateAction -> {
                next(action)
                translate(context.store, action)
            }
            is TranslationsAction.TranslateRestoreAction -> {
                next(action)
                translateRestoreOriginal(context.store, action)
            }
            else -> next(action)
        }
    }

    private fun loadUrl(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.LoadUrlAction,
    ) = scope.launch {
        val tab = store.state.findTabOrCustomTab(action.tabId) ?: return@launch
        val engineSession = tab.engineState.engineSession

        if (engineSession == null && tab.content.url == action.url) {
            // This tab does not have an engine session and we are asked to load the URL this
            // session is already pointing to. Creating an EngineSession will do exactly
            // that in the linking step. So let's do that. Otherwise we would load the URL
            // twice.
            store.dispatch(EngineAction.CreateEngineSessionAction(action.tabId, includeParent = action.includeParent))
            return@launch
        }

        val parentEngineSession = if (action.includeParent && tab is TabSessionState) {
            tab.parentId?.let { store.state.findTabOrCustomTab(it)?.engineState?.engineSession }
        } else {
            null
        }

        getEngineSessionOrDispatch(store, action)?.loadUrl(
            url = action.url,
            parent = parentEngineSession,
            flags = action.flags,
            additionalHeaders = action.additionalHeaders,
            textDirectiveUserActivation = action.textDirectiveUserActivation,
        )
    }

    private fun loadData(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.LoadDataAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.loadData(action.data, action.mimeType, action.encoding)
    }

    private fun reload(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.ReloadAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.reload(action.flags)
    }

    private fun goBack(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.GoBackAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.goBack(action.userInteraction)
    }

    private fun goForward(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.GoForwardAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.goForward(action.userInteraction)
    }

    private fun goToHistoryIndex(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.GoToHistoryIndexAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.goToHistoryIndex(action.index)
    }

    private fun toggleDesktopMode(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.ToggleDesktopModeAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.toggleDesktopMode(action.enable, reload = true)
    }

    private fun exitFullScreen(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.ExitFullScreenModeAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.exitFullScreenMode()
    }

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

    private fun printContent(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.PrintContentAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.requestPrintContent()
    }

    private fun translate(
        store: Store<BrowserState, BrowserAction>,
        action: TranslationsAction.TranslateAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.requestTranslate(action.fromLanguage, action.toLanguage, action.options)
    }

    private fun translateRestoreOriginal(
        store: Store<BrowserState, BrowserAction>,
        action: TranslationsAction.TranslateRestoreAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.requestTranslationRestore()
    }

    private fun clearData(
        store: Store<BrowserState, BrowserAction>,
        action: EngineAction.ClearDataAction,
    ) = scope.launch {
        getEngineSessionOrDispatch(store, action)
            ?.clearData(action.data)
    }

    private fun purgeHistory(
        state: BrowserState,
    ) = scope.launch {
        state.allTabs
            .mapNotNull { tab -> tab.engineState.engineSession }
            .forEach { engineSession -> engineSession.purgeHistory() }
    }
}

/**
 * Returns the [EngineSession] of the tab targeted by the provided action. If the tab
 * does not have an engine session yet a new one will be created by dispatching a
 * [EngineAction.CreateEngineSessionAction]. The provided [action] will be dispatched
 * as a follow up once the [EngineSession] has been created and initialized.
 *
 * @param store a reference to the browser store.
 * @param action the action to dispatch in case the engine session still has to be created.
 */
private fun getEngineSessionOrDispatch(
    store: Store<BrowserState, BrowserAction>,
    action: ActionWithTab,
): EngineSession? {
    val tab = action.lookupTabIn(store) ?: return null

    val engineSession = tab.engineState.engineSession

    return if (engineSession == null) {
        store.dispatch(
            EngineAction.CreateEngineSessionAction(
                action.tabId,
                followupAction = action.toBrowserAction(),
            ),
        )
        null
    } else {
        engineSession
    }
}