diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ba671b2b09..e0f1c157d5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/BaseWebViewActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/BaseWebViewActivity.kt index 071ce66893..7928361e11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/BaseWebViewActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/BaseWebViewActivity.kt @@ -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() { - - private var bundle: Bundle? = null +// FIXME: Not sure if some of these stuff still needed +open class BaseWebViewActivity : BaseActivity() { 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(R.id.action_mode_bar) - contextView?.updateLayoutParams { - 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 { - 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() { 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt index ddb37314cf..19983bcbd3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt @@ -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() - 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() + (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() - (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) } - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreen.kt new file mode 100644 index 0000000000..ce3aa14379 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreenModel.kt new file mode 100644 index 0000000000..02e9535a7d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreenModel.kt @@ -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.Loading) { + + var headers = emptyMap() + + 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" } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt index ba20546c8d..00e0965f78 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt @@ -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. diff --git a/app/src/main/java/yokai/presentation/StatsScreenState.kt b/app/src/main/java/yokai/presentation/StatsScreenState.kt new file mode 100644 index 0000000000..571b492ed6 --- /dev/null +++ b/app/src/main/java/yokai/presentation/StatsScreenState.kt @@ -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 +} diff --git a/app/src/main/java/yokai/presentation/component/AppBar.kt b/app/src/main/java/yokai/presentation/component/AppBar.kt new file mode 100644 index 0000000000..18176cef6b --- /dev/null +++ b/app/src/main/java/yokai/presentation/component/AppBar.kt @@ -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, +) { + var showMenu by remember { mutableStateOf(false) } + + actions.filterIsInstance().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() + .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 +} diff --git a/app/src/main/java/yokai/presentation/component/Banners.kt b/app/src/main/java/yokai/presentation/component/Banners.kt new file mode 100644 index 0000000000..bd404cdd84 --- /dev/null +++ b/app/src/main/java/yokai/presentation/component/Banners.kt @@ -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, + ) +} diff --git a/app/src/main/java/yokai/presentation/webview/WebViewScreenContent.kt b/app/src/main/java/yokai/presentation/webview/WebViewScreenContent.kt new file mode 100644 index 0000000000..184b436adf --- /dev/null +++ b/app/src/main/java/yokai/presentation/webview/WebViewScreenContent.kt @@ -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 = 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, + ) + } +} diff --git a/app/src/main/java/yokai/util/Navigation.kt b/app/src/main/java/yokai/util/Navigation.kt index b5531f7e1e..7c0e0f14f7 100644 --- a/app/src/main/java/yokai/util/Navigation.kt +++ b/app/src/main/java/yokai/util/Navigation.kt @@ -8,3 +8,7 @@ abstract class Screen : Screen { override val key: ScreenKey = uniqueScreenKey } + +interface AssistContentScreen { + fun onProvideAssistUrl(): String? +} diff --git a/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/WebViewUtil.kt index bb1d5d4aa0..63f6fec014 100644 --- a/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/WebViewUtil.kt +++ b/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -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) } +} diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index 5decb860fc..02dec18450 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -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" ] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1469060e7a..fa446ba32a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"] diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 6ffaa4db0b..92b218f85f 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -4,6 +4,9 @@ Yōkai Yokai + More options + Navigate up + File permissions required 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.\" TachiyomiJ2K requires access to all files to download chapters. Tap here, then enable \"Allow access to manage all files.\" @@ -1037,9 +1040,14 @@ Failed to bypass Cloudflare + Tap here for help with Cloudflare Please update the WebView app for better compatibility + WebView is required for the app to function WebView is required for Tachiyomi + Back + Forward + Refresh See your recently updated library entries