Removed other sources
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/gradlew linguist-generated
|
||||
/gradlew.bat linguist-generated
|
||||
|
||||
* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
4
.github/always_build.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"ko.newtoki",
|
||||
"ko.wolfdotcom"
|
||||
]
|
||||
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
88
.github/scripts/create-repo.py
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
PACKAGE_NAME_REGEX = re.compile(r"package: name='([^']+)'")
|
||||
VERSION_CODE_REGEX = re.compile(r"versionCode='([^']+)'")
|
||||
VERSION_NAME_REGEX = re.compile(r"versionName='([^']+)'")
|
||||
IS_NSFW_REGEX = re.compile(r"'tachiyomi.extension.nsfw' value='([^']+)'")
|
||||
APPLICATION_LABEL_REGEX = re.compile(r"^application-label:'([^']+)'", re.MULTILINE)
|
||||
APPLICATION_ICON_320_REGEX = re.compile(r"^application-icon-320:'([^']+)'", re.MULTILINE)
|
||||
LANGUAGE_REGEX = re.compile(r"tachiyomi-([^.]+)")
|
||||
|
||||
*_, ANDROID_BUILD_TOOLS = (Path(os.environ["ANDROID_HOME"]) / "build-tools").iterdir()
|
||||
REPO_DIR = Path("repo")
|
||||
REPO_APK_DIR = REPO_DIR / "apk"
|
||||
REPO_ICON_DIR = REPO_DIR / "icon"
|
||||
|
||||
REPO_ICON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open("output.json", encoding="utf-8") as f:
|
||||
inspector_data = json.load(f)
|
||||
|
||||
index_min_data = []
|
||||
|
||||
for apk in REPO_APK_DIR.iterdir():
|
||||
badging = subprocess.check_output(
|
||||
[
|
||||
ANDROID_BUILD_TOOLS / "aapt",
|
||||
"dump",
|
||||
"--include-meta-data",
|
||||
"badging",
|
||||
apk,
|
||||
]
|
||||
).decode()
|
||||
|
||||
package_info = next(x for x in badging.splitlines() if x.startswith("package: "))
|
||||
package_name = PACKAGE_NAME_REGEX.search(package_info).group(1)
|
||||
application_icon = APPLICATION_ICON_320_REGEX.search(badging).group(1)
|
||||
|
||||
with ZipFile(apk) as z, z.open(application_icon) as i, (
|
||||
REPO_ICON_DIR / f"{package_name}.png"
|
||||
).open("wb") as f:
|
||||
f.write(i.read())
|
||||
|
||||
language = LANGUAGE_REGEX.search(apk.name).group(1)
|
||||
sources = inspector_data[package_name]
|
||||
|
||||
if len(sources) == 1:
|
||||
source_language = sources[0]["lang"]
|
||||
|
||||
if (
|
||||
source_language != language
|
||||
and source_language not in {"all", "other"}
|
||||
and language not in {"all", "other"}
|
||||
):
|
||||
language = source_language
|
||||
|
||||
common_data = {
|
||||
"name": APPLICATION_LABEL_REGEX.search(badging).group(1),
|
||||
"pkg": package_name,
|
||||
"apk": apk.name,
|
||||
"lang": language,
|
||||
"code": int(VERSION_CODE_REGEX.search(package_info).group(1)),
|
||||
"version": VERSION_NAME_REGEX.search(package_info).group(1),
|
||||
"nsfw": int(IS_NSFW_REGEX.search(badging).group(1)),
|
||||
}
|
||||
min_data = {
|
||||
**common_data,
|
||||
"sources": [],
|
||||
}
|
||||
|
||||
for source in sources:
|
||||
min_data["sources"].append(
|
||||
{
|
||||
"name": source["name"],
|
||||
"lang": source["lang"],
|
||||
"id": source["id"],
|
||||
"baseUrl": source["baseUrl"],
|
||||
}
|
||||
)
|
||||
|
||||
index_min_data.append(min_data)
|
||||
|
||||
with REPO_DIR.joinpath("index.min.json").open("w", encoding="utf-8") as index_file:
|
||||
json.dump(index_min_data, index_file, ensure_ascii=False, separators=(",", ":"))
|
||||
113
.github/scripts/generate-build-matrices.py
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
EXTENSION_REGEX = re.compile(r"^src/(?P<lang>\w+)/(?P<extension>\w+)")
|
||||
MULTISRC_LIB_REGEX = re.compile(r"^lib-multisrc/(?P<multisrc>\w+)")
|
||||
LIB_REGEX = re.compile(r"^lib/(?P<lib>\w+)")
|
||||
MODULE_REGEX = re.compile(r"^:src:(?P<lang>\w+):(?P<extension>\w+)$")
|
||||
CORE_FILES_REGEX = re.compile(
|
||||
r"^(buildSrc/|core/|gradle/|build\.gradle\.kts|common\.gradle|gradle\.properties|settings\.gradle\.kts|utils/)"
|
||||
)
|
||||
|
||||
def run_command(command: str) -> str:
|
||||
result = subprocess.run(command, capture_output=True, text=True, shell=True)
|
||||
if result.returncode != 0:
|
||||
print(result.stderr.strip())
|
||||
sys.exit(result.returncode)
|
||||
return result.stdout.strip()
|
||||
|
||||
def get_module_list(ref: str) -> tuple[list[str], list[str]]:
|
||||
changed_files = run_command(f"git diff --name-only {ref}").splitlines()
|
||||
|
||||
modules = set()
|
||||
libs = set()
|
||||
deleted = set()
|
||||
core_files_changed = False
|
||||
|
||||
for file in map(lambda x: Path(x).as_posix(), changed_files):
|
||||
if CORE_FILES_REGEX.search(file):
|
||||
core_files_changed = True
|
||||
elif match := EXTENSION_REGEX.search(file):
|
||||
lang = match.group("lang")
|
||||
extension = match.group("extension")
|
||||
if Path("src", lang, extension).is_dir():
|
||||
modules.add(f':src:{lang}:{extension}')
|
||||
deleted.add(f"{lang}.{extension}")
|
||||
elif match := MULTISRC_LIB_REGEX.search(file):
|
||||
multisrc = match.group("multisrc")
|
||||
if Path("lib-multisrc", multisrc).is_dir():
|
||||
libs.add(f":lib-multisrc:{multisrc}:printDependentExtensions")
|
||||
elif match := LIB_REGEX.search(file):
|
||||
lib = match.group("lib")
|
||||
if Path("lib", lib).is_dir():
|
||||
libs.add(f":lib:{lib}:printDependentExtensions")
|
||||
|
||||
def is_extension_module(module: str) -> bool:
|
||||
if not (match := MODULE_REGEX.search(module)):
|
||||
return False
|
||||
lang = match.group("lang")
|
||||
extension = match.group("extension")
|
||||
deleted.add(f"{lang}.{extension}")
|
||||
return True
|
||||
|
||||
if len(libs) != 0 and not core_files_changed:
|
||||
modules.update([
|
||||
module for module in
|
||||
run_command("./gradlew -q " + " ".join(libs)).splitlines()
|
||||
if is_extension_module(module)
|
||||
])
|
||||
|
||||
if os.getenv("IS_PR_CHECK") != "true" and not core_files_changed:
|
||||
with Path(".github/always_build.json").open() as always_build_file:
|
||||
always_build = json.load(always_build_file)
|
||||
for extension in always_build:
|
||||
modules.add(":src:" + extension.replace(".", ":"))
|
||||
deleted.add(extension)
|
||||
|
||||
if core_files_changed:
|
||||
(all_modules, all_deleted) = get_all_modules()
|
||||
|
||||
modules.update(all_modules)
|
||||
deleted.update(all_deleted)
|
||||
|
||||
return list(modules), list(deleted)
|
||||
|
||||
def get_all_modules() -> tuple[list[str], list[str]]:
|
||||
modules = []
|
||||
deleted = []
|
||||
for lang in Path("src").iterdir():
|
||||
for extension in lang.iterdir():
|
||||
modules.append(f":src:{lang.name}:{extension.name}")
|
||||
deleted.append(f"{lang.name}.{extension.name}")
|
||||
return modules, deleted
|
||||
|
||||
def main() -> NoReturn:
|
||||
_, ref, build_type = sys.argv
|
||||
modules, deleted = get_module_list(ref)
|
||||
|
||||
chunked = {
|
||||
"chunk": [
|
||||
{"number": i + 1, "modules": modules}
|
||||
for i, modules in
|
||||
enumerate(itertools.batched(
|
||||
map(lambda x: f"{x}:assemble{build_type}", modules),
|
||||
int(os.getenv("CI_CHUNK_SIZE", 65))
|
||||
))
|
||||
]
|
||||
}
|
||||
|
||||
print(f"Module chunks to build:\n{json.dumps(chunked, indent=2)}\n\nModule to delete:\n{json.dumps(deleted, indent=2)}")
|
||||
|
||||
if os.getenv("CI") == "true":
|
||||
with open(os.getenv("GITHUB_OUTPUT"), 'a') as out_file:
|
||||
out_file.write(f"matrix={json.dumps(chunked)}\n")
|
||||
out_file.write(f"delete={json.dumps(deleted)}\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
50
.github/scripts/merge-repo.py
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import html
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
REMOTE_REPO: Path = Path.cwd()
|
||||
LOCAL_REPO: Path = REMOTE_REPO.parent.joinpath(sys.argv[2])
|
||||
|
||||
to_delete: list[str] = json.loads(sys.argv[1])
|
||||
|
||||
for module in to_delete:
|
||||
apk_name = f"tachiyomi-{module}-v*.*.*.apk"
|
||||
icon_name = f"eu.kanade.tachiyomi.extension.{module}.png"
|
||||
for file in REMOTE_REPO.joinpath("apk").glob(apk_name):
|
||||
print(file.name)
|
||||
file.unlink(missing_ok=True)
|
||||
for file in REMOTE_REPO.joinpath("icon").glob(icon_name):
|
||||
print(file.name)
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
shutil.copytree(src=LOCAL_REPO.joinpath("apk"), dst=REMOTE_REPO.joinpath("apk"), dirs_exist_ok = True)
|
||||
shutil.copytree(src=LOCAL_REPO.joinpath("icon"), dst=REMOTE_REPO.joinpath("icon"), dirs_exist_ok = True)
|
||||
|
||||
with REMOTE_REPO.joinpath("index.min.json").open() as remote_index_file:
|
||||
remote_index = json.load(remote_index_file)
|
||||
|
||||
with LOCAL_REPO.joinpath("index.min.json").open() as local_index_file:
|
||||
local_index = json.load(local_index_file)
|
||||
|
||||
index = [
|
||||
item for item in remote_index
|
||||
if not any([item["pkg"].endswith(f".{module}") for module in to_delete])
|
||||
]
|
||||
index.extend(local_index)
|
||||
index.sort(key=lambda x: x["pkg"])
|
||||
|
||||
with REMOTE_REPO.joinpath("index.json").open("w", encoding="utf-8") as index_file:
|
||||
json.dump(index, index_file, ensure_ascii=False, indent=2)
|
||||
|
||||
with REMOTE_REPO.joinpath("index.min.json").open("w", encoding="utf-8") as index_min_file:
|
||||
json.dump(index, index_min_file, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
with REMOTE_REPO.joinpath("index.html").open("w", encoding="utf-8") as index_html_file:
|
||||
index_html_file.write('<!DOCTYPE html>\n<html>\n<head>\n<meta charset="UTF-8">\n<title>apks</title>\n</head>\n<body>\n<pre>\n')
|
||||
for entry in index:
|
||||
apk_escaped = 'apk/' + html.escape(entry["apk"])
|
||||
name_escaped = html.escape(entry["name"])
|
||||
index_html_file.write(f'<a href="{apk_escaped}">{name_escaped}</a>\n')
|
||||
index_html_file.write('</pre>\n</body>\n</html>\n')
|
||||
12
.github/scripts/move-built-apks.py
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
REPO_APK_DIR = Path("repo/apk")
|
||||
|
||||
shutil.rmtree(REPO_APK_DIR, ignore_errors=True)
|
||||
REPO_APK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for apk in Path.home().joinpath("apk-artifacts").glob("**/*.apk"):
|
||||
apk_name = apk.name.replace("-release.apk", ".apk")
|
||||
|
||||
shutil.move(apk, REPO_APK_DIR.joinpath(apk_name))
|
||||
22
.github/workflows/codeberg_mirror.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Sync repo to the Codeberg mirror
|
||||
name: Mirror Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
workflow_dispatch: # Manual dispatch
|
||||
schedule:
|
||||
- cron: "0 */8 * * *"
|
||||
|
||||
jobs:
|
||||
codeberg:
|
||||
if: github.repository == 'yuzono/tachiyomi-extensions'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1.1.1
|
||||
with:
|
||||
target_repo_url: "git@codeberg.org:cuong-tran/komikku-extensions.git"
|
||||
ssh_private_key: ${{ secrets.CODEBERG_SSH }}
|
||||
6
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0 # Use the latest stable version
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
7
buildSrc/settings.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
dependencyResolutionManagement {
|
||||
versionCatalogs {
|
||||
create("libs") {
|
||||
from(files("../gradle/libs.versions.toml"))
|
||||
}
|
||||
}
|
||||
}
|
||||
47
buildSrc/src/main/kotlin/Extensions.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
import groovy.lang.MissingPropertyException
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.artifacts.ProjectDependency
|
||||
import org.gradle.api.plugins.ExtensionAware
|
||||
import org.gradle.kotlin.dsl.extra
|
||||
|
||||
var ExtensionAware.baseVersionCode: Int
|
||||
get() = (extra.get("baseVersionCode") as Int) + kmkBaseVersionCode
|
||||
set(value) = extra.set("baseVersionCode", value)
|
||||
|
||||
fun Project.getDependents(): Set<Project> {
|
||||
val dependentProjects = mutableSetOf<Project>()
|
||||
|
||||
rootProject.allprojects.forEach { project ->
|
||||
project.configurations.forEach { configuration ->
|
||||
configuration.dependencies.forEach { dependency ->
|
||||
if (dependency is ProjectDependency && dependency.path == path) {
|
||||
dependentProjects.add(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dependentProjects
|
||||
}
|
||||
|
||||
fun Project.printDependentExtensions() {
|
||||
getDependents().forEach { project ->
|
||||
if (project.path.startsWith(":src:")) {
|
||||
println(project.path)
|
||||
} else if (project.path.startsWith(":lib-multisrc:")) {
|
||||
project.getDependents().forEach { println(it.path) }
|
||||
} else if (project.path.startsWith(":lib:")) {
|
||||
project.printDependentExtensions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ExtensionAware.kmkBaseVersionCode: Int
|
||||
get() {
|
||||
return try {
|
||||
extra.get("kmkBaseVersionCode") as Int
|
||||
} catch (e: MissingPropertyException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
set(value) = extra.set("kmkBaseVersionCode", value)
|
||||
29
buildSrc/src/main/kotlin/lib-android.gradle.kts
Normal file
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("android")
|
||||
id("kotlinx-serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = AndroidConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AndroidConfig.minSdk
|
||||
}
|
||||
|
||||
namespace = "eu.kanade.tachiyomi.lib.${project.name}"
|
||||
|
||||
buildFeatures {
|
||||
androidResources = false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
|
||||
}
|
||||
|
||||
tasks.register("printDependentExtensions") {
|
||||
doLast {
|
||||
project.printDependentExtensions()
|
||||
}
|
||||
}
|
||||
60
buildSrc/src/main/kotlin/lib-multisrc.gradle.kts
Normal file
@@ -0,0 +1,60 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("android")
|
||||
id("kotlinx-serialization")
|
||||
id("org.jmailen.kotlinter")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = AndroidConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AndroidConfig.minSdk
|
||||
}
|
||||
|
||||
namespace = "eu.kanade.tachiyomi.multisrc.${project.name}"
|
||||
|
||||
sourceSets {
|
||||
named("main") {
|
||||
manifest.srcFile("AndroidManifest.xml")
|
||||
java.setSrcDirs(listOf("src"))
|
||||
res.setSrcDirs(listOf("res"))
|
||||
assets.setSrcDirs(listOf("assets"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||
}
|
||||
}
|
||||
|
||||
kotlinter {
|
||||
experimentalRules = true
|
||||
disabledRules = arrayOf(
|
||||
"experimental:argument-list-wrapping", // Doesn't play well with Android Studio
|
||||
"experimental:comment-wrapping",
|
||||
)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
|
||||
implementation(project(":utils"))
|
||||
}
|
||||
|
||||
tasks {
|
||||
preBuild {
|
||||
dependsOn(lintKotlin)
|
||||
}
|
||||
|
||||
if (System.getenv("CI") != "true") {
|
||||
lintKotlin {
|
||||
dependsOn(formatKotlin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("printDependentExtensions") {
|
||||
doLast {
|
||||
project.printDependentExtensions()
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.gmanga
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.MessageDigest
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
fun decrypt(responseData: String): String {
|
||||
val enc = responseData.split("|")
|
||||
val secretKey = enc[3].sha256().hexStringToByteArray()
|
||||
|
||||
return enc[0].aesDecrypt(secretKey, enc[2])
|
||||
}
|
||||
|
||||
private fun String.hexStringToByteArray(): ByteArray {
|
||||
val len = length
|
||||
val data = ByteArray(len / 2)
|
||||
var i = 0
|
||||
while (i < len) {
|
||||
data[i / 2] = (
|
||||
(Character.digit(this[i], 16) shl 4) +
|
||||
Character.digit(this[i + 1], 16)
|
||||
).toByte()
|
||||
i += 2
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private fun String.sha256(): String {
|
||||
return MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(toByteArray())
|
||||
.fold("") { str, it -> str + "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun String.aesDecrypt(secretKey: ByteArray, ivString: String): String {
|
||||
val c = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val sk = SecretKeySpec(secretKey, "AES")
|
||||
val iv = IvParameterSpec(Base64.decode(ivString.toByteArray(Charsets.UTF_8), Base64.DEFAULT))
|
||||
c.init(Cipher.DECRYPT_MODE, sk, iv)
|
||||
|
||||
val byteStr = Base64.decode(toByteArray(Charsets.UTF_8), Base64.DEFAULT)
|
||||
return String(c.doFinal(byteStr))
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangaworld
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
|
||||
class CookieRedirectInterceptor(private val client: OkHttpClient) : Interceptor {
|
||||
private val cookieRegex = Regex("""document\.cookie="(MWCookie[^"]+)""")
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
|
||||
val contentType = response.header("content-type")
|
||||
if (contentType != null && contentType.startsWith("image/", ignoreCase = true)) {
|
||||
return response
|
||||
}
|
||||
|
||||
// ignore requests that already have completed the JS challenge
|
||||
if (response.headers["vary"] != null) return response
|
||||
|
||||
val content = response.body.string()
|
||||
val results = cookieRegex.find(content)
|
||||
?: return response.newBuilder().body(content.toResponseBody(response.body.contentType())).build()
|
||||
val (cookieString) = results.destructured
|
||||
return chain.proceed(loadCookie(request, cookieString))
|
||||
}
|
||||
|
||||
private fun loadCookie(request: Request, cookieString: String): Request {
|
||||
val cookie = Cookie.parse(request.url, cookieString)!!
|
||||
client.cookieJar.saveFromResponse(request.url, listOf(cookie))
|
||||
val headers = request.headers.newBuilder()
|
||||
.add("Cookie", cookie.toString())
|
||||
.build()
|
||||
return GET(request.url, headers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package eu.kanade.tachiyomi.lib.zipinterceptor
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.util.Base64
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.lang.reflect.Method
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
object ImageDecoderWrapper {
|
||||
private var decodeMethod: Method
|
||||
private var newInstanceMethod: Method
|
||||
private var classSignature = ClassSignature.Newest
|
||||
|
||||
private enum class ClassSignature {
|
||||
Old, New, Newest
|
||||
}
|
||||
|
||||
init {
|
||||
val rectClass = Rect::class.java
|
||||
val booleanClass = Boolean::class.java
|
||||
val intClass = Int::class.java
|
||||
val byteArrayClass = ByteArray::class.java
|
||||
val inputStreamClass = InputStream::class.java
|
||||
|
||||
try {
|
||||
classSignature = ClassSignature.Newest
|
||||
|
||||
// decode(region, sampleSize)
|
||||
decodeMethod = ImageDecoder::class.java.getMethod(
|
||||
"decode",
|
||||
rectClass,
|
||||
intClass,
|
||||
)
|
||||
|
||||
// newInstance(stream, cropBorders, displayProfile)
|
||||
newInstanceMethod = ImageDecoder.Companion::class.java.getMethod(
|
||||
"newInstance",
|
||||
inputStreamClass,
|
||||
booleanClass,
|
||||
byteArrayClass,
|
||||
)
|
||||
} catch (_: NoSuchMethodException) {
|
||||
try {
|
||||
classSignature = ClassSignature.New
|
||||
|
||||
// decode(region, rgb565, sampleSize, applyColorManagement, displayProfile)
|
||||
decodeMethod = ImageDecoder::class.java.getMethod(
|
||||
"decode",
|
||||
rectClass,
|
||||
booleanClass,
|
||||
intClass,
|
||||
booleanClass,
|
||||
byteArrayClass,
|
||||
)
|
||||
|
||||
// newInstance(stream, cropBorders)
|
||||
newInstanceMethod = ImageDecoder.Companion::class.java.getMethod(
|
||||
"newInstance",
|
||||
inputStreamClass,
|
||||
booleanClass,
|
||||
)
|
||||
} catch (_: NoSuchMethodException) {
|
||||
classSignature = ClassSignature.Old
|
||||
|
||||
// decode(region, rgb565, sampleSize)
|
||||
decodeMethod =
|
||||
ImageDecoder::class.java.getMethod(
|
||||
"decode",
|
||||
rectClass,
|
||||
booleanClass,
|
||||
intClass,
|
||||
)
|
||||
|
||||
// newInstance(stream, cropBorders)
|
||||
newInstanceMethod = ImageDecoder.Companion::class.java.getMethod(
|
||||
"newInstance",
|
||||
inputStreamClass,
|
||||
booleanClass,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeImage(data: ByteArray, rgb565: Boolean, filename: String, entryName: String): Bitmap {
|
||||
val decoder = when (classSignature) {
|
||||
ClassSignature.Newest -> newInstanceMethod.invoke(ImageDecoder.Companion, ByteArrayInputStream(data), false, null)
|
||||
else -> newInstanceMethod.invoke(ImageDecoder.Companion, ByteArrayInputStream(data), false)
|
||||
} as ImageDecoder?
|
||||
|
||||
if (decoder == null || decoder.width <= 0 || decoder.height <= 0) {
|
||||
throw IOException("Falha ao inicializar o decodificador de imagem")
|
||||
}
|
||||
|
||||
val rect = Rect(0, 0, decoder.width, decoder.height)
|
||||
val bitmap = when (classSignature) {
|
||||
ClassSignature.Newest -> decodeMethod.invoke(decoder, rect, 1)
|
||||
ClassSignature.New -> decodeMethod.invoke(decoder, rect, rgb565, 1, false, null)
|
||||
else -> decodeMethod.invoke(decoder, rect, rgb565, 1)
|
||||
} as Bitmap?
|
||||
|
||||
decoder.recycle()
|
||||
|
||||
if (bitmap == null) {
|
||||
throw IOException("Não foi possível decodificar a imagem $filename#$entryName")
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
|
||||
open class ZipInterceptor {
|
||||
private val dataUriRegex = Regex("""base64,([0-9a-zA-Z/+=\s]+)""")
|
||||
|
||||
open fun zipGetByteStream(request: Request, response: Response): InputStream {
|
||||
return response.body.byteStream()
|
||||
}
|
||||
|
||||
open fun requestIsZipImage(request: Request): Boolean {
|
||||
return request.url.fragment == "page" && request.url.pathSegments.last().contains(".zip")
|
||||
}
|
||||
|
||||
fun zipImageInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
val filename = request.url.pathSegments.last()
|
||||
|
||||
if (requestIsZipImage(request).not()) {
|
||||
return response
|
||||
}
|
||||
|
||||
val zis = ZipInputStream(zipGetByteStream(request, response))
|
||||
|
||||
val images = generateSequence { zis.nextEntry }
|
||||
.mapNotNull {
|
||||
val entryName = it.name
|
||||
val splitEntryName = entryName.split('.')
|
||||
val entryIndex = splitEntryName.first().toInt()
|
||||
val entryType = splitEntryName.last()
|
||||
|
||||
val imageData = if (entryType == "avif" || splitEntryName.size == 1) {
|
||||
zis.readBytes()
|
||||
} else {
|
||||
val svgBytes = zis.readBytes()
|
||||
val svgContent = svgBytes.toString(Charsets.UTF_8)
|
||||
val b64 = dataUriRegex.find(svgContent)?.groupValues?.get(1)
|
||||
?: return@mapNotNull null
|
||||
|
||||
Base64.decode(b64, Base64.DEFAULT)
|
||||
}
|
||||
|
||||
entryIndex to ImageDecoderWrapper.decodeImage(imageData, isLowRamDevice, filename, entryName)
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
.toList()
|
||||
|
||||
zis.closeEntry()
|
||||
zis.close()
|
||||
|
||||
val totalWidth = images.maxOf { it.second.width }
|
||||
val totalHeight = images.sumOf { it.second.height }
|
||||
|
||||
val result = Bitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
|
||||
var dy = 0
|
||||
|
||||
images.forEach {
|
||||
val srcRect = Rect(0, 0, it.second.width, it.second.height)
|
||||
val dstRect = Rect(0, dy, it.second.width, dy + it.second.height)
|
||||
|
||||
canvas.drawBitmap(it.second, srcRect, dstRect, null)
|
||||
|
||||
dy += it.second.height
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
|
||||
val image = output.toByteArray()
|
||||
val body = image.toResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder()
|
||||
.body(body)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityManager#isLowRamDevice is based on a system property, which isn't
|
||||
* necessarily trustworthy. 1GB is supposedly the regular threshold.
|
||||
*
|
||||
* Instead, we consider anything with less than 3GB of RAM as low memory
|
||||
* considering how heavy image processing can be.
|
||||
*/
|
||||
private val isLowRamDevice by lazy {
|
||||
val ctx = Injekt.get<Application>()
|
||||
val activityManager = ctx.getSystemService("activity") as ActivityManager
|
||||
val memInfo = ActivityManager.MemoryInfo()
|
||||
|
||||
activityManager.getMemoryInfo(memInfo)
|
||||
|
||||
memInfo.totalMem < 3L * 1024 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("android")
|
||||
id("kotlinx-serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = AndroidConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
namespace = "eu.kanade.tachiyomi.lib.themesources"
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
isCanBeResolved = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(libs.bundles.common)
|
||||
|
||||
// Implements all :lib libraries on the multisrc generator
|
||||
// Note that this does not mean that generated sources are going to
|
||||
// implement them too; this is just to be able to compile and generate sources.
|
||||
rootProject.subprojects
|
||||
.filter { it.path.startsWith(":lib") }
|
||||
.forEach(::implementation)
|
||||
}
|
||||
|
||||
tasks {
|
||||
register<JavaExec>("generateExtensions") {
|
||||
val buildDir = layout.buildDirectory.asFile.get()
|
||||
classpath = configurations.compileOnly.get() +
|
||||
configurations.androidApis.get() + // android.jar path
|
||||
files("$buildDir/intermediates/aar_main_jar/debug/classes.jar") // jar made from this module
|
||||
mainClass.set("generator.GeneratorMainKt")
|
||||
|
||||
workingDir = workingDir.parentFile // project root
|
||||
|
||||
errorOutput = System.out // for GitHub workflow commands
|
||||
|
||||
if (!logger.isInfoEnabled) {
|
||||
standardOutput = org.gradle.internal.io.NullOutputStream.INSTANCE
|
||||
}
|
||||
|
||||
dependsOn("ktLint", "assembleDebug")
|
||||
}
|
||||
|
||||
register<org.jmailen.gradle.kotlinter.tasks.LintTask>("ktLint") {
|
||||
if (project.hasProperty("theme")) {
|
||||
val theme = project.property("theme")
|
||||
source(files("src/main/java/eu/kanade/tachiyomi/multisrc/$theme", "overrides/$theme"))
|
||||
return@register
|
||||
}
|
||||
source(files("src", "overrides"))
|
||||
}
|
||||
|
||||
register<org.jmailen.gradle.kotlinter.tasks.FormatTask>("ktFormat") {
|
||||
if (project.hasProperty("theme")) {
|
||||
val theme = project.property("theme")
|
||||
source(files("src/main/java/eu/kanade/tachiyomi/multisrc/$theme", "overrides/$theme"))
|
||||
return@register
|
||||
}
|
||||
source(files("src", "overrides"))
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.multisrc.a3manga.A3MangaUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:host="${SOURCEHOST}" />
|
||||
<data android:host="*.${SOURCEHOST}" />
|
||||
<data android:pathPattern="/truyen-tranh/..*"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -1,7 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.vi.ngonphong
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.a3manga.A3Manga
|
||||
|
||||
class NgonPhong : A3Manga("Ngôn Phong", "https://www.ngonphong.com", "vi") {
|
||||
override val id: Long = 7268977637085631557
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 139 KiB |
@@ -1,68 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.en.manhwaxxl
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.bakamanga.BakaManga
|
||||
|
||||
class ManhwaXXL : BakaManga(
|
||||
"Manhwa XXL",
|
||||
"https://manhwaxxl.com",
|
||||
"en",
|
||||
) {
|
||||
override fun getGenreList() = arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("Action", "action"),
|
||||
Pair("Adaptation", "adaptation"),
|
||||
Pair("Adult", "adult"),
|
||||
Pair("Adventure", "adventure"),
|
||||
Pair("BL", "bl"),
|
||||
Pair("Comedy", "comedy"),
|
||||
Pair("Cooking", "cooking"),
|
||||
Pair("Demons", "demons"),
|
||||
Pair("Drama", "drama"),
|
||||
Pair("Ecchi", "ecchi"),
|
||||
Pair("Fantasy", "fantasy"),
|
||||
Pair("Full color", "full-color"),
|
||||
Pair("Game", "game"),
|
||||
Pair("Gender Bender", "gender-bender"),
|
||||
Pair("GL", "gl"),
|
||||
Pair("Harem", "harem"),
|
||||
Pair("Historical", "historical"),
|
||||
Pair("Horror", "horror"),
|
||||
Pair("Isekai", "isekai"),
|
||||
Pair("Josei", "josei"),
|
||||
Pair("Live action", "live-action"),
|
||||
Pair("Love & Romance", "love-romance"),
|
||||
Pair("Magic", "magic"),
|
||||
Pair("Manga", "manga"),
|
||||
Pair("Manhua", "manhua"),
|
||||
Pair("Manhwa", "manhwa"),
|
||||
Pair("Martial Arts", "martial-arts"),
|
||||
Pair("Mature", "mature"),
|
||||
Pair("Mecha", "mecha"),
|
||||
Pair("Mystery", "mystery"),
|
||||
Pair("Omegaverse", "omegaverse"),
|
||||
Pair("Psychological", "psychological"),
|
||||
Pair("Raw", "raw"),
|
||||
Pair("Reincarnation", "reincarnation"),
|
||||
Pair("Romance", "romance"),
|
||||
Pair("RPG", "rpg"),
|
||||
Pair("School Life", "school-life"),
|
||||
Pair("Sci-fi", "sci-fi"),
|
||||
Pair("Seinen", "seinen"),
|
||||
Pair("Shoujo", "shoujo"),
|
||||
Pair("Shoujo Ai", "shoujo-ai"),
|
||||
Pair("Shounen", "shounen"),
|
||||
Pair("Slice of Life", "slice-of-life"),
|
||||
Pair("Smut", "smut"),
|
||||
Pair("Sports", "sports"),
|
||||
Pair("Supernatural", "supernatural"),
|
||||
Pair("Thriller", "thriller"),
|
||||
Pair("Tragedy", "tragedy"),
|
||||
Pair("Vampire", "vampire"),
|
||||
Pair("Vanilla", "vanilla"),
|
||||
Pair("Webtoon", "webtoon"),
|
||||
Pair("Webtoons", "webtoons"),
|
||||
Pair("Yaoi", "yaoi"),
|
||||
Pair("Yuri", "yuri"),
|
||||
Pair("Zombie", "zombie"),
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.en.bakkinselfhosted
|
||||
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.multisrc.bakkin.BakkinReaderX
|
||||
|
||||
class BakkinSelfHosted : BakkinReaderX("Bakkin Self-hosted", "", "en") {
|
||||
override val baseUrl by lazy {
|
||||
preferences.getString("baseUrl", "http://127.0.0.1/")!!
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
super.setupPreferenceScreen(screen)
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = "baseUrl"
|
||||
title = "Custom URL"
|
||||
summary = "Connect to a self-hosted Bakkin Reader X server"
|
||||
setDefaultValue("http://127.0.0.1/")
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
// Make sure the URL ends with one slash
|
||||
val url = (newValue as String).trimEnd('/') + '/'
|
||||
preferences.edit().putString("baseUrl", url).commit()
|
||||
}
|
||||
}.let(screen::addPreference)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 30 KiB |
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.multisrc.bilibili.BilibiliUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
|
||||
<data android:host="bilibilicomics.com" />
|
||||
<data android:host="m.bilibilicomics.com" />
|
||||
<data android:host="www.bilibilicomics.com" />
|
||||
|
||||
<data android:pathPattern="/detail/mc..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,411 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.bilibilicomics
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.Bilibili
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliAccessToken
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliAccessTokenCookie
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliComicDto
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliCredential
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliGetCredential
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliIntl
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliSearchDto
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliTag
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliUnlockedEpisode
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliUserEpisodes
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.net.URLDecoder
|
||||
import java.util.Calendar
|
||||
|
||||
class BilibiliComicsFactory : SourceFactory {
|
||||
override fun createSources() = listOf(
|
||||
BilibiliComicsEn(),
|
||||
BilibiliComicsCn(),
|
||||
BilibiliComicsId(),
|
||||
BilibiliComicsEs(),
|
||||
BilibiliComicsFr(),
|
||||
)
|
||||
}
|
||||
|
||||
abstract class BilibiliComics(lang: String) : Bilibili(
|
||||
"BILIBILI COMICS",
|
||||
"https://www.bilibilicomics.com",
|
||||
lang,
|
||||
) {
|
||||
|
||||
override val client: OkHttpClient = super.client.newBuilder()
|
||||
.apply { interceptors().add(0, Interceptor { chain -> signedInIntercept(chain) }) }
|
||||
.build()
|
||||
|
||||
init {
|
||||
setAccessTokenCookie(baseUrl.toHttpUrl())
|
||||
}
|
||||
|
||||
override val signedIn: Boolean
|
||||
get() = accessTokenCookie != null
|
||||
|
||||
private val globalApiSubDomain: String
|
||||
get() = GLOBAL_API_SUBDOMAINS[(accessTokenCookie?.area?.toIntOrNull() ?: 1) - 1]
|
||||
|
||||
private val globalApiBaseUrl: String
|
||||
get() = "https://$globalApiSubDomain.bilibilicomics.com"
|
||||
|
||||
private var accessTokenCookie: BilibiliAccessTokenCookie? = null
|
||||
|
||||
private val dayOfWeek: Int
|
||||
get() = Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 1
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val jsonPayload = buildJsonObject { put("day", dayOfWeek) }
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.add("Content-Length", requestBody.contentLength().toString())
|
||||
.add("Content-Type", requestBody.contentType().toString())
|
||||
.set("Referer", "$baseUrl/schedule")
|
||||
.build()
|
||||
|
||||
val apiUrl = "$baseUrl/$API_COMIC_V1_COMIC_ENDPOINT/GetSchedule".toHttpUrl().newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val result = response.parseAs<BilibiliSearchDto>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return MangasPage(emptyList(), hasNextPage = false)
|
||||
}
|
||||
|
||||
val comicList = result.data!!.list.map(::latestMangaFromObject)
|
||||
|
||||
return MangasPage(comicList, hasNextPage = false)
|
||||
}
|
||||
|
||||
protected open fun latestMangaFromObject(comic: BilibiliComicDto): SManga = SManga.create().apply {
|
||||
title = comic.title
|
||||
thumbnail_url = comic.verticalCover + THUMBNAIL_RESOLUTION
|
||||
url = "/detail/mc${comic.comicId}"
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
if (!signedIn) {
|
||||
return super.chapterListParse(response)
|
||||
}
|
||||
|
||||
val result = response.parseAs<BilibiliComicDto>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val comic = result.data!!
|
||||
|
||||
val userEpisodesRequest = userEpisodesRequest(comic.id)
|
||||
val userEpisodesResponse = client.newCall(userEpisodesRequest).execute()
|
||||
val unlockedEpisodes = userEpisodesParse(userEpisodesResponse)
|
||||
|
||||
return comic.episodeList.map { ep -> chapterFromObject(ep, comic.id, isUnlocked = ep.id in unlockedEpisodes) }
|
||||
}
|
||||
|
||||
private fun userEpisodesRequest(comicId: Int): Request {
|
||||
val jsonPayload = buildJsonObject { put("comic_id", comicId) }
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", baseUrl)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$globalApiBaseUrl/$API_COMIC_V1_USER_ENDPOINT/GetUserEpisodes".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
private fun userEpisodesParse(response: Response): List<Int> {
|
||||
if (!response.isSuccessful) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val result = response.parseAs<BilibiliUserEpisodes>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return result.data!!.unlockedEpisodes.orEmpty()
|
||||
.map(BilibiliUnlockedEpisode::id)
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
if (!signedIn) {
|
||||
return super.pageListRequest(chapter)
|
||||
}
|
||||
|
||||
val chapterPaths = (baseUrl + chapter.url).toHttpUrl().pathSegments
|
||||
val comicId = chapterPaths[0].removePrefix("mc").toInt()
|
||||
val episodeId = chapterPaths[1].toInt()
|
||||
|
||||
val jsonPayload = BilibiliGetCredential(comicId, episodeId, 1)
|
||||
val requestBody = json.encodeToString(jsonPayload).toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", baseUrl + chapter.url)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$globalApiBaseUrl/$API_GLOBAL_V1_USER_ENDPOINT/GetCredential".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
if (!signedIn) {
|
||||
return super.pageListParse(response)
|
||||
}
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception(intl.failedToGetCredential)
|
||||
}
|
||||
|
||||
val result = response.parseAs<BilibiliCredential>()
|
||||
val credential = result.data?.credential ?: ""
|
||||
|
||||
val requestPayload = response.request.bodyString
|
||||
val credentialInfo = json.decodeFromString<BilibiliGetCredential>(requestPayload)
|
||||
val chapterUrl = "/mc${credentialInfo.comicId}/${credentialInfo.episodeId}"
|
||||
|
||||
val imageIndexRequest = imageIndexRequest(chapterUrl, credential)
|
||||
val imageIndexResponse = client.newCall(imageIndexRequest).execute()
|
||||
|
||||
return super.pageListParse(imageIndexResponse)
|
||||
}
|
||||
|
||||
private fun setAccessTokenCookie(url: HttpUrl) {
|
||||
val authCookie = client.cookieJar.loadForRequest(url)
|
||||
.firstOrNull { cookie -> cookie.name == ACCESS_TOKEN_COOKIE_NAME }
|
||||
?.let { cookie -> URLDecoder.decode(cookie.value, "UTF-8") }
|
||||
?.let { jsonString -> json.decodeFromString<BilibiliAccessTokenCookie>(jsonString) }
|
||||
|
||||
if (accessTokenCookie == null) {
|
||||
accessTokenCookie = authCookie
|
||||
} else if (authCookie == null) {
|
||||
accessTokenCookie = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun signedInIntercept(chain: Interceptor.Chain): Response {
|
||||
var request = chain.request()
|
||||
val requestUrl = request.url.toString()
|
||||
|
||||
if (!requestUrl.contains("bilibilicomics.com")) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
setAccessTokenCookie(request.url)
|
||||
|
||||
if (!accessTokenCookie?.accessToken.isNullOrEmpty()) {
|
||||
request = request.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${accessTokenCookie!!.accessToken}")
|
||||
.build()
|
||||
}
|
||||
|
||||
val response = chain.proceed(request)
|
||||
|
||||
// Try to refresh the token if it expired.
|
||||
if (response.code == 401 && !accessTokenCookie?.refreshToken.isNullOrEmpty()) {
|
||||
response.close()
|
||||
|
||||
val refreshTokenRequest = refreshTokenRequest(
|
||||
accessTokenCookie!!.accessToken,
|
||||
accessTokenCookie!!.refreshToken,
|
||||
)
|
||||
val refreshTokenResponse = chain.proceed(refreshTokenRequest)
|
||||
|
||||
accessTokenCookie = refreshTokenParse(refreshTokenResponse)
|
||||
refreshTokenResponse.close()
|
||||
|
||||
request = request.newBuilder()
|
||||
.header("Authorization", "Bearer ${accessTokenCookie!!.accessToken}")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun refreshTokenRequest(accessToken: String, refreshToken: String): Request {
|
||||
val jsonPayload = buildJsonObject { put("refresh_token", refreshToken) }
|
||||
val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.add("Authorization", "Bearer $accessToken")
|
||||
.set("Referer", baseUrl)
|
||||
.build()
|
||||
|
||||
val apiUrl = "$globalApiBaseUrl/$API_GLOBAL_V1_USER_ENDPOINT/RefreshToken".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addCommonParameters()
|
||||
.toString()
|
||||
|
||||
return POST(apiUrl, newHeaders, requestBody)
|
||||
}
|
||||
|
||||
private fun refreshTokenParse(response: Response): BilibiliAccessTokenCookie {
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException(intl.failedToRefreshToken)
|
||||
}
|
||||
|
||||
val result = response.parseAs<BilibiliAccessToken>()
|
||||
|
||||
if (result.code != 0) {
|
||||
throw IOException(intl.failedToRefreshToken)
|
||||
}
|
||||
|
||||
val accessToken = result.data!!
|
||||
|
||||
return BilibiliAccessTokenCookie(
|
||||
accessToken.accessToken,
|
||||
accessToken.refreshToken,
|
||||
accessTokenCookie!!.area,
|
||||
)
|
||||
}
|
||||
|
||||
private val Request.bodyString: String
|
||||
get() {
|
||||
val requestCopy = newBuilder().build()
|
||||
val buffer = Buffer()
|
||||
|
||||
return runCatching { buffer.apply { requestCopy.body!!.writeTo(this) }.readUtf8() }
|
||||
.getOrNull() ?: ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACCESS_TOKEN_COOKIE_NAME = "access_token"
|
||||
|
||||
private val GLOBAL_API_SUBDOMAINS = arrayOf("us-user", "sg-user")
|
||||
private const val API_GLOBAL_V1_USER_ENDPOINT = "twirp/global.v1.User"
|
||||
private const val API_COMIC_V1_USER_ENDPOINT = "twirp/comic.v1.User"
|
||||
}
|
||||
}
|
||||
|
||||
class BilibiliComicsEn : BilibiliComics(BilibiliIntl.ENGLISH) {
|
||||
|
||||
override fun getAllGenres(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("All", -1),
|
||||
BilibiliTag("Action", 19),
|
||||
BilibiliTag("Adventure", 22),
|
||||
BilibiliTag("BL", 3),
|
||||
BilibiliTag("Comedy", 14),
|
||||
BilibiliTag("Eastern", 30),
|
||||
BilibiliTag("Fantasy", 11),
|
||||
BilibiliTag("GL", 16),
|
||||
BilibiliTag("Harem", 15),
|
||||
BilibiliTag("Historical", 12),
|
||||
BilibiliTag("Horror", 23),
|
||||
BilibiliTag("Mistery", 17),
|
||||
BilibiliTag("Romance", 13),
|
||||
BilibiliTag("Slice of Life", 21),
|
||||
BilibiliTag("Suspense", 41),
|
||||
BilibiliTag("Teen", 20),
|
||||
)
|
||||
}
|
||||
|
||||
class BilibiliComicsCn : BilibiliComics(BilibiliIntl.SIMPLIFIED_CHINESE) {
|
||||
|
||||
override fun getAllGenres(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("全部", -1),
|
||||
BilibiliTag("校园", 18),
|
||||
BilibiliTag("都市", 9),
|
||||
BilibiliTag("耽美", 3),
|
||||
BilibiliTag("少女", 20),
|
||||
BilibiliTag("恋爱", 13),
|
||||
BilibiliTag("奇幻", 11),
|
||||
BilibiliTag("热血", 19),
|
||||
BilibiliTag("冒险", 22),
|
||||
BilibiliTag("古风", 12),
|
||||
BilibiliTag("百合", 16),
|
||||
BilibiliTag("玄幻", 30),
|
||||
BilibiliTag("悬疑", 41),
|
||||
BilibiliTag("科幻", 8),
|
||||
)
|
||||
}
|
||||
|
||||
class BilibiliComicsId : BilibiliComics(BilibiliIntl.INDONESIAN) {
|
||||
|
||||
override fun getAllGenres(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("Semua", -1),
|
||||
BilibiliTag("Aksi", 19),
|
||||
BilibiliTag("Fantasi Timur", 30),
|
||||
BilibiliTag("Fantasi", 11),
|
||||
BilibiliTag("Historis", 12),
|
||||
BilibiliTag("Horror", 23),
|
||||
BilibiliTag("Kampus", 18),
|
||||
BilibiliTag("Komedi", 14),
|
||||
BilibiliTag("Menegangkan", 41),
|
||||
BilibiliTag("Remaja", 20),
|
||||
BilibiliTag("Romantis", 13),
|
||||
)
|
||||
}
|
||||
|
||||
class BilibiliComicsEs : BilibiliComics(BilibiliIntl.SPANISH) {
|
||||
|
||||
override fun getAllGenres(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("Todos", -1),
|
||||
BilibiliTag("Adolescencia", 105),
|
||||
BilibiliTag("BL", 3),
|
||||
BilibiliTag("Ciberdeportes", 104),
|
||||
BilibiliTag("Ciencia ficción", 8),
|
||||
BilibiliTag("Comedia", 14),
|
||||
BilibiliTag("Fantasía occidental", 106),
|
||||
BilibiliTag("Fantasía", 11),
|
||||
BilibiliTag("Ficción Realista", 116),
|
||||
BilibiliTag("GL", 16),
|
||||
BilibiliTag("Histórico", 12),
|
||||
BilibiliTag("Horror", 23),
|
||||
BilibiliTag("Juvenil", 20),
|
||||
BilibiliTag("Moderno", 111),
|
||||
BilibiliTag("Oriental", 30),
|
||||
BilibiliTag("Romance", 13),
|
||||
BilibiliTag("Suspenso", 41),
|
||||
BilibiliTag("Urbano", 9),
|
||||
BilibiliTag("Wuxia", 103),
|
||||
)
|
||||
}
|
||||
|
||||
class BilibiliComicsFr : BilibiliComics(BilibiliIntl.FRENCH) {
|
||||
|
||||
override fun getAllGenres(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("Tout", -1),
|
||||
BilibiliTag("BL", 3),
|
||||
BilibiliTag("Science Fiction", 8),
|
||||
BilibiliTag("Historique", 12),
|
||||
BilibiliTag("Romance", 13),
|
||||
BilibiliTag("GL", 16),
|
||||
BilibiliTag("Fantasy Orientale", 30),
|
||||
BilibiliTag("Suspense", 41),
|
||||
BilibiliTag("Moderne", 111),
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.multisrc.bilibili.BilibiliUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
|
||||
<data android:host="manga.bilibili.com" />
|
||||
|
||||
<data android:pathPattern="/detail/mc..*" />
|
||||
<data android:pathPattern="/m/detail/mc..*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 209 KiB |
@@ -1,83 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.zh.bilibilimanga
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.Bilibili
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliComicDto
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliIntl
|
||||
import eu.kanade.tachiyomi.multisrc.bilibili.BilibiliTag
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Response
|
||||
|
||||
class BilibiliManga : Bilibili(
|
||||
"哔哩哔哩漫画",
|
||||
"https://manga.bilibili.com",
|
||||
BilibiliIntl.SIMPLIFIED_CHINESE,
|
||||
) {
|
||||
|
||||
override val id: Long = 3561131545129718586
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("User-Agent", DEFAULT_USER_AGENT)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val result = response.parseAs<BilibiliComicDto>()
|
||||
|
||||
if (result.code != 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return result.data!!.episodeList
|
||||
.filter { episode -> episode.isInFree || !episode.isLocked }
|
||||
.map { ep -> chapterFromObject(ep, result.data.id) }
|
||||
}
|
||||
|
||||
override val defaultPopularSort: Int = 0
|
||||
|
||||
override val defaultLatestSort: Int = 1
|
||||
|
||||
override fun getAllSortOptions(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag(intl.sortPopular, 0),
|
||||
BilibiliTag(intl.sortUpdated, 1),
|
||||
BilibiliTag(intl.sortFollowers, 2),
|
||||
BilibiliTag(intl.sortAdded, 3),
|
||||
)
|
||||
|
||||
override fun getAllPrices(): Array<String> =
|
||||
arrayOf(intl.priceAll, intl.priceFree, intl.pricePaid, intl.priceWaitForFree)
|
||||
|
||||
override fun getAllGenres(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("全部", -1),
|
||||
BilibiliTag("竞技", 1034),
|
||||
BilibiliTag("冒险", 1013),
|
||||
BilibiliTag("热血", 999),
|
||||
BilibiliTag("搞笑", 994),
|
||||
BilibiliTag("恋爱", 995),
|
||||
BilibiliTag("少女", 1026),
|
||||
BilibiliTag("日常", 1020),
|
||||
BilibiliTag("校园", 1001),
|
||||
BilibiliTag("治愈", 1007),
|
||||
BilibiliTag("古风", 997),
|
||||
BilibiliTag("玄幻", 1016),
|
||||
BilibiliTag("奇幻", 998),
|
||||
BilibiliTag("惊奇", 996),
|
||||
BilibiliTag("悬疑", 1023),
|
||||
BilibiliTag("都市", 1002),
|
||||
BilibiliTag("剧情", 1030),
|
||||
BilibiliTag("总裁", 1004),
|
||||
BilibiliTag("科幻", 1015),
|
||||
BilibiliTag("正能量", 1028),
|
||||
)
|
||||
|
||||
override fun getAllAreas(): Array<BilibiliTag> = arrayOf(
|
||||
BilibiliTag("全部", -1),
|
||||
BilibiliTag("大陆", 1),
|
||||
BilibiliTag("日本", 2),
|
||||
BilibiliTag("韩国", 6),
|
||||
BilibiliTag("其他", 5),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.multisrc.bilibili.BilibiliUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:host="${SOURCEHOST}"
|
||||
android:pathPattern="/detail/mc..*"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1006 B |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |