refactor(webview): Replace WebView with its Compose counterpart

Co-authored-by: null2264 <palembani@gmail.com>
This commit is contained in:
arkon 2024-11-26 22:01:37 +07:00 committed by Ahmad Ansori Palembani
parent a199ff326d
commit 5e84586ff5
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
15 changed files with 687 additions and 328 deletions

View file

@ -168,7 +168,7 @@ dependencies {
implementation(compose.bundles.compose)
debugImplementation(compose.ui.tooling)
implementation(libs.compose.theme.adapter3)
implementation(libs.accompanist.webview)
implementation(compose.webview)
implementation(libs.flexbox)

View file

@ -1,175 +1,44 @@
package eu.kanade.tachiyomi.ui.webview
import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.widget.LinearLayout
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.changesIn
import eu.kanade.tachiyomi.databinding.WebviewActivityBinding
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.getPrefTheme
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.isInNightMode
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.view.setStyle
import android.R as AR
open class BaseWebViewActivity : BaseActivity<WebviewActivityBinding>() {
private var bundle: Bundle? = null
// FIXME: Not sure if some of these stuff still needed
open class BaseWebViewActivity : BaseActivity<ViewBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = WebviewActivityBinding.inflate(layoutInflater)
delegate.localNightMode = preferences.nightMode().get()
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.toolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
val tintColor = getResourceColor(R.attr.actionBarTintColor)
binding.toolbar.navigationIcon?.setTint(tintColor)
binding.toolbar.navigationIcon?.setTint(tintColor)
binding.toolbar.overflowIcon?.mutate()
binding.toolbar.overflowIcon?.setTint(tintColor)
val container: ViewGroup = findViewById(R.id.web_view_layout)
val content: LinearLayout = binding.webLinearLayout
WindowCompat.setDecorFitsSystemWindows(window, false)
ViewCompat.setOnApplyWindowInsetsListener(container) { v, insets ->
val contextView = window?.decorView?.findViewById<View>(R.id.action_mode_bar)
contextView?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
leftMargin = insets.getInsets(systemBars()).left
rightMargin = insets.getInsets(systemBars()).right
}
// Consume any horizontal insets and pad all content in. There's not much we can do
// with horizontal insets
v.updatePadding(
left = insets.getInsets(systemBars()).left,
right = insets.getInsets(systemBars()).right,
)
WindowInsetsCompat.Builder(insets).setInsets(
systemBars(),
Insets.of(
0,
insets.getInsets(systemBars()).top,
0,
insets.getInsets(systemBars()).bottom,
),
).build()
}
binding.swipeRefresh.setStyle()
binding.swipeRefresh.setOnRefreshListener {
refreshPage()
}
window.statusBarColor = ColorUtils.setAlphaComponent(
getResourceColor(R.attr.colorSurface),
255,
)
ViewCompat.setOnApplyWindowInsetsListener(content) { v, insets ->
// if pure white theme on a device that does not support dark status bar
/*if (getResourceColor(AR.attr.statusBarColor) != Color.TRANSPARENT)
window.statusBarColor = Color.BLACK
else window.statusBarColor = getResourceColor(R.attr.colorPrimary)*/
window.navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Color.BLACK
} else {
getResourceColor(R.attr.colorPrimaryVariant)
}
v.setPadding(
insets.getInsets(systemBars()).left,
insets.getInsets(systemBars()).top,
insets.getInsets(systemBars()).right,
0,
)
if (!isInNightMode()) {
WindowInsetsControllerCompat(window, content).isAppearanceLightNavigationBars = true
}
insets
}
binding.swipeRefresh.isEnabled = false
if (bundle == null) {
binding.webview.setDefaultSettings()
binding.webview.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
binding.progressBar.isVisible = true
binding.progressBar.progress = newProgress
if (newProgress == 100) {
binding.progressBar.isInvisible = true
invalidateOptionsMenu()
}
super.onProgressChanged(view, newProgress)
}
override fun onReceivedTitle(view: WebView?, title: String?) {
super.onReceivedTitle(view, title)
this@BaseWebViewActivity.title = title
binding.toolbarTitle.text = title
binding.toolbarSubtitle.text = view?.url
binding.toolbarSubtitle.isSelected = true
}
}
val marginB = binding.webview.marginBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.swipeRefresh) { v, insets ->
val bottomInset = insets.getInsets(systemBars()).bottom
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = marginB + bottomInset
}
insets
}
} else {
bundle?.let {
binding.webview.restoreState(it)
}
}
preferences.incognitoMode()
.changesIn(lifecycleScope) {
SecureActivityDelegate.setSecure(this)
}
}
override fun onProvideAssistContent(outContent: AssistContent?) {
super.onProvideAssistContent(outContent)
binding.webview.url?.let { outContent?.webUri = it.toUri() }
}
private fun refreshPage() {
binding.swipeRefresh.isRefreshing = true
binding.webview.reload()
}
@SuppressLint("ResourceType")
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@ -189,30 +58,14 @@ open class BaseWebViewActivity : BaseActivity<WebviewActivityBinding>() {
val attrs = theme.obtainStyledAttributes(
intArrayOf(
R.attr.colorSurface,
R.attr.actionBarTintColor,
R.attr.colorPrimaryVariant,
),
)
val colorSurface = attrs.getColor(0, 0)
val actionBarTintColor = attrs.getColor(1, 0)
val colorPrimaryVariant = attrs.getColor(2, 0)
val colorPrimaryVariant = attrs.getColor(1, 0)
attrs.recycle()
window.statusBarColor = ColorUtils.setAlphaComponent(colorSurface, 255)
binding.toolbar.setBackgroundColor(colorSurface)
binding.toolbar.popupTheme =
if (lightMode) {
R.style.ThemeOverlay_Material3
} else {
R.style.ThemeOverlay_Material3_Dark
}
binding.toolbar.setNavigationIconTint(actionBarTintColor)
binding.toolbar.overflowIcon?.mutate()
binding.toolbar.setTitleTextColor(actionBarTintColor)
binding.toolbar.overflowIcon?.setTint(actionBarTintColor)
binding.swipeRefresh.setColorSchemeColors(actionBarTintColor)
binding.swipeRefresh.setProgressBackgroundColorSchemeColor(colorPrimaryVariant)
window.navigationBarColor =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || !lightMode) {
colorPrimaryVariant

View file

@ -1,42 +1,97 @@
package eu.kanade.tachiyomi.ui.webview
import android.app.assist.AssistContent
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import androidx.core.graphics.ColorUtils
import android.widget.Toast
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.extensionIntentForText
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import eu.kanade.tachiyomi.util.view.setComposeContent
import okhttp3.HttpUrl.Companion.toHttpUrl
import uy.kohesive.injekt.injectLazy
import yokai.i18n.MR
import yokai.presentation.webview.WebViewScreenContent
import yokai.util.lang.getString
open class WebViewActivity : BaseWebViewActivity() {
private val sourceManager by injectLazy<SourceManager>()
private var bundle: Bundle? = null
private val sourceManager: SourceManager by injectLazy()
private val network: NetworkHelper by injectLazy()
private var backPressedCallback: OnBackPressedCallback? = null
private val backCallback = {
if (binding.webview.canGoBack()) binding.webview.goBack()
reEnableBackPressedCallBack()
private var assistUrl: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!WebViewUtil.supportsWebView(this)) {
toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
finish()
return
}
val url = intent.extras?.getString(URL_KEY) ?: return
assistUrl = url
var headers = emptyMap<String, String>()
(sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource)?.let { source ->
try {
headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
} catch (e: Exception) {
Logger.e(e) { "Failed to build headers" }
}
}
setComposeContent {
WebViewScreenContent(
onNavigateUp = { finish() },
initialTitle = intent.extras?.getString(TITLE_KEY),
url = url,
headers = headers,
onUrlChange = { assistUrl = it },
onShare = this::shareWebpage,
onOpenInApp = this::openUrlInApp,
onOpenInBrowser = this::openInBrowser,
onClearCookies = this::clearCookies,
)
}
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
assistUrl?.let { outContent.webUri = it.toUri() }
}
private fun openUrlInApp(url: String) {
extensionIntentForText(url)?.let { startActivity(it) }
}
private fun shareWebpage(url: String) {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(Intent.createChooser(intent, getString(MR.strings.share)))
} catch (e: Exception) {
toast(e.message)
}
}
private fun openInBrowser(url: String) {
openInBrowser(url, forceBrowser = true, fullBrowser = true)
}
private fun clearCookies(url: String) {
val cleared = network.cookieJar.remove(url.toHttpUrl())
toast("Cleared $cleared cookies for: $url")
}
companion object {
@ -53,155 +108,4 @@ open class WebViewActivity : BaseWebViewActivity() {
return intent
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
title = intent.extras?.getString(TITLE_KEY)
binding.swipeRefresh.isEnabled = false
backPressedCallback = onBackPressedDispatcher.addCallback { backCallback() }
binding.toolbar.setNavigationOnClickListener {
backPressedCallback?.isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
if (bundle == null) {
val url = intent.extras!!.getString(URL_KEY) ?: return
var headers = emptyMap<String, String>()
(sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource)?.let { source ->
try {
headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
} catch (e: Exception) {
Logger.e(e) { "Failed to build headers" }
}
}
headers["user-agent"]?.let {
binding.webview.settings.userAgentString = it
}
binding.webview.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
// Don't attempt to open blobs as webpages
if (url.startsWith("blob:http")) {
return false
}
// Continue with request, but with custom headers
view.loadUrl(url, headers)
return true
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
invalidateOptionsMenu()
binding.swipeRefresh.isEnabled = true
binding.swipeRefresh.isRefreshing = false
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
invalidateOptionsMenu()
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
binding.webview.scrollTo(0, 0)
}
override fun doUpdateVisitedHistory(
view: WebView?,
url: String?,
isReload: Boolean,
) {
super.doUpdateVisitedHistory(view, url, isReload)
if (!isReload) {
invalidateOptionsMenu()
}
}
}
binding.webview.loadUrl(url, headers)
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
invalidateOptionsMenu()
}
/**
* Called when the options menu of the toolbar is being created. It adds our custom menu.
*/
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.webview, menu)
return true
}
private fun reEnableBackPressedCallBack() {
backPressedCallback?.isEnabled = binding.webview.canGoBack()
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val backItem = binding.toolbar.menu.findItem(R.id.action_web_back)
val forwardItem = binding.toolbar.menu.findItem(R.id.action_web_forward)
backItem?.isEnabled = binding.webview.canGoBack()
forwardItem?.isEnabled = binding.webview.canGoForward()
val hasHistory = binding.webview.canGoBack() || binding.webview.canGoForward()
backItem?.isVisible = hasHistory
forwardItem?.isVisible = hasHistory
val tintColor = getResourceColor(R.attr.actionBarTintColor)
val translucentWhite = ColorUtils.setAlphaComponent(tintColor, 127)
backItem.icon?.setTint(if (binding.webview.canGoBack()) tintColor else translucentWhite)
forwardItem?.icon?.setTint(if (binding.webview.canGoForward()) tintColor else translucentWhite)
val extenstionCanOpenUrl = binding.webview.canGoBack() &&
binding.webview.url?.let { extensionIntentForText(it) != null } ?: false
binding.toolbar.menu.findItem(R.id.action_open_in_app)?.isVisible = extenstionCanOpenUrl
reEnableBackPressedCallBack()
return super.onPrepareOptionsMenu(menu)
}
/**
* Called when an item of the options menu was clicked. Used to handle clicks on our menu
* entries.
*/
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_web_back -> binding.webview.goBack()
R.id.action_web_forward -> binding.webview.goForward()
R.id.action_web_share -> shareWebpage()
R.id.action_web_browser -> openInBrowser()
R.id.action_open_in_app -> openUrlInApp()
R.id.action_web_clear_cookies -> clearCookies(binding.webview.url)
}
return super.onOptionsItemSelected(item)
}
private fun clearCookies(url: String?) {
url?.toHttpUrlOrNull()?.let {
val cleared = network.cookieJar.remove(it)
toast("Cleared $cleared cookies for: $url")
}
}
private fun openUrlInApp() {
val url = binding.webview.url ?: return
extensionIntentForText(url)?.let { startActivity(it) }
}
private fun shareWebpage() {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, binding.webview.url)
}
startActivity(Intent.createChooser(intent, getString(MR.strings.share)))
} catch (e: Exception) {
toast(e.message)
}
}
private fun openInBrowser() {
binding.webview.url?.let { openInBrowser(it, forceBrowser = true, fullBrowser = true) }
}
}

