Files
TachiyomiSY/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt
T
Trevor Paley edcf939611 Improve WebView multi-window UX (#2662)
- Navigation history for lower windows is preserved when a popup is opened
- Back gesture will close a popup window rather than the entire WebView activity when there is no previous page
- The leftmost close button closes the entire activity as before
- When a popup window is shown, a new button appears to close just that window

(cherry picked from commit 855eea2ada8c09404dee99a965325913b246618f)
2025-11-16 18:07:44 +05:45

338 lines
14 KiB
Kotlin

package eu.kanade.presentation.webview
import android.content.pm.ApplicationInfo
import android.graphics.Bitmap
import android.os.Message
import android.webkit.WebResourceRequest
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.stack.mutableStateStackOf
import com.kevinnzou.web.AccompanistWebChromeClient
import com.kevinnzou.web.AccompanistWebViewClient
import com.kevinnzou.web.LoadingState
import com.kevinnzou.web.WebContent
import com.kevinnzou.web.WebView
import com.kevinnzou.web.WebViewNavigator
import com.kevinnzou.web.WebViewState
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getHtml
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
class WebViewWindow(webContent: WebContent, val navigator: WebViewNavigator) {
var state by mutableStateOf(WebViewState(webContent))
var popupMessage: Message? = null
private set
var webView: WebView? = null
constructor(popupMessage: Message, navigator: WebViewNavigator) : this(WebContent.NavigatorOnly, navigator) {
this.popupMessage = popupMessage
}
}
@Composable
fun WebViewScreenContent(
onNavigateUp: () -> Unit,
initialTitle: String?,
url: String,
onShare: (String) -> Unit,
onOpenInBrowser: (String) -> Unit,
onClearCookies: (String) -> Unit,
headers: Map<String, String> = emptyMap(),
onUrlChange: (String) -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val windowStack = remember {
mutableStateStackOf(
WebViewWindow(
WebContent.Url(url = url, additionalHttpHeaders = headers),
WebViewNavigator(coroutineScope),
),
)
}
val currentWindow = windowStack.lastItemOrNull!!
val navigator = currentWindow.navigator
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
var currentUrl by remember { mutableStateOf(url) }
var showCloudflareHelp by remember { mutableStateOf(false) }
val webClient = remember {
object : AccompanistWebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
url?.let {
currentUrl = it
onUrlChange(it)
}
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
scope.launch {
val html = view.getHtml()
showCloudflareHelp = "window._cf_chl_opt" in html || "Ray ID is" in html
}
}
override fun doUpdateVisitedHistory(
view: WebView,
url: String?,
isReload: Boolean,
) {
super.doUpdateVisitedHistory(view, url, isReload)
url?.let {
currentUrl = it
onUrlChange(it)
}
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return false
// Ignore intents urls
if (url.startsWith("intent://")) return true
// Only open valid web urls
if (url.startsWith("http") || url.startsWith("https")) {
if (url != view?.url) {
view?.loadUrl(url, headers)
return true
}
}
return false
}
}
}
val webChromeClient = remember {
object : AccompanistWebChromeClient() {
override fun onCreateWindow(
view: WebView,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: Message,
): Boolean {
// if it wasn't initiated by a user gesture, we should ignore it like a normal browser would
if (isUserGesture) {
windowStack.push(WebViewWindow(resultMsg, WebViewNavigator(coroutineScope)))
return true
}
return false
}
}
}
fun initializePopup(webView: WebView, message: Message): WebView {
val transport = message.obj as WebView.WebViewTransport
transport.webView = webView
message.sendToTarget()
return webView
}
val popState = remember<() -> Unit> {
{
if (windowStack.size == 1) {
onNavigateUp()
} else {
windowStack.pop()
}
}
}
BackHandler(windowStack.size > 1, popState)
Scaffold(
topBar = {
Box {
Column {
AppBar(
title = currentWindow.state.pageTitle ?: initialTitle,
subtitle = currentUrl,
navigateUp = onNavigateUp,
navigationIcon = Icons.Outlined.Close,
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_webview_back),
icon = Icons.AutoMirrored.Outlined.ArrowBack,
onClick = {
if (navigator.canGoBack) {
navigator.navigateBack()
}
},
enabled = navigator.canGoBack,
),
AppBar.Action(
title = stringResource(MR.strings.action_webview_forward),
icon = Icons.AutoMirrored.Outlined.ArrowForward,
onClick = {
if (navigator.canGoForward) {
navigator.navigateForward()
}
},
enabled = navigator.canGoForward,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.action_webview_refresh),
onClick = { navigator.reload() },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.action_share),
onClick = { onShare(currentUrl) },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.action_open_in_browser),
onClick = { onOpenInBrowser(currentUrl) },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.pref_clear_cookies),
onClick = { onClearCookies(currentUrl) },
),
).builder().apply {
if (windowStack.size > 1) {
add(
0,
AppBar.Action(
title = stringResource(MR.strings.action_webview_close_tab),
icon = ImageVector.vectorResource(R.drawable.ic_tab_close_24px),
onClick = popState,
),
)
}
}.build(),
)
},
)
if (showCloudflareHelp) {
Surface(
modifier = Modifier.padding(8.dp),
) {
WarningBanner(
textRes = MR.strings.information_cloudflare_help,
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.clickable {
uriHandler.openUri(
"https://mihon.app/docs/guides/troubleshooting/#cloudflare",
)
},
)
}
}
}
when (val loadingState = currentWindow.state.loadingState) {
is LoadingState.Initializing -> LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
is LoadingState.Loading -> LinearProgressIndicator(
progress = { (loadingState as? LoadingState.Loading)?.progress ?: 1f },
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
else -> {}
}
}
},
) { contentPadding ->
// We need to key the WebView composable to the window object since simply updating the WebView composable will
// not cause it to re-invoke the WebView factory and render the new current window's WebView. This lets us
// completely reset the WebView composable when the current window switches.
key(currentWindow) {
WebView(
state = currentWindow.state,
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
navigator = navigator,
onCreated = { webView ->
webView.setDefaultSettings()
// Debug mode (chrome://inspect/#devices)
if (BuildConfig.DEBUG &&
0 != webView.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
) {
WebView.setWebContentsDebuggingEnabled(true)
}
headers["user-agent"]?.let {
webView.settings.userAgentString = it
}
},
onDispose = { webView ->
val window = windowStack.items.find { it.webView == webView }
if (window == null) {
// If we couldn't find any window on the stack that owns this WebView, it means that we can
// safely dispose of it because the window containing it has been closed.
webView.destroy()
} else {
// The composable is being disposed but the WebView object is not.
// When the WebView element is recomposed, we will want the WebView to resume from its state
// before it was unmounted, we won't want it to reset back to its original target.
window.state.content = WebContent.NavigatorOnly
}
},
client = webClient,
chromeClient = webChromeClient,
factory = { context ->
currentWindow.webView
?: WebView(context).also { webView ->
currentWindow.webView = webView
currentWindow.popupMessage?.let {
initializePopup(webView, it)
}
}
},
)
}
}
}