2034971cc0
* Replace deprecated rememberPlainTooltipPositionProvider * Remove superfluous when branch This when is marked as exhaustive. * Replace deprecated LibrariesContainer call AboutLibraries now wants us to produce the libraries ourselves. * Replace deprecated ClipboardManager with Clipboard Clipboard uses suspend functions, hence the coroutine scope addition. * Use multi-dollar strs to simplify GraphQL queries These have been available since Kotlin 2.1. * Remove various redundant casts & conversions - WebViewScreenContent: loadingState is in the LoadingState.Loading branch, no need to cast at all - Bangumi: username is not modified, make val - Kavita: token is already a String - PagerViewerAdapter: insertPageLastPage is already null-checked - PagerViewerAdapter: use reified filterIsInstance - ReaderViewModel: chapter IDs are already Longs - CloudflareInterceptor: webview is smart-cast to non-null here * Replace deprecated MenuAnchorType Literally just a typealias for ExposedDropdownMenuAnchorType anyway. * OptimizeNonSkippingGroups is enabled by default * Suppress shadowing warning This is explicitly intentional according to the KDocs. * Migrate Context Receivers to Context Parameters Requires changing the compiler arg, but that is part of the migration: https://blog.jetbrains.com/kotlin/2025/04/update-on-context-parameters Apparently, the only visible change is that names are required now. "_" can be used for anonymous context parameters. * Fix expression bodies with explicit return Naming conflict resolved by aliasing. From 2.4/2.5 onward, these will only be allowed with explicit return types, or have to be turned into a block body. I opted for the latter since the function is reasonably dense already. see: https://youtrack.jetbrains.com/issue/KTLC-288 * Suppress deprecation of non-AutoMirrored icons We use these arrows for navigation in the Upcoming screen. I strongly doubt the AutoMirrored versions would make sense for our use-case. * Explicitly opt-in to new annotation default rules affects the following annotated value-parameters: - Preference.SliderPreference.steps (`@IntRange`) - ReaderViewModel.State.brightnessOverlayValue (`@IntRange`) - ReadingMode.iconRes (`@DrawableRes`) - MigrationListScreenModel.Dialog.Progress.progress (`@FloatRange`) see: https://youtrack.jetbrains.com/issue/KT-73255 see: https://github.com/Kotlin/KEEP/blob/change-defaulting-rule/proposals/annotation-target-in-properties.md Warning message was the following: This annotation is currently applied to the value parameter only, but in the future it will also be applied to field. - To opt in to applying to both value parameter and field, add '-Xannotation-default-target=param-property' to your compiler arguments. - To keep applying to the value parameter only, use the '@param:' annotation target. (cherry picked from commit b543bc089a442c5e93b0fb6c83bc4037740b1eb5) # Conflicts: # app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt # core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt # core/common/src/main/kotlin/mihon/core/common/archive/ArchiveInputStream.kt
338 lines
14 KiB
Kotlin
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.progress },
|
|
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)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|