From 8cb0764a72156ef722ae53e88a45bcfd305bf028 Mon Sep 17 00:00:00 2001 From: Hiirbaf <42479509+Hiirbaf@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:12:33 -0300 Subject: [PATCH] WV --- .../webview/WebViewScreenContent.kt | 225 ++++++++++++++---- .../main/res/drawable/ic_tab_close_24px.xml | 10 + .../tachiyomi/util/system/WebViewUtil.kt | 2 + .../moko-resources/base/strings.xml | 1 + 4 files changed, 198 insertions(+), 40 deletions(-) create mode 100644 app/src/main/res/drawable/ic_tab_close_24px.xml diff --git a/app/src/main/java/yokai/presentation/webview/WebViewScreenContent.kt b/app/src/main/java/yokai/presentation/webview/WebViewScreenContent.kt index 8bd4bb4cb6..52643db8ca 100644 --- a/app/src/main/java/yokai/presentation/webview/WebViewScreenContent.kt +++ b/app/src/main/java/yokai/presentation/webview/WebViewScreenContent.kt @@ -2,8 +2,12 @@ package yokai.presentation.webview import android.content.pm.ApplicationInfo import android.graphics.Bitmap +import android.os.Message +import android.webkit.JsPromptResult +import android.webkit.JsResult 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 @@ -22,24 +26,32 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect 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.ui.graphics.vector.ImageVector 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.platform.LocalContext 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.rememberWebViewNavigator -import com.kevinnzou.web.rememberWebViewState +import com.kevinnzou.web.WebViewNavigator +import com.kevinnzou.web.WebViewState import dev.icerock.moko.resources.compose.stringResource import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.util.system.extensionIntentForText import eu.kanade.tachiyomi.util.system.getHtml import eu.kanade.tachiyomi.util.system.setDefaultSettings @@ -52,6 +64,17 @@ import yokai.presentation.component.AppBarTitle import yokai.presentation.component.UpIcon import yokai.presentation.component.WarningBanner +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, @@ -64,14 +87,31 @@ fun WebViewScreenContent( headers: Map = emptyMap(), onUrlChange: (String) -> Unit = {}, ) { - val state = rememberWebViewState(url = url, additionalHttpHeaders = headers) - val navigator = rememberWebViewNavigator() + 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 context = LocalContext.current val scope = rememberCoroutineScope() var currentUrl by remember { mutableStateOf(url) } var showCloudflareHelp by remember { mutableStateOf(false) } + var isActive by remember { mutableStateOf(true) } + + DisposableEffect(Unit) { + onDispose { isActive = false } + } val webClient = remember { object : AccompanistWebViewClient() { @@ -107,25 +147,91 @@ fun WebViewScreenContent( view: WebView?, request: WebResourceRequest?, ): Boolean { - request?.let { - // Don't attempt to open blobs as webpages - if (it.url.toString().startsWith("blob:http")) { - return false - } + val url = request?.url?.toString() ?: return false + + // Ignore intents urls + if (url.startsWith("intent://")) return true - // Ignore intents urls - if (it.url.toString().startsWith("intent://")) { + // 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 + } + + override fun onJsAlert(view: WebView, url: String?, message: String?, result: JsResult): Boolean { + if (!isActive) { + result.confirm() + return true + } + return super.onJsAlert(view, url, message, result) + } + + override fun onJsConfirm(view: WebView, url: String?, message: String?, result: JsResult): Boolean { + if (!isActive) { + result.cancel() + return true + } + return super.onJsConfirm(view, url, message, result) + } - // Continue with request, but with custom headers - view?.loadUrl(it.url.toString(), headers) + override fun onJsPrompt( + view: WebView, + url: String?, + message: String?, + defaultValue: String?, + result: JsPromptResult, + ): Boolean { + if (!isActive) { + result.cancel() + return true } - return super.shouldOverrideUrlLoading(view, request) + return super.onJsPrompt(view, url, message, defaultValue, result) + } + } + } + + 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 { @@ -133,7 +239,7 @@ fun WebViewScreenContent( TopAppBar( title = { AppBarTitle( - title = state.pageTitle ?: initialTitle, + title = currentWindow.state.pageTitle ?: initialTitle, subtitle = currentUrl, ) }, @@ -187,7 +293,18 @@ fun WebViewScreenContent( title = stringResource(MR.strings.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(), ) }, ) @@ -209,14 +326,14 @@ fun WebViewScreenContent( } } } - when (val loadingState = state.loadingState) { + 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 }, + progress = { loadingState.progress }, modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter), @@ -226,28 +343,56 @@ fun WebViewScreenContent( } }, ) { contentPadding -> - WebView( - state = state, - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .imePadding(), - 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) - } + // 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) + .imePadding(), + navigator = navigator, + onCreated = { webView -> + webView.setDefaultSettings() - headers["user-agent"]?.let { - webView.settings.userAgentString = it - } - }, - client = webClient, - ) + // 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) + } + } + }, + ) + } } } diff --git a/app/src/main/res/drawable/ic_tab_close_24px.xml b/app/src/main/res/drawable/ic_tab_close_24px.xml new file mode 100644 index 0000000000..e14d35158c --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_close_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/main/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/core/main/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/WebViewUtil.kt index 072c62829a..b6863fa972 100644 --- a/core/main/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/WebViewUtil.kt +++ b/core/main/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -80,6 +80,8 @@ fun WebView.setDefaultSettings() { builtInZoomControls = true displayZoomControls = false cacheMode = WebSettings.LOAD_DEFAULT + // Handle popups properly + setSupportMultipleWindows(true) } CookieManager.getInstance().acceptThirdPartyCookies(this) diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 0669615167..cc9ec86838 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -1053,6 +1053,7 @@ Back Forward Refresh + Close tab See your recently updated library entries