View file

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.ui.webview
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import eu.kanade.tachiyomi.util.compose.currentOrThrow
import yokai.presentation.webview.WebViewScreenContent
import yokai.util.AssistContentScreen
import yokai.util.Screen
class WebViewScreen(
private val url: String,
private val initialTitle: String? = null,
private val sourceId: Long? = null,
) : Screen(), AssistContentScreen {
private var assistUrl: String? = null
override fun onProvideAssistUrl() = assistUrl
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val screenModel = rememberScreenModel { WebViewScreenModel(sourceId) }
WebViewScreenContent(
onNavigateUp = { navigator.pop() },
initialTitle = initialTitle,
url = url,
headers = screenModel.headers,
onUrlChange = { assistUrl = it },
onShare = { screenModel.shareWebpage(context, it) },
onOpenInApp = { screenModel.openInApp(context, it) },
onOpenInBrowser = { screenModel.openInBrowser(context, it) },
onClearCookies = screenModel::clearCookies,
)
}
}

View file

@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.ui.webview
import android.content.Context
import android.content.Intent
import cafe.adriel.voyager.core.model.StateScreenModel
import co.touchlab.kermit.Logger
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.extensionIntentForText
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import yokai.i18n.MR
import yokai.presentation.StatsScreenState
import yokai.util.lang.getString
class WebViewScreenModel(
val sourceId: Long?,
private val sourceManager: SourceManager = Injekt.get(),
private val network: NetworkHelper = Injekt.get(),
) : StateScreenModel<StatsScreenState>(StatsScreenState.Loading) {
var headers = emptyMap<String, String>()
init {
sourceId?.let { sourceManager.get(it) as? HttpSource }?.let { source ->
try {
headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
} catch (e: Exception) {
Logger.e(e) { "Failed to build headers" }
}
}
}
fun shareWebpage(context: Context, url: String) {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
context.startActivity(Intent.createChooser(intent, context.getString(MR.strings.share)))
} catch (e: Exception) {
context.toast(e.message)
}
}
fun openInApp(context: Context, url: String) {
context.extensionIntentForText(url)?.let { context.startActivity(it) }
}
fun openInBrowser(context: Context, url: String) {
context.openInBrowser(url, forceBrowser = true, fullBrowser = true)
}
fun clearCookies(url: String) {
url.toHttpUrlOrNull()?.let {
val cleared = network.cookieJar.remove(it)
Logger.d { "Cleared $cleared cookies for: $url" }
}
}
}

