Remove auto-solve captcha
This commit is contained in:
parent
726626f2c5
commit
0784629cbb
@ -381,10 +381,6 @@
|
||||
<data android:pathPattern="/chapter/..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="exh.ui.captcha.BrowserActionActivity"
|
||||
android:theme="@style/Theme.Tachiyomi"
|
||||
android:exported="false"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import eu.kanade.domain.UnsortedPreferences
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.library.service.LibraryPreferences
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
@ -40,7 +39,6 @@ object SettingsGeneralScreen : SearchableSettings {
|
||||
val libraryPrefs = remember { Injekt.get<LibraryPreferences>() }
|
||||
// SY -->
|
||||
val uiPrefs = remember { Injekt.get<UiPreferences>() }
|
||||
val unsortedPrefs = remember { Injekt.get<UnsortedPreferences>() }
|
||||
// SY <--
|
||||
return mutableListOf<Preference>().apply {
|
||||
add(
|
||||
@ -104,11 +102,6 @@ object SettingsGeneralScreen : SearchableSettings {
|
||||
pref = uiPrefs.expandFilters(),
|
||||
title = stringResource(R.string.toggle_expand_search_filters),
|
||||
),
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
pref = unsortedPrefs.autoSolveCaptcha(),
|
||||
title = stringResource(R.string.auto_solve_captchas),
|
||||
subtitle = stringResource(R.string.auto_solve_captchas_summary),
|
||||
),
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
pref = uiPrefs.recommendsInOverflow(),
|
||||
title = stringResource(R.string.put_recommends_in_overflow),
|
||||
|
@ -1,88 +0,0 @@
|
||||
package exh.patch
|
||||
|
||||
import android.app.Application
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import exh.ui.captcha.BrowserActionActivity
|
||||
import exh.util.interceptAsHtml
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
private val HIDE_SCRIPT =
|
||||
"""
|
||||
document.querySelector("#forgot_button").style.visibility = "hidden";
|
||||
document.querySelector("#signup_button").style.visibility = "hidden";
|
||||
document.querySelector("#announcement").style.visibility = "hidden";
|
||||
document.querySelector("nav").style.visibility = "hidden";
|
||||
document.querySelector("footer").style.visibility = "hidden";
|
||||
""".trimIndent()
|
||||
|
||||
private fun verifyComplete(url: String): Boolean {
|
||||
return url.toHttpUrlOrNull()?.let { parsed ->
|
||||
parsed.host == "mangadex.org" && parsed.pathSegments.none { it.isNotBlank() }
|
||||
} ?: false
|
||||
}
|
||||
|
||||
val MANGADEX_LOGIN_PATCH: EHInterceptor = { request, response, sourceId ->
|
||||
if (request.url.host == MANGADEX_DOMAIN) {
|
||||
response.interceptAsHtml { doc ->
|
||||
if (doc.title().trim().equals("Login - MangaDex", true)) {
|
||||
BrowserActionActivity.launchAction(
|
||||
Injekt.get<Application>(),
|
||||
::verifyComplete,
|
||||
HIDE_SCRIPT,
|
||||
"https://mangadex.org/login",
|
||||
"Login",
|
||||
(Injekt.get<SourceManager>().get(sourceId) as? HttpSource)?.headers?.toMultimap()?.mapValues {
|
||||
it.value.joinToString(",")
|
||||
} ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
val MANGADEX_SOURCE_IDS = listOf(
|
||||
2499283573021220255,
|
||||
8033579885162383068,
|
||||
1952071260038453057,
|
||||
2098905203823335614,
|
||||
5098537545549490547,
|
||||
4505830566611664829,
|
||||
9194073792736219759,
|
||||
6400665728063187402,
|
||||
4938773340256184018,
|
||||
5860541308324630662,
|
||||
5189216366882819742,
|
||||
2655149515337070132,
|
||||
1145824452519314725,
|
||||
3846770256925560569,
|
||||
3807502156582598786,
|
||||
4284949320785450865,
|
||||
5463447640980279236,
|
||||
8578871918181236609,
|
||||
6750440049024086587,
|
||||
3339599426223341161,
|
||||
5148895169070562838,
|
||||
1493666528525752601,
|
||||
1713554459881080228,
|
||||
4150470519566206911,
|
||||
1347402746269051958,
|
||||
3578612018159256808,
|
||||
425785191804166217,
|
||||
8254121249433835847,
|
||||
3260701926561129943,
|
||||
1411768577036936240,
|
||||
3285208643537017688,
|
||||
737986167355114438,
|
||||
1471784905273036181,
|
||||
5967745367608513818,
|
||||
3781216447842245147,
|
||||
4774459486579224459,
|
||||
4710920497926776490,
|
||||
5779037855201976894,
|
||||
)
|
||||
const val MANGADEX_DOMAIN = "mangadex.org"
|
@ -1,43 +0,0 @@
|
||||
package exh.patch
|
||||
|
||||
import eu.kanade.domain.UnsortedPreferences
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
typealias EHInterceptor = (request: Request, response: Response, sourceId: Long) -> Response
|
||||
|
||||
fun OkHttpClient.Builder.injectPatches(sourceIdProducer: () -> Long): OkHttpClient.Builder {
|
||||
return addInterceptor { chain ->
|
||||
val req = chain.request()
|
||||
val response = chain.proceed(req)
|
||||
val sourceId = sourceIdProducer()
|
||||
findAndApplyPatches(sourceId)(req, response, sourceId)
|
||||
}
|
||||
}
|
||||
|
||||
fun findAndApplyPatches(sourceId: Long): EHInterceptor {
|
||||
// TODO make it so captcha doesnt auto open in manga eden while applying universal interceptors
|
||||
return if (Injekt.get<UnsortedPreferences>().autoSolveCaptcha().get()) {
|
||||
(EH_INTERCEPTORS[sourceId].orEmpty() + EH_INTERCEPTORS[EH_UNIVERSAL_INTERCEPTOR].orEmpty()).merge()
|
||||
} else {
|
||||
EH_INTERCEPTORS[sourceId].orEmpty().merge()
|
||||
}
|
||||
}
|
||||
|
||||
fun List<EHInterceptor>.merge(): EHInterceptor {
|
||||
return { request, response, sourceId ->
|
||||
fold(response) { acc, int ->
|
||||
int(request, acc, sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val EH_UNIVERSAL_INTERCEPTOR = -1L
|
||||
private val EH_INTERCEPTORS: Map<Long, List<EHInterceptor>> = mapOf(
|
||||
EH_UNIVERSAL_INTERCEPTOR to listOf(
|
||||
CAPTCHA_DETECTION_PATCH, // Auto captcha detection
|
||||
),
|
||||
)
|
@ -1,25 +0,0 @@
|
||||
package exh.patch
|
||||
|
||||
import android.app.Application
|
||||
import exh.ui.captcha.BrowserActionActivity
|
||||
import exh.util.interceptAsHtml
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
val CAPTCHA_DETECTION_PATCH: EHInterceptor = { request, response, sourceId ->
|
||||
if (!response.isSuccessful) {
|
||||
response.interceptAsHtml { doc ->
|
||||
// Find captcha
|
||||
if (doc.getElementsByClass("g-recaptcha").isNotEmpty() || doc.getElementsByClass("h-captcha").isNotEmpty()) {
|
||||
// Found it, allow the user to solve this thing
|
||||
BrowserActionActivity.launchUniversal(
|
||||
Injekt.get<Application>(),
|
||||
sourceId,
|
||||
request.url.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import org.jsoup.nodes.DataNode
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
class AutoSolvingWebViewClient(
|
||||
activity: BrowserActionActivity,
|
||||
verifyComplete: (String) -> Boolean,
|
||||
injectScript: String?,
|
||||
headers: Map<String, String>,
|
||||
) : HeadersInjectingWebViewClient(activity, verifyComplete, injectScript, headers) {
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
// Inject our custom script into the recaptcha iframes
|
||||
val lastPathSegment = request.url.pathSegments.lastOrNull()
|
||||
if (lastPathSegment == "anchor" || lastPathSegment == "bframe") {
|
||||
val oReq = request.toOkHttpRequest()
|
||||
val response = activity.httpClient.newCall(oReq).execute()
|
||||
val doc = response.asJsoup()
|
||||
doc.body().appendChild(Element("script").appendChild(DataNode(BrowserActionActivity.CROSS_WINDOW_SCRIPT_INNER)))
|
||||
return WebResourceResponse(
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
doc.toString().byteInputStream().buffered(),
|
||||
)
|
||||
}
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
open class BasicWebViewClient(
|
||||
protected val activity: BrowserActionActivity,
|
||||
protected val verifyComplete: (String) -> Boolean,
|
||||
private val injectScript: String?,
|
||||
) : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
super.onPageFinished(view, url)
|
||||
|
||||
if (verifyComplete(url)) {
|
||||
activity.finish()
|
||||
} else {
|
||||
if (injectScript != null) {
|
||||
view.evaluateJavascript("(function() {$injectScript})();", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,774 +0,0 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.MotionEvent
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.JsResult
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import eu.kanade.domain.UnsortedPreferences
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.databinding.EhActivityCaptchaBinding
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
|
||||
import eu.kanade.tachiyomi.util.system.setDefaultSettings
|
||||
import exh.log.xLogD
|
||||
import exh.log.xLogE
|
||||
import exh.source.DelegatedHttpSource
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.Serializable
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
|
||||
class BrowserActionActivity : AppCompatActivity() {
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val preferencesHelper: UnsortedPreferences by injectLazy()
|
||||
private val networkHelper: NetworkHelper by injectLazy()
|
||||
|
||||
val httpClient = networkHelper.client
|
||||
|
||||
private var currentLoopId: String? = null
|
||||
private var validateCurrentLoopId: String? = null
|
||||
private var strictValidationStartTime: Long? = null
|
||||
|
||||
private val credentialsFlow = MutableSharedFlow<String>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private lateinit var binding: EhActivityCaptchaBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = EhActivityCaptchaBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
||||
val sourceId = intent.getLongExtra(SOURCE_ID_EXTRA, -1)
|
||||
val originalSource = if (sourceId != -1L) sourceManager.get(sourceId) else null
|
||||
val source = if (originalSource != null) {
|
||||
originalSource as? ActionCompletionVerifier
|
||||
?: run {
|
||||
(originalSource as? HttpSource)?.let {
|
||||
NoopActionCompletionVerifier(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val headers = (
|
||||
(source as? HttpSource)?.headers?.toMultimap()?.mapValues {
|
||||
it.value.joinToString(",")
|
||||
} ?: emptyMap()
|
||||
) + (intent.getSerializableExtraCompat<HashMap<String, String>>(HEADERS_EXTRA) ?: emptyMap())
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cookies = intent.getSerializableExtraCompat<HashMap<String, String>>(COOKIES_EXTRA)
|
||||
val script: String? = intent.getStringExtra(SCRIPT_EXTRA)
|
||||
val url: String? = intent.getStringExtra(URL_EXTRA)
|
||||
val actionName = intent.getStringExtra(ACTION_NAME_EXTRA)
|
||||
|
||||
@Suppress("NOT_NULL_ASSERTION_ON_CALLABLE_REFERENCE", "UNCHECKED_CAST")
|
||||
val verifyComplete: ((String) -> Boolean)? = if (source != null) {
|
||||
source::verifyComplete!!
|
||||
} else {
|
||||
intent.getSerializableExtraCompat(VERIFY_LAMBDA_EXTRA)
|
||||
}
|
||||
|
||||
if (verifyComplete == null || url == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val actionStr = actionName ?: "Solve captcha"
|
||||
|
||||
binding.toolbar.title = if (source != null) {
|
||||
"${source.name}: $actionStr"
|
||||
} else {
|
||||
actionStr
|
||||
}
|
||||
|
||||
val parsedUrl = URL(url)
|
||||
|
||||
val cm = CookieManager.getInstance()
|
||||
|
||||
cookies?.forEach { (t, u) ->
|
||||
val cookieString = t + "=" + u + "; domain=" + parsedUrl.host
|
||||
cm.setCookie(url, cookieString)
|
||||
}
|
||||
|
||||
binding.webview.setDefaultSettings()
|
||||
headers.entries.find { it.key.equals("user-agent", true) }?.let {
|
||||
binding.webview.settings.userAgentString = it.value
|
||||
}
|
||||
|
||||
var loadedInners = 0
|
||||
|
||||
binding.webview.webChromeClient = object : WebChromeClient() {
|
||||
override fun onJsAlert(view: WebView?, url: String?, message: String, result: JsResult): Boolean {
|
||||
if (message.startsWith("exh-")) {
|
||||
loadedInners++
|
||||
// Wait for both inner scripts to be loaded
|
||||
if (loadedInners >= 2) {
|
||||
// Attempt to autosolve captcha
|
||||
if (preferencesHelper.autoSolveCaptcha().get()) {
|
||||
binding.webview.post {
|
||||
// 10 seconds to auto-solve captcha
|
||||
strictValidationStartTime = System.currentTimeMillis() + 1000 * 10
|
||||
beginSolveLoop()
|
||||
beginValidateCaptchaLoop()
|
||||
binding.webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE) {
|
||||
binding.webview.evaluateJavascript(SOLVE_UI_SCRIPT_SHOW, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.confirm()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
binding.webview.webViewClient = if (actionName == null && preferencesHelper.autoSolveCaptcha().get()) {
|
||||
// Fetch auto-solve credentials early for speed
|
||||
lifecycleScope.launchIO {
|
||||
try {
|
||||
credentialsFlow.emit(
|
||||
httpClient.newCall(
|
||||
Request.Builder()
|
||||
// Rob demo credentials
|
||||
.url("https://speech-to-text-demo.ng.bluemix.net/api/v1/credentials")
|
||||
.build(),
|
||||
).await().parseAs<JsonObject>()["token"]!!.jsonPrimitive.content,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
xLogE("Failed to get credentials", e)
|
||||
}
|
||||
}
|
||||
|
||||
binding.webview.addJavascriptInterface(this@BrowserActionActivity, "exh")
|
||||
AutoSolvingWebViewClient(this, verifyComplete, script, headers)
|
||||
} else {
|
||||
HeadersInjectingWebViewClient(this, verifyComplete, script, headers)
|
||||
}
|
||||
|
||||
binding.webview.loadUrl(url, headers)
|
||||
|
||||
setSupportActionBar(binding.toolbar)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun captchaSolveFail() {
|
||||
currentLoopId = null
|
||||
validateCurrentLoopId = null
|
||||
xLogE("Captcha solve Error", IllegalStateException("Captcha solve failure!"))
|
||||
withUIContext {
|
||||
binding.webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null)
|
||||
MaterialAlertDialogBuilder(this@BrowserActionActivity)
|
||||
.setTitle(R.string.captcha_solve_failure)
|
||||
.setMessage(R.string.captcha_solve_failure_message)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
suspend fun callback(result: String?, loopId: String, stage: Int) {
|
||||
if (loopId != currentLoopId) return
|
||||
|
||||
when (stage) {
|
||||
STAGE_CHECKBOX -> {
|
||||
if (result!!.toBoolean()) {
|
||||
binding.webview.postDelayed(
|
||||
{
|
||||
getAudioButtonLocation(loopId)
|
||||
},
|
||||
250,
|
||||
)
|
||||
} else {
|
||||
binding.webview.postDelayed(
|
||||
{
|
||||
doStageCheckbox(loopId)
|
||||
},
|
||||
250,
|
||||
)
|
||||
}
|
||||
}
|
||||
STAGE_GET_AUDIO_BTN_LOCATION -> {
|
||||
if (result != null) {
|
||||
val splitResult = result.split(" ").map { it.toFloat() }
|
||||
val origX = splitResult[0]
|
||||
val origY = splitResult[1]
|
||||
val iw = splitResult[2]
|
||||
val ih = splitResult[3]
|
||||
val x = binding.webview.x + origX / iw * binding.webview.width
|
||||
val y = binding.webview.y + origY / ih * binding.webview.height
|
||||
xLogD("Found audio button coords: %f %f", x, y)
|
||||
simulateClick(x + 50, y + 50)
|
||||
binding.webview.post {
|
||||
doStageDownloadAudio(loopId)
|
||||
}
|
||||
} else {
|
||||
binding.webview.postDelayed(
|
||||
{
|
||||
getAudioButtonLocation(loopId)
|
||||
},
|
||||
250,
|
||||
)
|
||||
}
|
||||
}
|
||||
STAGE_DOWNLOAD_AUDIO -> {
|
||||
if (result != null) {
|
||||
xLogD("Got audio URL: $result")
|
||||
lifecycleScope.launchIO {
|
||||
try {
|
||||
val transcript = performRecognize(result)
|
||||
xLogD("Got audio transcript: $transcript")
|
||||
binding.webview.post {
|
||||
typeResult(
|
||||
loopId,
|
||||
transcript
|
||||
.replace(TRANSCRIPT_CLEANER_REGEX, "")
|
||||
.replace(SPACE_DEDUPE_REGEX, " ")
|
||||
.trim(),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
captchaSolveFail()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.webview.postDelayed(
|
||||
{
|
||||
doStageDownloadAudio(loopId)
|
||||
},
|
||||
250,
|
||||
)
|
||||
}
|
||||
}
|
||||
STAGE_TYPE_RESULT -> {
|
||||
if (result!!.toBoolean()) {
|
||||
// Fail if captcha still not solved after 1.5s
|
||||
strictValidationStartTime = System.currentTimeMillis() + 1500
|
||||
} else {
|
||||
captchaSolveFail()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performRecognize(url: String): String {
|
||||
val token = credentialsFlow.first()
|
||||
val audioFile = httpClient.newCall(
|
||||
Request.Builder()
|
||||
.url(url)
|
||||
.build(),
|
||||
).await().body.bytes()
|
||||
val response = httpClient.newCall(
|
||||
POST(
|
||||
"https://stream.watsonplatform.net/speech-to-text/api/v1/recognize".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addQueryParameter("watson-token", token)
|
||||
.build()
|
||||
.toString(),
|
||||
body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("jsonDescription", RECOGNIZE_JSON)
|
||||
.addFormDataPart(
|
||||
"audio.mp3",
|
||||
"audio.mp3",
|
||||
audioFile.toRequestBody(
|
||||
"audio/mp3".toMediaTypeOrNull(),
|
||||
0,
|
||||
audioFile.size,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
),
|
||||
).await()
|
||||
return response.parseAs<JsonObject>()["results"]!!
|
||||
.jsonArray[0]
|
||||
.jsonObject["alternatives"]!!
|
||||
.jsonArray[0]
|
||||
.jsonObject["transcript"]!!
|
||||
.jsonPrimitive
|
||||
.content
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun doStageCheckbox(loopId: String) {
|
||||
if (loopId != currentLoopId) return
|
||||
|
||||
binding.webview.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_cframe = document.querySelector('iframe[role=presentation][name|=a]');
|
||||
|
||||
if(exh_cframe != null) {
|
||||
cwmExec(exh_cframe, `
|
||||
let exh_cb = document.getElementsByClassName('recaptcha-checkbox-checkmark')[0];
|
||||
if(exh_cb != null) {
|
||||
exh_cb.click();
|
||||
return "true";
|
||||
} else {
|
||||
return "false";
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_CHECKBOX);
|
||||
});
|
||||
} else {
|
||||
exh.callback("false", '$loopId', $STAGE_CHECKBOX);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getAudioButtonLocation(loopId: String) {
|
||||
binding.webview.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
let bfb = exh_bframe.getBoundingClientRect();
|
||||
let iw = window.innerWidth;
|
||||
let ih = window.innerHeight;
|
||||
if(bfb.left < 0 || bfb.top < 0) {
|
||||
exh.callback(null, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
} else {
|
||||
cwmExec(exh_bframe, ` let exh_ab = document.getElementById("recaptcha-audio-button");
|
||||
if(exh_ab != null) {
|
||||
let bounds = exh_ab.getBoundingClientRect();
|
||||
return (${'$'}{bfb.left} + bounds.left) + " " + (${'$'}{bfb.top} + bounds.top) + " " + ${'$'}{iw} + " " + ${'$'}{ih};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
exh.callback(null, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun doStageDownloadAudio(loopId: String) {
|
||||
binding.webview.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
cwmExec(exh_bframe, `
|
||||
let exh_as = document.getElementById("audio-source");
|
||||
if(exh_as != null) {
|
||||
return exh_as.src;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_DOWNLOAD_AUDIO);
|
||||
});
|
||||
} else {
|
||||
exh.callback(null, '$loopId', $STAGE_DOWNLOAD_AUDIO);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun typeResult(loopId: String, result: String) {
|
||||
binding.webview.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
cwmExec(exh_bframe, `
|
||||
let exh_as = document.getElementById("audio-response");
|
||||
let exh_vb = document.getElementById("recaptcha-verify-button");
|
||||
if(exh_as != null && exh_vb != null) {
|
||||
exh_as.value = "$result";
|
||||
exh_vb.click();
|
||||
return "true";
|
||||
} else {
|
||||
return "false";
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_TYPE_RESULT);
|
||||
});
|
||||
} else {
|
||||
exh.callback("false", '$loopId', $STAGE_TYPE_RESULT);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
fun beginSolveLoop() {
|
||||
val loopId = UUID.randomUUID().toString()
|
||||
currentLoopId = loopId
|
||||
doStageCheckbox(loopId)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
suspend fun validateCaptchaCallback(result: Boolean, loopId: String) {
|
||||
if (loopId != validateCurrentLoopId) return
|
||||
|
||||
if (result) {
|
||||
xLogD("Captcha solved!")
|
||||
binding.webview.post {
|
||||
binding.webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null)
|
||||
}
|
||||
val asbtn = intent.getStringExtra(ASBTN_EXTRA)
|
||||
if (asbtn != null) {
|
||||
binding.webview.post {
|
||||
binding.webview.evaluateJavascript("(function() {document.querySelector('$asbtn').click();})();", null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val savedStrictValidationStartTime = strictValidationStartTime
|
||||
if (savedStrictValidationStartTime != null &&
|
||||
System.currentTimeMillis() > savedStrictValidationStartTime
|
||||
) {
|
||||
captchaSolveFail()
|
||||
} else {
|
||||
binding.webview.postDelayed(
|
||||
{
|
||||
runValidateCaptcha(loopId)
|
||||
},
|
||||
250,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runValidateCaptcha(loopId: String) {
|
||||
if (loopId != validateCurrentLoopId) return
|
||||
|
||||
binding.webview.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_cframe = document.querySelector('iframe[role=presentation][name|=a]');
|
||||
|
||||
if(exh_cframe != null) {
|
||||
cwmExec(exh_cframe, `
|
||||
let exh_cb = document.querySelector(".recaptcha-checkbox[aria-checked=true]");
|
||||
if(exh_cb != null) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.validateCaptchaCallback(result, '$loopId');
|
||||
});
|
||||
} else {
|
||||
exh.validateCaptchaCallback(false, '$loopId');
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
fun beginValidateCaptchaLoop() {
|
||||
val loopId = UUID.randomUUID().toString()
|
||||
validateCurrentLoopId = loopId
|
||||
runValidateCaptcha(loopId)
|
||||
}
|
||||
|
||||
private fun simulateClick(x: Float, y: Float) {
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
val eventTime = SystemClock.uptimeMillis()
|
||||
val properties = arrayOfNulls<MotionEvent.PointerProperties>(1)
|
||||
val pp1 = MotionEvent.PointerProperties().apply {
|
||||
id = 0
|
||||
toolType = MotionEvent.TOOL_TYPE_FINGER
|
||||
}
|
||||
properties[0] = pp1
|
||||
val pointerCoords = arrayOfNulls<MotionEvent.PointerCoords>(1)
|
||||
val pc1 = MotionEvent.PointerCoords().apply {
|
||||
this.x = x
|
||||
this.y = y
|
||||
pressure = 1f
|
||||
size = 1f
|
||||
}
|
||||
pointerCoords[0] = pc1
|
||||
var motionEvent = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, 1, properties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
dispatchTouchEvent(motionEvent)
|
||||
motionEvent.recycle()
|
||||
motionEvent = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, 1, properties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
dispatchTouchEvent(motionEvent)
|
||||
motionEvent.recycle()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VERIFY_LAMBDA_EXTRA = "verify_lambda_extra"
|
||||
const val SOURCE_ID_EXTRA = "source_id_extra"
|
||||
const val COOKIES_EXTRA = "cookies_extra"
|
||||
const val SCRIPT_EXTRA = "script_extra"
|
||||
const val URL_EXTRA = "url_extra"
|
||||
const val ASBTN_EXTRA = "asbtn_extra"
|
||||
const val ACTION_NAME_EXTRA = "action_name_extra"
|
||||
const val HEADERS_EXTRA = "headers_extra"
|
||||
|
||||
const val STAGE_CHECKBOX = 0
|
||||
const val STAGE_GET_AUDIO_BTN_LOCATION = 1
|
||||
const val STAGE_DOWNLOAD_AUDIO = 2
|
||||
const val STAGE_TYPE_RESULT = 3
|
||||
|
||||
val CROSS_WINDOW_SCRIPT_OUTER =
|
||||
"""
|
||||
function cwmExec(element, code, cb) {
|
||||
console.log(">>> [CWM-Outer] Running: " + code);
|
||||
let runId = Math.random();
|
||||
if(cb != null) {
|
||||
let listener;
|
||||
listener = function(event) {
|
||||
if(typeof event.data === "string" && event.data.startsWith("exh-")) {
|
||||
let response = JSON.parse(event.data.substring(4));
|
||||
if(response.id === runId) {
|
||||
cb(response.result);
|
||||
window.removeEventListener('message', listener);
|
||||
console.log(">>> [CWM-Outer] Finished: " + response.id + " ==> " + response.result);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', listener, false);
|
||||
}
|
||||
let runRequest = { id: runId, code: code };
|
||||
element.contentWindow.postMessage("exh-" + JSON.stringify(runRequest), "*");
|
||||
}
|
||||
""".trimIndent().replace("\n", "")
|
||||
|
||||
val CROSS_WINDOW_SCRIPT_INNER =
|
||||
"""
|
||||
window.addEventListener('message', function(event) {
|
||||
if(typeof event.data === "string" && event.data.startsWith("exh-")) {
|
||||
let request = JSON.parse(event.data.substring(4));
|
||||
console.log(">>> [CWM-Inner] Incoming: " + request.id);
|
||||
let result = eval("(function() {" + request.code + "})();");
|
||||
let response = { id: request.id, result: result };
|
||||
console.log(">>> [CWM-Inner] Outgoing: " + response.id + " ==> " + response.result);
|
||||
event.source.postMessage("exh-" + JSON.stringify(response), event.origin);
|
||||
}
|
||||
}, false);
|
||||
console.log(">>> [CWM-Inner] Loaded!");
|
||||
alert("exh-");
|
||||
""".trimIndent()
|
||||
|
||||
val SOLVE_UI_SCRIPT_SHOW =
|
||||
"""
|
||||
(function() {
|
||||
let exh_overlay = document.createElement("div");
|
||||
exh_overlay.id = "exh_overlay";
|
||||
exh_overlay.style.zIndex = 2000000001;
|
||||
exh_overlay.style.backgroundColor = "rgba(0, 0, 0, 0.8)";
|
||||
exh_overlay.style.position = "fixed";
|
||||
exh_overlay.style.top = 0;
|
||||
exh_overlay.style.left = 0;
|
||||
exh_overlay.style.width = "100%";
|
||||
exh_overlay.style.height = "100%";
|
||||
exh_overlay.style.pointerEvents = "none";
|
||||
document.body.appendChild(exh_overlay);
|
||||
let exh_otext = document.createElement("div");
|
||||
exh_otext.id = "exh_otext";
|
||||
exh_otext.style.zIndex = 2000000002;
|
||||
exh_otext.style.position = "fixed";
|
||||
exh_otext.style.top = "50%";
|
||||
exh_otext.style.left = 0;
|
||||
exh_otext.style.transform = "translateY(-50%)";
|
||||
exh_otext.style.color = "white";
|
||||
exh_otext.style.fontSize = "25pt";
|
||||
exh_otext.style.pointerEvents = "none";
|
||||
exh_otext.style.width = "100%";
|
||||
exh_otext.style.textAlign = "center";
|
||||
exh_otext.textContent = "Solving captcha..."
|
||||
document.body.appendChild(exh_otext);
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
val SOLVE_UI_SCRIPT_HIDE =
|
||||
"""
|
||||
(function() {
|
||||
let exh_overlay = document.getElementById("exh_overlay");
|
||||
let exh_otext = document.getElementById("exh_otext");
|
||||
if(exh_overlay != null) exh_overlay.remove();
|
||||
if(exh_otext != null) exh_otext.remove();
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
val RECOGNIZE_JSON =
|
||||
"""
|
||||
{
|
||||
"part_content_type": "audio/mp3",
|
||||
"keywords": [],
|
||||
"profanity_filter": false,
|
||||
"max_alternatives": 1,
|
||||
"speaker_labels": false,
|
||||
"firstReadyInSession": false,
|
||||
"preserveAdaptation": false,
|
||||
"timestamps": false,
|
||||
"inactivity_timeout": 30,
|
||||
"word_confidence": false,
|
||||
"audioMetrics": false,
|
||||
"latticeGeneration": true,
|
||||
"customGrammarWords": [],
|
||||
"action": "recognize"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val TRANSCRIPT_CLEANER_REGEX = Regex("[^0-9a-zA-Z_ -]")
|
||||
val SPACE_DEDUPE_REGEX = Regex(" +")
|
||||
|
||||
private fun baseIntent(context: Context) =
|
||||
Intent(context, BrowserActionActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
fun launchCaptcha(
|
||||
context: Context,
|
||||
source: ActionCompletionVerifier,
|
||||
cookies: Map<String, String>,
|
||||
script: String?,
|
||||
url: String,
|
||||
autoSolveSubmitBtnSelector: String? = null,
|
||||
) {
|
||||
val intent = baseIntent(context).apply {
|
||||
putExtra(SOURCE_ID_EXTRA, source.id)
|
||||
putExtra(COOKIES_EXTRA, HashMap(cookies))
|
||||
putExtra(SCRIPT_EXTRA, script)
|
||||
putExtra(URL_EXTRA, url)
|
||||
putExtra(ASBTN_EXTRA, autoSolveSubmitBtnSelector)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun launchUniversal(
|
||||
context: Context,
|
||||
source: HttpSource,
|
||||
url: String,
|
||||
) {
|
||||
val intent = baseIntent(context).apply {
|
||||
putExtra(SOURCE_ID_EXTRA, source.id)
|
||||
putExtra(URL_EXTRA, url)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun launchUniversal(
|
||||
context: Context,
|
||||
sourceId: Long,
|
||||
url: String,
|
||||
) {
|
||||
val intent = baseIntent(context).apply {
|
||||
putExtra(SOURCE_ID_EXTRA, sourceId)
|
||||
putExtra(URL_EXTRA, url)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun launchAction(
|
||||
context: Context,
|
||||
completionVerifier: ActionCompletionVerifier,
|
||||
script: String?,
|
||||
url: String,
|
||||
actionName: String,
|
||||
) {
|
||||
val intent = baseIntent(context).apply {
|
||||
putExtra(SOURCE_ID_EXTRA, completionVerifier.id)
|
||||
putExtra(SCRIPT_EXTRA, script)
|
||||
putExtra(URL_EXTRA, url)
|
||||
putExtra(ACTION_NAME_EXTRA, actionName)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun launchAction(
|
||||
context: Context,
|
||||
completionVerifier: (String) -> Boolean,
|
||||
script: String?,
|
||||
url: String,
|
||||
actionName: String,
|
||||
headers: Map<String, String>? = emptyMap(),
|
||||
) {
|
||||
val intent = baseIntent(context).apply {
|
||||
putExtra(HEADERS_EXTRA, HashMap(headers!!))
|
||||
putExtra(VERIFY_LAMBDA_EXTRA, completionVerifier as Serializable)
|
||||
putExtra(SCRIPT_EXTRA, script)
|
||||
putExtra(URL_EXTRA, url)
|
||||
putExtra(ACTION_NAME_EXTRA, actionName)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NoopActionCompletionVerifier(private val source: HttpSource) :
|
||||
DelegatedHttpSource(source),
|
||||
ActionCompletionVerifier {
|
||||
override val versionId get() = source.versionId
|
||||
override val lang: String get() = source.lang
|
||||
|
||||
override fun verifyComplete(url: String) = false
|
||||
}
|
||||
|
||||
interface ActionCompletionVerifier : Source {
|
||||
fun verifyComplete(url: String): Boolean
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
|
||||
open class HeadersInjectingWebViewClient(
|
||||
activity: BrowserActionActivity,
|
||||
verifyComplete: (String) -> Boolean,
|
||||
injectScript: String?,
|
||||
private val headers: Map<String, String>,
|
||||
) : BasicWebViewClient(activity, verifyComplete, injectScript) {
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
// Temp disabled as it's unreliable
|
||||
/*if(headers.isNotEmpty()) {
|
||||
val response = activity.httpClient.newCall(request.toOkHttpRequest()
|
||||
.newBuilder()
|
||||
.apply {
|
||||
headers.forEach { (n, v) -> header(n, v) }
|
||||
}
|
||||
.build())
|
||||
.execute()
|
||||
|
||||
return WebResourceResponse(
|
||||
response.body()?.contentType()?.let { "${it.type()}/${it.subtype()}" },
|
||||
response.body()?.contentType()?.charset()?.toString(),
|
||||
response.code(),
|
||||
response.message().nullIfBlank() ?: FALLBACK_REASON_PHRASES[response.code()] ?: "Unknown status",
|
||||
response.headers().toMultimap().mapValues { it.value.joinToString(",") },
|
||||
response.body()?.byteStream()
|
||||
)
|
||||
}*/
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FALLBACK_REASON_PHRASES = mapOf(
|
||||
100 to "Continue",
|
||||
101 to "Switching Protocols",
|
||||
200 to "OK",
|
||||
201 to "Created",
|
||||
202 to "Accepted",
|
||||
203 to "Non-Authoritative Information",
|
||||
204 to "No Content",
|
||||
205 to "Reset Content",
|
||||
206 to "Partial Content",
|
||||
300 to "Multiple Choices",
|
||||
301 to "Moved Permanently",
|
||||
302 to "Moved Temporarily",
|
||||
303 to "See Other",
|
||||
304 to "Not Modified",
|
||||
305 to "Use Proxy",
|
||||
400 to "Bad Request",
|
||||
401 to "Unauthorized",
|
||||
402 to "Payment Required",
|
||||
403 to "Forbidden",
|
||||
404 to "Not Found",
|
||||
405 to "Method Not Allowed",
|
||||
406 to "Not Acceptable",
|
||||
407 to "Proxy Authentication Required",
|
||||
408 to "Request Time-out",
|
||||
409 to "Conflict",
|
||||
410 to "Gone",
|
||||
411 to "Length Required",
|
||||
412 to "Precondition Failed",
|
||||
413 to "Request Entity Too Large",
|
||||
414 to "Request-URI Too Large",
|
||||
415 to "Unsupported Media Type",
|
||||
500 to "Internal Server Error",
|
||||
501 to "Not Implemented",
|
||||
502 to "Bad Gateway",
|
||||
503 to "Service Unavailable",
|
||||
504 to "Gateway Time-out",
|
||||
505 to "HTTP Version not supported",
|
||||
)
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.webkit.WebResourceRequest
|
||||
import okhttp3.Request
|
||||
|
||||
fun WebResourceRequest.toOkHttpRequest(): Request {
|
||||
val request = Request.Builder()
|
||||
.url(url.toString())
|
||||
.method(method, null)
|
||||
|
||||
requestHeaders.entries.forEach { (t, u) ->
|
||||
request.addHeader(t, u)
|
||||
}
|
||||
|
||||
return request.build()
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.TachiyomiAppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentTop="true"
|
||||
app:elevation="0dp">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:theme="?attr/actionBarTheme" />
|
||||
|
||||
</com.google.android.material.appbar.TachiyomiAppBarLayout>
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_below="@+id/appbar"
|
||||
android:layout_centerHorizontal="true" />
|
||||
</RelativeLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
Loading…
x
Reference in New Issue
Block a user