Migrate WebViewActivity to Compose

This commit is contained in:
arkon 2022-04-24 10:22:22 -04:00
parent 6e95fde4ec
commit 558b18899c
10 changed files with 291 additions and 258 deletions

View file

@ -139,12 +139,15 @@ android {
}
dependencies {
// Compose
implementation(compose.activity)
implementation(compose.foundation)
implementation(compose.material3.core)
implementation(compose.material3.adapter)
implementation(compose.material.icons)
implementation(compose.animation)
implementation(compose.ui.tooling)
implementation(compose.accompanist.webview)
implementation(androidx.paging.runtime)
implementation(androidx.paging.compose)
@ -154,7 +157,6 @@ dependencies {
implementation(libs.sqldelight.android.paging)
implementation(kotlinx.reflect)
implementation(kotlinx.bundles.coroutines)
// Source models and interfaces from Tachiyomi 1.x

View file

@ -0,0 +1,103 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.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.MaterialTheme
import androidx.compose.material3.Text
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import eu.kanade.tachiyomi.R
@Composable
fun AppBarTitle(
title: String?,
subtitle: String? = null,
) {
val subtitleTextStyle = MaterialTheme.typography.bodyMedium
Column {
title?.let {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
subtitle?.let {
Text(
text = it,
style = subtitleTextStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun AppBarActions(
actions: List<AppBar.AppBarAction>,
) {
var showMenu by remember { mutableStateOf(false) }
actions.filterIsInstance<AppBar.Action>().map {
IconButton(
onClick = it.onClick,
enabled = it.isEnabled,
) {
Icon(
imageVector = it.icon,
contentDescription = it.title,
)
}
}
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
if (overflowActions.isNotEmpty()) {
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.label_more))
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
overflowActions.map {
DropdownMenuItem(
onClick = {
it.onClick()
showMenu = false
},
text = { Text(it.title) },
)
}
}
}
}
object AppBar {
interface AppBarAction
data class Action(
val title: String,
val icon: ImageVector,
val onClick: () -> Unit,
val isEnabled: Boolean = true,
) : AppBarAction
data class OverflowAction(
val title: String,
val onClick: () -> Unit,
) : AppBarAction
}

View file

@ -0,0 +1,152 @@
package eu.kanade.presentation.webview
import android.content.pm.ApplicationInfo
import android.webkit.WebResourceRequest
import android.webkit.WebView
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.google.accompanist.web.AccompanistWebViewClient
import com.google.accompanist.web.LoadingState
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewNavigator
import com.google.accompanist.web.rememberWebViewState
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.AppBarTitle
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.setDefaultSettings
@Composable
fun WebViewScreen(
onUp: () -> Unit,
initialTitle: String?,
url: String,
headers: Map<String, String> = emptyMap(),
onShare: (String) -> Unit,
onOpenInBrowser: (String) -> Unit,
onClearCookies: (String) -> Unit,
) {
val context = LocalContext.current
val state = rememberWebViewState(url = url)
val navigator = rememberWebViewNavigator()
Column {
SmallTopAppBar(
title = {
AppBarTitle(
title = state.pageTitle ?: initialTitle,
subtitle = state.content.getCurrentUrl(),
)
},
navigationIcon = {
IconButton(onClick = onUp) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.action_close),
)
}
},
actions = {
AppBarActions(
listOf(
AppBar.Action(
title = stringResource(R.string.action_webview_back),
icon = Icons.Default.ArrowBack,
onClick = {
if (navigator.canGoBack) {
navigator.navigateBack()
}
},
isEnabled = navigator.canGoBack,
),
AppBar.Action(
title = stringResource(R.string.action_webview_forward),
icon = Icons.Default.ArrowForward,
onClick = {
if (navigator.canGoForward) {
navigator.navigateForward()
}
},
isEnabled = navigator.canGoForward,
),
AppBar.OverflowAction(
title = stringResource(R.string.action_webview_refresh),
onClick = { navigator.reload() },
),
AppBar.OverflowAction(
title = stringResource(R.string.action_share),
onClick = { onShare(state.content.getCurrentUrl()!!) },
),
AppBar.OverflowAction(
title = stringResource(R.string.action_open_in_browser),
onClick = { onOpenInBrowser(state.content.getCurrentUrl()!!) },
),
AppBar.OverflowAction(
title = stringResource(R.string.pref_clear_cookies),
onClick = { onClearCookies(state.content.getCurrentUrl()!!) },
),
),
)
},
)
Box(modifier = Modifier.weight(1f)) {
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = loadingState.progress,
modifier = Modifier.fillMaxWidth(),
)
}
val webClient = remember {
object : AccompanistWebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
request?.let {
view?.loadUrl(it.url.toString(), headers)
}
return super.shouldOverrideUrlLoading(view, request)
}
}
}
WebView(
state = state,
modifier = Modifier.fillMaxSize(),
navigator = navigator,
onCreated = { webView ->
webView.setDefaultSettings()
// Debug mode (chrome://inspect/#devices)
if (BuildConfig.DEBUG && 0 != context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) {
WebView.setWebContentsDebuggingEnabled(true)
}
headers["User-Agent"]?.let {
webView.settings.userAgentString = it
}
},
client = webClient,
)
}
}
}