View file

@ -32,6 +32,8 @@ import android.view.WindowInsets
import android.widget.Button
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.Dimension
import androidx.annotation.FloatRange
import androidx.annotation.IdRes
@ -39,6 +41,12 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.core.animation.addListener
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
@ -64,7 +72,6 @@ import com.github.florent37.viewtooltip.ViewTooltip
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.math.MathUtils
import com.google.android.material.navigation.NavigationBarItemView
import com.google.android.material.navigation.NavigationBarMenuView
@ -74,9 +81,6 @@ import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.snackbar.Snackbar
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R
import yokai.i18n.MR
import yokai.util.lang.getString
import dev.icerock.moko.resources.compose.stringResource
import eu.kanade.tachiyomi.util.lang.tintText
import eu.kanade.tachiyomi.util.system.ThemeUtil
import eu.kanade.tachiyomi.util.system.dpToPx
@ -90,6 +94,24 @@ import eu.kanade.tachiyomi.widget.StaggeredGridLayoutManagerAccurateOffset
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.roundToInt
import yokai.presentation.theme.YokaiTheme
import yokai.util.lang.getString
inline fun ComponentActivity.setComposeContent(
parent: CompositionContext? = null,
crossinline content: @Composable () -> Unit,
) {
setContent(parent) {
YokaiTheme {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
content()
}
}
}
}
/**
* Returns coordinates of view.

View file

@ -0,0 +1,16 @@
package yokai.presentation
import androidx.compose.runtime.Immutable
sealed interface StatsScreenState {
@Immutable
data object Loading : StatsScreenState
// @Immutable
// data class Success(
// val overview: StatsData.Overview,
// val titles: StatsData.Titles,
// val chapters: StatsData.Chapters,
// val trackers: StatsData.Trackers,
// ) : StatsScreenState
}

View file

@ -0,0 +1,160 @@
package yokai.presentation.component
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.collections.immutable.ImmutableList
import yokai.i18n.MR
@Composable
fun AppBarTitle(
title: String?,
modifier: Modifier = Modifier,
subtitle: String? = null,
) {
Column(modifier = modifier) {
title?.let {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.basicMarquee(
repeatDelayMillis = 2_000,
),
)
}
}
}
@Composable
fun AppBarActions(
actions: ImmutableList<AppBar.AppBarAction>,
) {
var showMenu by remember { mutableStateOf(false) }
actions.filterIsInstance<AppBar.Action>().map {
TooltipBox (
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(it.title)
}
},
state = rememberTooltipState(),
) {
IconButton(
onClick = it.onClick,
enabled = it.enabled,
) {
Icon(
imageVector = it.icon,
tint = it.iconTint ?: LocalContentColor.current,
contentDescription = it.title,
)
}
}
}
val overflowActions = actions
.filterIsInstance<AppBar.OverflowAction>()
.filter { it.isVisible }
if (overflowActions.isNotEmpty()) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(stringResource(MR.strings.action_menu_overflow_description))
}
},
state = rememberTooltipState(),
) {
IconButton(
onClick = { showMenu = !showMenu },
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(MR.strings.action_menu_overflow_description),
)
}
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
overflowActions.map {
DropdownMenuItem(
onClick = {
it.onClick()
showMenu = false
},
text = { Text(it.title, fontWeight = FontWeight.Normal) },
)
}
}
}
}
@Composable
fun UpIcon(
modifier: Modifier = Modifier,
navigationIcon: ImageVector? = null,
) {
val icon = navigationIcon
?: Icons.AutoMirrored.Outlined.ArrowBack
Icon(
imageVector = icon,
contentDescription = stringResource(MR.strings.action_bar_up_description),
modifier = modifier,
)
}
sealed interface AppBar {
sealed interface AppBarAction
data class Action(
val title: String,
val icon: ImageVector,
val iconTint: Color? = null,
val onClick: () -> Unit,
val enabled: Boolean = true,
) : AppBarAction
data class OverflowAction(
val title: String,
val onClick: () -> Unit,
val isVisible: Boolean = true,
) : AppBarAction
}

View file

@ -0,0 +1,30 @@
package yokai.presentation.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun WarningBanner(
textRes: StringResource,
modifier: Modifier = Modifier,
) {
Text(
text = stringResource(textRes),
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.error)
.padding(16.dp),
color = MaterialTheme.colorScheme.onError,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}

View file

@ -0,0 +1,251 @@
package yokai.presentation.webview
import android.content.pm.ApplicationInfo
import android.graphics.Bitmap
import android.webkit.WebResourceRequest
import android.webkit.WebView
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.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import com.kevinnzou.accompanist.web.AccompanistWebViewClient
import com.kevinnzou.accompanist.web.LoadingState
import com.kevinnzou.accompanist.web.WebView
import com.kevinnzou.accompanist.web.rememberWebViewNavigator
import com.kevinnzou.accompanist.web.rememberWebViewState
import dev.icerock.moko.resources.compose.stringResource
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.system.extensionIntentForText
import eu.kanade.tachiyomi.util.system.getHtml
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import yokai.i18n.MR
import yokai.presentation.component.AppBar
import yokai.presentation.component.AppBarActions
import yokai.presentation.component.AppBarTitle
import yokai.presentation.component.UpIcon
import yokai.presentation.component.WarningBanner
@Composable
fun WebViewScreenContent(
onNavigateUp: () -> Unit,
initialTitle: String?,
url: String,
onShare: (String) -> Unit,
onOpenInApp: (String) -> Unit,
onOpenInBrowser: (String) -> Unit,
onClearCookies: (String) -> Unit,
headers: Map<String, String> = emptyMap(),
onUrlChange: (String) -> Unit = {},
) {
val state = rememberWebViewState(url = url, additionalHttpHeaders = headers)
val navigator = rememberWebViewNavigator()
val uriHandler = LocalUriHandler.current
val context = LocalContext.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 {
request?.let {
// Don't attempt to open blobs as webpages
if (it.url.toString().startsWith("blob:http")) {
return false
}
// Ignore intents urls
if (it.url.toString().startsWith("intent://")) {
return true
}
// Continue with request, but with custom headers
view?.loadUrl(it.url.toString(), headers)
}
return super.shouldOverrideUrlLoading(view, request)
}
}
}
Scaffold (
topBar = {
Box {
Column {
TopAppBar(
title = {
AppBarTitle(
title = state.pageTitle ?: initialTitle,
subtitle = currentUrl,
)
},
navigationIcon = {
IconButton(onClick = onNavigateUp) {
UpIcon(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.share),
onClick = { onShare(currentUrl) },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.open_in_app),
onClick = { onOpenInApp(currentUrl) },
isVisible = navigator.canGoBack &&
context.extensionIntentForText(currentUrl) != null,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.open_in_browser),
onClick = { onOpenInBrowser(currentUrl) },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.clear_cookies),
onClick = { onClearCookies(currentUrl) },
),
),
)
},
)
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 = 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 ->
WebView(
state = 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
}
},
client = webClient,
)
}
}

View file

@ -8,3 +8,7 @@ abstract class Screen : Screen {
override val key: ScreenKey = uniqueScreenKey
}
interface AssistContentScreen {
fun onProvideAssistUrl(): String?
}

View file

@ -7,6 +7,8 @@ import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebView
import co.touchlab.kermit.Logger
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
object WebViewUtil {
const val MINIMUM_WEBVIEW_VERSION = 114
@ -65,3 +67,7 @@ private fun WebView.getDefaultUserAgentString(): String {
return defaultUserAgentString
}
suspend fun WebView.getHtml(): String = suspendCancellableCoroutine {
evaluateJavascript("document.documentElement.outerHTML") { html -> it.resume(html) }
}

View file

@ -10,6 +10,7 @@ material-motion = { module = "io.github.fornewid:material-motion-compose-core",
ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
icons = { module = "androidx.compose.material:material-icons-extended" }
webview = { module = "io.github.kevinnzou:compose-webview", version = "0.33.3" }
[bundles]
compose = [ "animation", "foundation", "material3", "material-motion", "ui-tooling-preview", "icons" ]

View file

@ -18,7 +18,6 @@ voyager = "1.1.0-beta02"
[libraries]
aboutlibraries = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version = "0.30.1" }
chucker-library-no-op = { module = "com.github.ChuckerTeam.Chucker:library-no-op", version.ref = "chucker" }
chucker-library = { module = "com.github.ChuckerTeam.Chucker:library", version.ref = "chucker" }
coil3-bom = { module = "io.coil-kt.coil3:coil-bom", version = "3.0.3" }
@ -99,6 +98,7 @@ viewtooltip = { module = "com.github.CarlosEsco:ViewTooltip", version = "f79a895
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
[plugins]
aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" }
@ -120,4 +120,4 @@ sqlite = [ "sqlite-ktx" ]
test = [ "junit-api", "kotest-assertions", "mockk" ]
test-android = [ "junit-android" ]
test-runtime = [ "junit-engine" ]
voyager = ["voyager-navigator", "voyager-transitions"]
voyager = ["voyager-navigator", "voyager-transitions", "voyager-screenmodel"]

View file

@ -4,6 +4,9 @@
<string name="app_short_name" translatable="false">Yōkai</string>
<string name="app_normalized_name" translatable="false">Yokai</string>
<string name="action_menu_overflow_description">More options</string>
<string name="action_bar_up_description">Navigate up</string>
<string name="all_files_permission_required">File permissions required</string>
<string name="external_storage_permission_notice">TachiyomiJ2K requires access to all files in Android 11 to download chapters, create automatic backups, and read local series. \n\nOn the next screen, enable \"Allow access to manage all files.\"</string>
<string name="external_storage_download_notice">TachiyomiJ2K requires access to all files to download chapters. Tap here, then enable \"Allow access to manage all files.\"</string>
@ -1037,9 +1040,14 @@
<!-- Webview -->
<string name="failed_to_bypass_cloudflare">Failed to bypass Cloudflare</string>
<string name="information_cloudflare_help">Tap here for help with Cloudflare</string>
<string name="please_update_webview">Please update the WebView app for better compatibility</string>
<string name="information_webview_required">WebView is required for the app to function</string>
<!-- Do not translate "WebView" -->
<string name="webview_is_required">WebView is required for Tachiyomi</string>
<string name="action_webview_back">Back</string>
<string name="action_webview_forward">Forward</string>
<string name="action_webview_refresh">Refresh</string>
<!-- App widget -->
<string name="appwidget_updates_description">See your recently updated library entries</string>