Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 185 additions & 40 deletions app/src/main/java/yokai/presentation/webview/WebViewScreenContent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -64,14 +87,31 @@ fun WebViewScreenContent(
headers: Map<String, String> = 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() {
Expand Down Expand Up @@ -107,33 +147,99 @@ 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 {
Column {
TopAppBar(
title = {
AppBarTitle(
title = state.pageTitle ?: initialTitle,
title = currentWindow.state.pageTitle ?: initialTitle,
subtitle = currentUrl,
)
},
Expand Down Expand Up @@ -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(),
)
},
)
Expand All @@ -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),
Expand All @@ -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)
}
}
},
)
}
}
}
10 changes: 10 additions & 0 deletions app/src/main/res/drawable/ic_tab_close_24px.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M476,540L560,456L644,540L700,484L616,400L700,316L644,260L560,344L476,260L420,316L504,400L420,484L476,540ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"/>
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ fun WebView.setDefaultSettings() {
builtInZoomControls = true
displayZoomControls = false
cacheMode = WebSettings.LOAD_DEFAULT
// Handle popups properly
setSupportMultipleWindows(true)
}

CookieManager.getInstance().acceptThirdPartyCookies(this)
Expand Down
1 change: 1 addition & 0 deletions i18n/src/commonMain/moko-resources/base/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@
<string name="action_webview_back">Back</string>
<string name="action_webview_forward">Forward</string>
<string name="action_webview_refresh">Refresh</string>
<string name="action_webview_close_tab">Close tab</string>

<!-- App widget -->
<string name="appwidget_updates_description">See your recently updated library entries</string>
Expand Down
Loading