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) implementation(compose.bundles.compose)
debugImplementation(compose.ui.tooling) debugImplementation(compose.ui.tooling)
implementation(libs.compose.theme.adapter3) implementation(libs.compose.theme.adapter3)
implementation(libs.accompanist.webview) implementation(compose.webview)
implementation(libs.flexbox) implementation(libs.flexbox)

View file

@ -1,175 +1,44 @@
package eu.kanade.tachiyomi.ui.webview package eu.kanade.tachiyomi.ui.webview
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.TypedValue 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.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.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.WindowInsetsControllerCompat 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.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.changesIn 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.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.getPrefTheme import eu.kanade.tachiyomi.util.system.getPrefTheme
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.isInNightMode 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 import android.R as AR
open class BaseWebViewActivity : BaseActivity<WebviewActivityBinding>() { // FIXME: Not sure if some of these stuff still needed
open class BaseWebViewActivity : BaseActivity<ViewBinding>() {
private var bundle: Bundle? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = WebviewActivityBinding.inflate(layoutInflater)
delegate.localNightMode = preferences.nightMode().get() delegate.localNightMode = preferences.nightMode().get()
setContentView(binding.root) 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) 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( window.statusBarColor = ColorUtils.setAlphaComponent(
getResourceColor(R.attr.colorSurface), getResourceColor(R.attr.colorSurface),
255, 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() preferences.incognitoMode()
.changesIn(lifecycleScope) { .changesIn(lifecycleScope) {
SecureActivityDelegate.setSecure(this) 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") @SuppressLint("ResourceType")
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
@ -189,30 +58,14 @@ open class BaseWebViewActivity : BaseActivity<WebviewActivityBinding>() {
val attrs = theme.obtainStyledAttributes( val attrs = theme.obtainStyledAttributes(
intArrayOf( intArrayOf(
R.attr.colorSurface, R.attr.colorSurface,
R.attr.actionBarTintColor,
R.attr.colorPrimaryVariant, R.attr.colorPrimaryVariant,
), ),
) )
val colorSurface = attrs.getColor(0, 0) val colorSurface = attrs.getColor(0, 0)
val actionBarTintColor = attrs.getColor(1, 0) val colorPrimaryVariant = attrs.getColor(1, 0)
val colorPrimaryVariant = attrs.getColor(2, 0)
attrs.recycle() attrs.recycle()
window.statusBarColor = ColorUtils.setAlphaComponent(colorSurface, 255) 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 = window.navigationBarColor =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || !lightMode) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || !lightMode) {
colorPrimaryVariant colorPrimaryVariant

View file

@ -1,42 +1,97 @@
package eu.kanade.tachiyomi.ui.webview package eu.kanade.tachiyomi.ui.webview
import android.app.assist.AssistContent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.widget.Toast
import android.view.MenuItem import androidx.core.net.toUri
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import androidx.core.graphics.ColorUtils
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource 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.extensionIntentForText
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast 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 uy.kohesive.injekt.injectLazy
import yokai.i18n.MR import yokai.i18n.MR
import yokai.presentation.webview.WebViewScreenContent
import yokai.util.lang.getString import yokai.util.lang.getString
open class WebViewActivity : BaseWebViewActivity() { open class WebViewActivity : BaseWebViewActivity() {
private val sourceManager by injectLazy<SourceManager>() private val sourceManager: SourceManager by injectLazy()
private var bundle: Bundle? = null
private val network: NetworkHelper by injectLazy() private val network: NetworkHelper by injectLazy()
private var backPressedCallback: OnBackPressedCallback? = null private var assistUrl: String? = null
private val backCallback = {
if (binding.webview.canGoBack()) binding.webview.goBack() override fun onCreate(savedInstanceState: Bundle?) {
reEnableBackPressedCallBack() 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 { companion object {
@ -53,155 +108,4 @@ open class WebViewActivity : BaseWebViewActivity() {
return intent 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.Button
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.Dimension import androidx.annotation.Dimension
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.annotation.IdRes import androidx.annotation.IdRes
@ -39,6 +41,12 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu 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.animation.addListener
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils 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.button.MaterialButton
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.datepicker.MaterialDatePicker 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.math.MathUtils
import com.google.android.material.navigation.NavigationBarItemView import com.google.android.material.navigation.NavigationBarItemView
import com.google.android.material.navigation.NavigationBarMenuView 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 com.google.android.material.snackbar.Snackbar
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R 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.lang.tintText
import eu.kanade.tachiyomi.util.system.ThemeUtil import eu.kanade.tachiyomi.util.system.ThemeUtil
import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.dpToPx
@ -90,6 +94,24 @@ import eu.kanade.tachiyomi.widget.StaggeredGridLayoutManagerAccurateOffset
import kotlin.math.max import kotlin.math.max
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.roundToInt 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. * 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 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.WebSettings
import android.webkit.WebView import android.webkit.WebView
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
object WebViewUtil { object WebViewUtil {
const val MINIMUM_WEBVIEW_VERSION = 114 const val MINIMUM_WEBVIEW_VERSION = 114
@ -65,3 +67,7 @@ private fun WebView.getDefaultUserAgentString(): String {
return defaultUserAgentString 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 = { module = "androidx.compose.ui:ui-tooling" }
ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
icons = { module = "androidx.compose.material:material-icons-extended" } icons = { module = "androidx.compose.material:material-icons-extended" }
webview = { module = "io.github.kevinnzou:compose-webview", version = "0.33.3" }
[bundles] [bundles]
compose = [ "animation", "foundation", "material3", "material-motion", "ui-tooling-preview", "icons" ] compose = [ "animation", "foundation", "material3", "material-motion", "ui-tooling-preview", "icons" ]

View file

@ -18,7 +18,6 @@ voyager = "1.1.0-beta02"
[libraries] [libraries]
aboutlibraries = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } 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-no-op = { module = "com.github.ChuckerTeam.Chucker:library-no-op", version.ref = "chucker" }
chucker-library = { module = "com.github.ChuckerTeam.Chucker:library", 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" } 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-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", 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] [plugins]
aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" } aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" }
@ -120,4 +120,4 @@ sqlite = [ "sqlite-ktx" ]
test = [ "junit-api", "kotest-assertions", "mockk" ] test = [ "junit-api", "kotest-assertions", "mockk" ]
test-android = [ "junit-android" ] test-android = [ "junit-android" ]
test-runtime = [ "junit-engine" ] 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_short_name" translatable="false">Yōkai</string>
<string name="app_normalized_name" translatable="false">Yokai</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="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_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> <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 --> <!-- Webview -->
<string name="failed_to_bypass_cloudflare">Failed to bypass Cloudflare</string> <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="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" --> <!-- Do not translate "WebView" -->
<string name="webview_is_required">WebView is required for Tachiyomi</string> <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 --> <!-- App widget -->
<string name="appwidget_updates_description">See your recently updated library entries</string> <string name="appwidget_updates_description">See your recently updated library entries</string>