View file

@ -2,50 +2,28 @@ package eu.kanade.tachiyomi.ui.webview
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.graphics.Bitmap
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.widget.Toast
import androidx.core.graphics.ColorUtils
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.BuildConfig
import androidx.activity.compose.setContent
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.presentation.webview.WebViewScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.WebviewActivityBinding
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okhttp3.HttpUrl.Companion.toHttpUrl
import reactivecircus.flowbinding.appcompat.navigationClicks
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import uy.kohesive.injekt.injectLazy
class WebViewActivity : BaseActivity() {
private lateinit var binding: WebviewActivityBinding
private val sourceManager: SourceManager by injectLazy()
private val network: NetworkHelper by injectLazy()
private var bundle: Bundle? = null
private var isRefreshing: Boolean = false
init {
registerSecureActivity(this)
}
@ -59,152 +37,33 @@ class WebViewActivity : BaseActivity() {
return
}
try {
binding = WebviewActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
} catch (e: Throwable) {
// Potentially throws errors like "Error inflating class android.webkit.WebView"
toast(R.string.information_webview_required, Toast.LENGTH_LONG)
finish()
return
val url = intent.extras!!.getString(URL_KEY) ?: return
var headers = mutableMapOf<String, String>()
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
if (source != null) {
headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap()
}
title = intent.extras?.getString(TITLE_KEY)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.toolbar.navigationClicks()
.onEach { super.onBackPressed() }
.launchIn(lifecycleScope)
binding.swipeRefresh.isEnabled = false
binding.swipeRefresh.refreshes()
.onEach { refreshPage() }
.launchIn(lifecycleScope)
if (bundle == null) {
binding.webview.setDefaultSettings()
// Debug mode (chrome://inspect/#devices)
if (BuildConfig.DEBUG && 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) {
WebView.setWebContentsDebuggingEnabled(true)
setContent {
TachiyomiTheme {
WebViewScreen(
onUp = { finish() },
initialTitle = intent.extras?.getString(TITLE_KEY),
url = url,
headers = headers,
onShare = this::shareWebpage,
onOpenInBrowser = this::openInBrowser,
onClearCookies = this::clearCookies,
)
}
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
}
super.onProgressChanged(view, newProgress)
}
}
} else {
binding.webview.restoreState(bundle!!)
}
if (bundle == null) {
val url = intent.extras!!.getString(URL_KEY) ?: return
var headers = mutableMapOf<String, String>()
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
if (source != null) {
headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap()
binding.webview.settings.userAgentString = source.headers["User-Agent"]
}
supportActionBar?.subtitle = url
binding.webview.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
view.loadUrl(url, headers)
return true
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
invalidateOptionsMenu()
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
invalidateOptionsMenu()
title = view?.title
supportActionBar?.subtitle = url
binding.swipeRefresh.isEnabled = true
binding.swipeRefresh.isRefreshing = false
// Reset to top when page refreshes
if (isRefreshing) {
view?.scrollTo(0, 0)
isRefreshing = false
}
}
}
binding.webview.loadUrl(url, headers)
}
}
@Suppress("UNNECESSARY_SAFE_CALL")
override fun onDestroy() {
super.onDestroy()
// Binding sometimes isn't actually instantiated yet somehow
binding?.webview?.destroy()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.webview, menu)
return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val iconTintColor = getResourceColor(R.attr.colorOnSurface)
val translucentIconTintColor = ColorUtils.setAlphaComponent(iconTintColor, 127)
menu.findItem(R.id.action_web_back).apply {
isEnabled = binding.webview.canGoBack()
icon.setTint(if (binding.webview.canGoBack()) iconTintColor else translucentIconTintColor)
}
menu.findItem(R.id.action_web_forward).apply {
isEnabled = binding.webview.canGoForward()
icon.setTint(if (binding.webview.canGoForward()) iconTintColor else translucentIconTintColor)
}
return super.onPrepareOptionsMenu(menu)
}
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_refresh -> refreshPage()
R.id.action_web_share -> shareWebpage()
R.id.action_web_browser -> openInBrowser()
R.id.action_clear_cookies -> clearCookies()
}
return super.onOptionsItemSelected(item)
}
override fun onBackPressed() {
if (binding.webview.canGoBack()) binding.webview.goBack()
else super.onBackPressed()
}
private fun refreshPage() {
binding.swipeRefresh.isRefreshing = true
binding.webview.reload()
isRefreshing = true
}
private fun shareWebpage() {
private fun shareWebpage(url: String) {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, binding.webview.url)
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(Intent.createChooser(intent, getString(R.string.action_share)))
} catch (e: Exception) {
@ -212,12 +71,11 @@ class WebViewActivity : BaseActivity() {
}
}
private fun openInBrowser() {
openInBrowser(binding.webview.url!!, forceDefaultBrowser = true)
private fun openInBrowser(url: String) {
openInBrowser(url, forceDefaultBrowser = true)
}
private fun clearCookies() {
val url = binding.webview.url!!
private fun clearCookies(url: String) {
val cleared = network.cookieManager.remove(url.toHttpUrl())
logcat { "Cleared $cleared cookies for: $url" }
}

View file

@ -11,7 +11,7 @@ import logcat.LogPriority
object WebViewUtil {
const val SPOOF_PACKAGE_NAME = "org.chromium.chrome"
const val MINIMUM_WEBVIEW_VERSION = 95
const val MINIMUM_WEBVIEW_VERSION = 98
fun supportsWebView(context: Context): Boolean {
try {

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View file

@ -1,40 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_close_24dp" />
</com.google.android.material.appbar.AppBarLayout>
<eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
android:id="@id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</WebView>
</eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
</LinearLayout>

View file

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_web_back"
android:icon="@drawable/ic_arrow_back_24dp"
android:title="@string/action_webview_back"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_web_forward"
android:icon="@drawable/ic_arrow_forward_24dp"
android:title="@string/action_webview_forward"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_web_refresh"
android:title="@string/action_webview_refresh"
app:showAsAction="never" />
<item
android:id="@+id/action_web_share"
android:title="@string/action_share"
app:showAsAction="never" />
<item
android:id="@+id/action_web_browser"
android:title="@string/action_open_in_browser"
app:showAsAction="never" />
<item
android:id="@+id/action_clear_cookies"
android:title="@string/pref_clear_cookies"
app:showAsAction="never" />
</menu>

View file

@ -121,6 +121,7 @@
<string name="action_save">Save</string>
<string name="action_reset">Reset</string>
<string name="action_undo">Undo</string>
<string name="action_close">Close</string>
<string name="action_open_log">Open log</string>
<string name="action_show_errors">Tap to see details</string>
<string name="action_create">Create</string>

View file

@ -1,10 +1,15 @@
[versions]
compose = "1.2.0-alpha07"
accompanist = "0.24.6-alpha"
[libraries]
activity = "androidx.activity:activity-compose:1.6.0-alpha01"
foundation = { module = "androidx.compose.foundation:foundation", version.ref="compose" }
animation = { module = "androidx.compose.animation:animation", version.ref="compose" }
ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref="compose" }
material3-core = "androidx.compose.material3:material3:1.0.0-alpha09"
material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.6"
material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref="compose" }
animation = { module = "androidx.compose.animation:animation", version.ref="compose" }
ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref="compose" }
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref="accompanist" }