This guide provides instructions and tips on creating a new Keiyoushi extension. Please read it carefully if you are a new contributor or lack experience with the required languages and knowledge.
This guide is not definitive and is updated over time. If you find any issues, feel free to report them through a Meta Issue or fix them directly by submitting a Pull Request.
- Contributing
- Table of Contents
- Prerequisites
- Getting help
- Writing an extension
- Setting up a new Gradle module
- Loading a subset of Gradle modules
- Source declaration
- Core dependencies
- Extension API
- lib tools
- Available libs
- Adding a lib dependency
- Creating a new lib
- keiyoushi.utils (core utilities)
- JSON parsing -
parseAs - JSON serialization -
toJsonString/toJsonRequestBody/toJsonElement - JSON models (DTOs) and serialization
- Protobuf parsing and serialization -
parseAsProto/toRequestBodyProto - Date parsing -
tryParsehelpers - HTTP requests -
OkHttpClient.get/post/put/head - Rate limiting -
rateLimit - Custom cookies -
addCookie - WebView execution -
runWebView/getLocalStorage - Filter helpers -
firstInstance/firstInstanceOrNull - SharedPreferences -
getPreferences/getPreferencesLazy - Next.js data extraction -
extractNextJs/extractNextJsRsc - Extracting URLs -
setUrlWithoutDomain+absUrl - GraphQL Requests -
graphQLBody/parseGraphQLAs - GraphQL GET requests -
graphQLGet - JsonElement accessor helpers
- ZIP streaming -
zipDirectoryAsync/readZipEntry - Jsoup helpers -
asJsoup/attrOrNull/textOrNull - Cryptography -
decodeHex/rc4 - Binary endian helpers
- Decompression -
Inflater.inflate - SeedRandom - seeded pseudo-random generator
- JSON parsing -
- Additional dependencies
- Extension main class
- HTML and Image Processing
- Extension call flow
- Misc notes
- Advanced Extension features
- Multi-source themes
- Running
- Debugging
- Building
- Submitting the changes
Before you start, please note that the ability to use following technologies is required and that existing contributors will not actively teach these to you.
- Basic Android development
- Kotlin
- Java Development Kit (JDK) 17 or higher (required by the Gradle build system and Android Gradle Plugin)
- Web scraping
- Android Studio
- Java Development Kit (JDK) 17+
- Emulator or phone with developer options enabled and a recent version of Mihon installed
- Icon Generator
- Try jsoup
Some alternative steps can be followed to skip unrelated sources, which will make it faster to pull, navigate and build. This will also reduce disk usage and network traffic.
Due to the large size of this repository, it is highly recommended to do a partial clone to save network traffic and disk space.
Steps
-
Do a partial clone.
git clone --filter=blob:none --sparse <fork-repo-url> cd extensions-source/
-
Configure sparse checkout.
There are two modes of pattern matching. The default is cone mode. Cone mode enables significantly faster pattern matching for big monorepos and the sparse index feature to make Git commands more responsive. In this mode, you can only filter by file path, which is less flexible and might require more work when the project structure changes.
Cone mode is the recommended standard for pattern matching and responsiveness. Using non-cone mode is deprecated and discouraged.
git sparse-checkout set --cone --sparse-index # add project folders git sparse-checkout add common compiler core gradle lib lib-multisrc # add a single source git sparse-checkout add src/<lang>/<source>
To remove a source, open
.git/info/sparse-checkoutand delete the exact lines you typed when adding it. Don't touch the other auto-generated lines unless you fully understand how cone mode works, or you might break it.Using non-cone mode is deprecated and not recommended. If you still need it, follow these steps:
# enable sparse checkout git sparse-checkout set --no-cone # edit sparse checkout filter vim .git/info/sparse-checkout # alternatively, if you have VS Code installed code .git/info/sparse-checkout
Here's an example:
/* !/src/* !/lib-multisrc/* # allow a single source /src/<lang>/<source> # allow a multisrc theme /lib-multisrc/<source> # or type the source name directly <source>
Explanation: the rules are like
gitignore. We first exclude all sources while retaining project folders, then add the needed sources back manually. -
Configure remotes.
# add upstream git remote add upstream <keiyoushi-repo-url> # optionally disable push to upstream git remote set-url --push upstream no_pushing # optionally fetch main only (ignore all other branches) git config remote.upstream.fetch "+refs/heads/main:refs/remotes/upstream/main" # update remotes git remote update # track main of upstream instead of fork git branch main -u upstream/main
-
Useful configurations. (optional)
# prune obsolete remote branches on fetch git config remote.origin.prune true # fast-forward only when pulling main branch git config pull.ff only # Add an alias to sync main branch without fetching useless blobs. # If you run `git pull` to fast-forward in a blobless clone like this, # all blobs (files) in the new commits are still fetched regardless of # sparse rules, which makes the local repo accumulate unused files. # Use `git sync-main` to avoid this. Be careful if you have changes # on main branch, which is bad practice. git config alias.sync-main '!git switch main && git fetch upstream && git reset --keep FETCH_HEAD'
-
Later, if you change the sparse checkout filter, run
git sparse-checkout reapply.
Read more on Git's object model, partial clone, sparse checkout, sparse index, and negative refspecs.
- Join the Discord server for online help and to ask questions during
development. Please ask questions in the
#programmingchannel. - There are features and tricks not explored in this document; refer to existing extension code for examples.
The quickest way to get started is by using the ext-bootstrap.py script. We also recommend reading through the code of a few existing extensions before beginning.
Each extension should reside in src/<lang>/<mysourcename>. Use all as <lang> if your target
source supports multiple languages or if it could support multiple sources.
The <lang> used in the folder inside src should be the major language part. For example, if
you will be creating a pt-BR source, use <lang> in the directory path as pt only (src/pt/mysource).
In the source {} block in build.gradle.kts, set lang = "pt-BR" to specify the full locale string.
Do NOT declare or override lang in the source class itself, as it is owned by the DSL/codegen and
doing so will throw a fatal KSP compiler error.
Instead of setting this up by hand, you can use the ext-bootstrap.py script to scaffold a new
extension module automatically:
$ python ext-bootstrap.py -n "My Source" -l en -u https://mysource.comThis creates src/<lang>/<mysourcename>/build.gradle.kts along with the extension's package
directory and a starter source class. New (non-multisrc) sources are scaffolded extending
KeiSource with libVersion = "1.6"; sources based on an existing multisrc theme (-m) inherit
whatever base class and libVersion that theme uses.
Available options:
| Flag | Description |
|---|---|
-n, --extname |
Extension name |
-l, --lang, --language |
Extension language (2- or 3-letter ISO code, or all) |
-u, --baseurl |
Extension base URL (must be https://) |
--source-name |
Source name (defaults to --extname) |
-c, --content-warning |
SAFE, MIXED, or NSFW (default: SAFE) |
-m, --multisrc |
Name of an existing multisrc theme to base the source on |
--path |
Path to the extension repo directory (defaults to the current directory) |
For example, to scaffold a source based on the madara multisrc theme:
$ python ext-bootstrap.py -n "My Source" -l en -u https://mysource.com -m madaraBy default, all individual and multisrc extensions are loaded for local development. This may be inconvenient and can drastically slow down your system when working on a single extension.
To adjust which modules are loaded, make adjustments to the settings.gradle.kts file as needed. Comment out loadAllIndividualExtensions() and uncomment loadIndividualExtension("<lang>", "<name>") to specify the single extension you want to work on. This helps avoid loading unnecessary modules, making the build process more efficient and preventing your CPU from being overworked.
The simplest extension structure looks like this:
$ tree src/<lang>/<mysourcename>/
src/<lang>/<mysourcename>/
├── build.gradle.kts
├── res
│ ├── mipmap-hdpi
│ │ └── ic_launcher.png
│ ├── mipmap-mdpi
│ │ └── ic_launcher.png
│ ├── mipmap-xhdpi
│ │ └── ic_launcher.png
│ ├── mipmap-xxhdpi
│ │ └── ic_launcher.png
│ └── mipmap-xxxhdpi
│ └── ic_launcher.png
└── src
└── eu
└── kanade
└── tachiyomi
└── extension
└── <lang>
└── <mysourcename>
├── <MySourceName>.kt
├── <Dto>.kt (optional)
└── <Filters>.kt (optional)
<lang> should be an ISO 639-1 compliant language code (two letters or all). <mysourcename>
should be adapted from the site name, and can only contain lowercase ASCII letters and digits.
Your extension code must be placed in the package eu.kanade.tachiyomi.extension.<lang>.<mysourcename>.
Tip
Additional files in the extension package (like Dto.kt, Filters.kt)
should NOT repeat the extension name (e.g. use Dto.kt instead of MySourceNameDto.kt).
Note: While older extensions might use the repeated name pattern, avoiding it is a newly enforced convention to maintain consistency across the repository.
Extensions' build.gradle.kts should look like this:
import io.github.keiyoushi.gradle.api.ContentWarning
plugins {
alias(kei.plugins.extension)
}
keiyoushi {
name = "Example" // Replace with your actual source name
versionCode = 1
contentWarning = ContentWarning.NSFW // Options: ContentWarning.SAFE, ContentWarning.MIXED, ContentWarning.NSFW
libVersion = "1.6"
source {
name = "Example" // Optional, defaults to the top-level name
lang = "en"
baseUrl = "https://example.com"
}
}At least one source {} block is required for every extension.
| Field | Description |
|---|---|
name |
The name of the extension. Should be romanized if site name is not in English. |
versionCode |
The extension version code. This must be a positive integer and incremented with any change to the code. Do not bump for changes that do not affect users, such as changing a private function to a public function. |
contentWarning |
Content safety classification. Must be set explicitly to one of ContentWarning.SAFE, ContentWarning.MIXED, or ContentWarning.NSFW. |
libVersion |
The extension library version. All new extensions must set this to "1.6" and implement KeiSource (see Extension main class). "1.4" is legacy and only found in extensions that have not yet been migrated. |
theme |
Name of a multi-source theme from lib-multisrc/ to inherit from (e.g. "madara"). When set, the extension's version code is theme.baseVersionCode + versionCode. |
pkgName |
Overrides the application ID suffix (defaults to <lang>.<source dir> derived from the module path). Only needed when moving a module to a different directory - see Moving a source to a different directory. |
source {} |
Declares one source (or multiple, for multi-language or multi-mirror extensions) using KSP code generation. This block is mandatory. See Source declaration. |
deeplink {} |
Declares a URL deeplink intent filter. See URL intent filter. |
The extension's version name is generated automatically by concatenating libVersion and the calculated version code.
With the example used above, the version would be 1.6.1.
Sources are registered through source {} blocks in build.gradle.kts, combined with the @Source annotation on your source class. The build system uses KSP to generate an entry-point subclass (keiyoushi.source.Generated) that automatically injects name, lang, id, and baseUrl- you no longer need to declare them manually in Kotlin.
Add @Source to your main class and remove any manual declarations of name, lang, id, and baseUrl:
import keiyoushi.annotation.Source
import keiyoushi.source.KeiSource
@Source
abstract class MySource : KeiSource() {
// name, lang, id, and baseUrl are injected automatically - do not declare them here.
// All other overrides go here as normal.
}New sources must extend KeiSource (with libVersion = "1.6"), never HttpSource directly - see Extension main class for details.
Add one or more source {} blocks inside keiyoushi {}:
keiyoushi {
name = "My Source"
versionCode = 1
contentWarning = ContentWarning.SAFE
libVersion = "1.6"
source {
lang = "en"
baseUrl = "https://example.com"
}
}| Field | Description |
|---|---|
name |
The source name shown in the app. Optional; defaults to the top-level extension name. |
lang |
ISO 639-1 language code. Required. |
baseUrl |
The source's base URL. See baseUrl modes below. |
id |
Explicit source ID. Optional; auto-computed from name + lang + versionId if omitted. Set this explicitly in the source {} block (never in the source class) when renaming a source to preserve users' libraries. |
versionId |
Integer used as a seed for auto-computing id. Defaults to 1. Only bump this if the source's URL structure fundamentally changes and old entries can no longer be redirected. |
A source class may compute name and/or baseUrl itself by declaring override val name / override val baseUrl; codegen detects the override and skips generating that property (the DSL value is then used only for metadata such as the repo index and deeplink hosts). This is discouraged — prefer letting the DSL own name and baseUrl, and only override them in the source class when you have a very specific reason. baseUrl may only be overridden when the DSL declares a plain static baseUrl — the mirrors/custom modes generate preference infrastructure and cannot be hand-overridden. id, lang, and versionId are strictly owned by the DSL/codegen; overriding them in the source class throws a fatal KSP compiler error.
The baseUrl field inside source {} supports three modes:
Static (single URL, no preferences UI):
source {
lang = "en"
baseUrl = "https://example.com"
}Mirrors (user picks a mirror from a list - preference UI is generated automatically):
source {
lang = "en"
baseUrl {
mirrors(
"https://example.com",
"https://mirror1.com",
"https://mirror2.com",
)
}
}The first url is the default; each mirror's host is shown in the picker. To show custom labels instead, pass "label" to "url" pairs — either all urls are naked or all are labeled, not a mix:
baseUrl {
mirrors(
"Main" to "https://example.com",
"Mirror" to "https://mirror1.com",
)
}The extension automatically implements ConfigurableSource and adds a "Preferred mirror" ListPreference to the settings screen. You do not need to write any SharedPreferences code or add setupPreferenceScreen. If your class already implements ConfigurableSource, super.setupPreferenceScreen(screen) is called so your own preferences are preserved.
Custom URL (user can enter any URL - preference UI with validation is generated automatically):
source {
lang = "en"
baseUrl {
custom("https://example.com")
}
}Like mirrors, the extension automatically implements ConfigurableSource and adds a validated "Custom base URL" EditTextPreference. The default URL is restored automatically if the hardcoded default changes in a future update.
Important
When using mirrors(...) or custom(...), do not implement mirror/URL selection manually in your class using SharedPreferences or a ListPreference - the generated code handles it. Doing both will create duplicate preferences.
To expose multiple sources (previously done with SourceFactory), add multiple source {} blocks. Each block generates an anonymous inner class that subclasses your @Source class:
keiyoushi {
name = "Example"
versionCode = 1
contentWarning = ContentWarning.SAFE
libVersion = "1.6"
source {
name = "Example EN"
lang = "en"
baseUrl = "https://en.example.com"
}
source {
name = "Example JP"
lang = "ja"
baseUrl = "https://jp.example.com"
}
}The generated keiyoushi.source.Generated class implements SourceFactory automatically. You do not need to implement SourceFactory yourself.
Extensions rely on extensions-lib, which provides some interfaces and stubs from the app for compilation purposes. The actual implementations can be found in the Mihon source code. Referencing the actual implementation will help with understanding extensions' call flow.
The lib/ directory contains reusable Gradle modules that solve common problems shared across
multiple extensions, such as image descrambling, JavaScript deobfuscation, and more. Before
implementing something from scratch, check whether an existing lib already covers your
use case. Each lib is self-documented via KDoc comments in its Kotlin source files.
| Module | Description |
|---|---|
lib-aessiv |
AES-SIV (RFC 5297) and AES-GCM-SIV (RFC 8452) AEAD decryption |
lib-cryptoaes |
AES-CBC decryption compatible with CryptoJS; JSFuck deobfuscation |
lib-dataimage |
Decodes base64 data:image strings into mock URLs that OkHttp can handle |
lib-e4p |
Decodes and decrypts E4P-format manga page archives (TIFF/XEBP) |
lib-ece |
RFC 8188 aes128gcm decryption, HKDF key derivation, and P-256 raw point helpers |
lib-i18n |
Internationalization helper (Intl) for multi-language UI strings in extensions |
lib-lzstring |
LZ-String decompression and compression |
lib-publus |
Handles Publus DRM-protected reader decryption, unscrambling, and page loading |
lib-randomua |
Fetches and rotates real-world User-Agent strings (requires overriding getMangaUrl()) |
lib-secretstream |
ChaCha20/Poly1305/X25519 cryptography for secret-stream encrypted sources |
lib-speedbinb |
Processes, decrypts, and descrambles SpeedBinb reader payloads |
lib-synchrony |
JavaScript deobfuscation via the Synchrony engine (QuickJS sandbox) |
lib-textinterceptor |
Renders plain text or HTML as a PNG image page |
lib-unpacker |
Unpacks Dean Edwards-packed JavaScript; substring extraction helpers |
lib-zipinterceptor |
Decodes, stitches, and processes multi-page ZIP/AVIF/SVG image archives |
Important
If your module uses :lib:randomua, the Spotless check requires your extension to override the getMangaUrl() method in your main class, or the build will fail.
Note
The table above highlights the most commonly used libraries. Check the lib/ directory for the full list of available modules and their source files.
Declare the module in your extension's build.gradle.kts:
dependencies {
implementation(project(":lib:<name>"))
}Tip
For multi-source themes in lib-multisrc/, use api() instead of implementation() so the dependency is transitively available to all extensions using the theme.
For example:
dependencies {
implementation(project(":lib:dataimage"))
}Gradle resolves transitive dependencies automatically, so you only need to declare the lib you are directly using.
If no existing lib fits your needs and the functionality is generic enough to be shared across multiple extensions, you can create a new one.
A lib follows this structure:
lib/<mylibname>/
├── build.gradle.kts
└── src
└── keiyoushi
└── lib
└── <mylibname>
└── MyLib.ktThe build.gradle.kts must apply the kei.plugins.library plugin:
plugins {
alias(kei.plugins.library)
}If your lib depends on another lib, declare it in the same file:
plugins {
alias(kei.plugins.library)
}
dependencies {
implementation(project(":lib:other-lib")) // Replace with the actual other library name
}Place your code in the package keiyoushi.lib.<mylibname>. Document public API with KDoc so
contributors can understand the lib without needing to read CONTRIBUTING.md.
The core/utils module provides a set of shared extension functions that are available to all extensions
without any extra Gradle dependency. Prefer using these helpers instead of implementing your own equivalents, as they provide standardized and maintained solutions.
The utilities live in the keiyoushi.utils package and are imported individually.
Use keiyoushi.utils.parseAs to deserialize JSON. It works on String, Response, InputStream, and JsonElement receivers and uses the shared jsonInstance (a pre-configured Json with ignoreUnknownKeys = true). The Response and InputStream variants use efficient stream decoding and automatically close the stream after reading.
import keiyoushi.utils.parseAs
// From a Response (uses streaming and consumes the body):
val dto = response.parseAs<MyDto>()
// From a String:
val dto = jsonString.parseAs<MyDto>()
// With a transform applied before parsing (e.g., stripping JSONP callbacks):
val dto = response.parseAs<MyDto> { it.substringAfter("callback(").dropLast(1) }Do not create a local private val json: Json by injectLazy() unless you specifically need a custom JSON configuration (e.g., isLenient = true or custom serializers). For standard parsing, the global instance is already available via jsonInstance and the parseAs helpers use it automatically.
Use keiyoushi.utils.toJsonString to serialize an object to a JSON string. If you are sending a POST/PUT request, use keiyoushi.utils.toJsonRequestBody to directly convert your object into an OkHttp RequestBody with the correct application/json media type. Use keiyoushi.utils.toJsonElement to convert any @Serializable object directly into a JsonElement.
import keiyoushi.utils.toJsonElement
import keiyoushi.utils.toJsonRequestBody
import keiyoushi.utils.toJsonString
// To a RequestBody for OkHttp (recommended for APIs):
val body = myRequestDto.toJsonRequestBody()
// To a simple String:
val jsonString = myRequestDto.toJsonString()
// To a JsonElement:
val element = myRequestDto.toJsonElement()When defining @Serializable classes for JSON parsing, do not use data class unless you actually need data class features (like copy() or destructuring). Use a regular class instead to reduce the generated bytecode size.
Always use camelCase for Kotlin properties, except when mapping to external model properties that use snake_case (e.g., thumbnail_url in SManga or date_upload in SChapter). Only use @SerialName when the JSON key does not match the property name (e.g., mapping a snake_case JSON key like cover_img to coverImg, or an invalid Kotlin identifier like _count to count). If the JSON key already matches the property name exactly, @SerialName is redundant and should be omitted. It is also recommended to make fields private if they are only used internally (for instance, when mapping directly to SManga or SChapter within the DTO).
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Bad: Using data class and snake_case variable names
@Serializable
data class MyDto(val manga_id: Int, val cover_img: String)
// Good: Regular class, camelCase variables mapped with @SerialName only when names differ, and private fields
@Serializable
class MyDto(
@SerialName("manga_id") private val mangaId: Int,
@SerialName("cover_img") private val coverImg: String,
private val title: String, // No @SerialName needed if JSON key is "title"
@SerialName("_count") private val count: Int, // Needed for invalid Kotlin identifiers
) {
fun toSManga() = SManga.create().apply {
url = mangaId.toString()
thumbnail_url = coverImg
this.title = title
}
}- Use
@Serializableclasses instead ofJsonObject: Do not manually traverseJsonObjectorJsonArray. Define@Serializableclasses and useparseAs<T>(). - Map only used fields: Do not map all fields from the JSON response in your DTOs if they are not used. Omit unused fields to keep the class clean and reduce bytecode.
- Mandatory fields should not have defaults: Do not provide default empty/null values to mandatory fields (like a manga's ID or title) in DTOs just to avoid parsing exceptions. Let the parser fail early so broken entries are detected.
- Avoid
buildJsonObjectfor requests: Instead of manually buildingJsonObjectwithbuildJsonObject { put(...) }, define a@Serializablerequest DTO class and usetoJsonRequestBody(). - Avoid manual JSON string reading: Avoid manually reading the response body as a string to parse JSON (e.g.,
response.body.string()orresponse.peekBody(Long.MAX_VALUE).string()outside of interceptors). Useresponse.parseAs<T>()directly, which handles efficient stream decoding and automatically closes the response body.
If a source's API uses Protocol Buffers (Protobuf) instead of JSON, use the keiyoushi.utils helpers to decode and encode the data. These extensions use a shared protoInstance and automatically handle resource management.
import keiyoushi.utils.decodeProtoBase64
import keiyoushi.utils.encodeProtoBase64
import keiyoushi.utils.parseAsProto
import keiyoushi.utils.toRequestBodyProto
// From a Response (automatically closes the body):
val dto = response.parseAsProto<MyProtoDto>()
// From a Response with a transform applied before decoding (e.g. skipping a 4-byte prefix):
val dto = response.parseAsProto<MyProtoDto> { source ->
source.skip(4L)
source
}
// Decoding a Base64-encoded Protobuf string:
val dto = base64String.decodeProtoBase64<MyProtoDto>()
// Encoding an object to a Base64-encoded Protobuf string:
val base64String = myDto.encodeProtoBase64()
// Creating a RequestBody for a POST request (defaults to application/protobuf):
val requestBody = myRequestDto.toRequestBodyProto()If you only need to work with raw bytes, you can also use .decodeProto() and .encodeProto() directly on a ByteArray.
Do not create a local private val proto: ProtoBuf by injectLazy() unless you specifically need a custom configuration. For standard parsing, the global instance is already available and the parseAsProto helpers use it automatically.
Use the date helpers from keiyoushi.utils instead of parsing dates manually. They accept nullable
strings, return epoch milliseconds, and return 0L when parsing fails.
For a self-describing ISO-8601 instant, such as 2024-01-06T00:00:00Z or
the equivalent 2024-01-06T01:00:00+01:00, use the kotlin.time helper:
import keiyoushi.utils.tryParse
import kotlin.time.Instant
chapter.date_upload = Instant.tryParse(dateStr)For a site-specific format, declare a java.time.format.DateTimeFormatter at class or file level and
use the helper that matches the information in the input:
tryParseDatefor a date without a time. It resolves to the start of the day in the supplied zone.tryParseDateTimefor a local date and time whose zone is supplied separately.tryParseZonedDateTimewhen the parsed text contains the offset or zone that should determine the instant.
import keiyoushi.utils.tryParseDate
import keiyoushi.utils.tryParseDateTime
import keiyoushi.utils.tryParseZonedDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private val dateFormat = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH)
private val dateTimeFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val zonedDateTimeFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
chapter.date_upload = dateFormat.tryParseDate(dateStr, ZoneId.of("Europe/London"))
chapter.date_upload = dateTimeFormat.tryParseDateTime(dateStr, ZoneId.of("Asia/Tokyo"))
chapter.date_upload = zonedDateTimeFormat.tryParseZonedDateTime(dateStr)The date and local date-time helpers default to ZoneId.systemDefault(). Pass an explicit zone when
the site's zone is known so results do not depend on the user's device. Use the appropriate Locale
when a pattern contains locale-sensitive text such as month names. Offset and zone pattern letters
must not be quoted; for example, use XXX, not a literal 'Z', when calling
tryParseZonedDateTime.
Do not use SimpleDateFormat for new code. Its keiyoushi.utils.tryParse overload is deprecated in
favor of these kotlin.time and java.time helpers.
Always make requests with the keiyoushi.network suspend extensions on OkHttpClient - get,
post, put, and head. Use them for every request inside your KeiSource suspend functions
(getPopularManga, getSearchMangaList, fetchMangaUpdate, getPageList, etc.) instead of
building a Request and calling client.newCall(request).execute()/.await() by hand.
import keiyoushi.network.get
import keiyoushi.network.post
// GET with an explicit Headers object:
val response = client.get(url, headers)
// Inside a source, headers can be omitted - your own `headers` are used automatically:
val response = client.get(url)
// POST with a request body:
val response = client.post(url, headers, body)urlmay be aStringor anHttpUrl.ensureSuccess(defaulttrue) throws if the response isn't 2xx; passensureSuccess = falseto inspect non-2xx responses yourself.get/headalso take acacheControl(defaults to a 10-minute max-age).- Always wrap the returned
Responseinresponse.use { ... }or consume it withparseAs/asJsoup, which close it for you.
To prevent IP bans and respect source server capacity, configure client rate limiting via OkHttpClient.Builder.configureClient() using keiyoushi.network.rateLimit:
import keiyoushi.network.rateLimit
import kotlin.time.Duration.Companion.seconds
override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder =
rateLimit(permits = 2, period = 1.seconds)permits: Number of requests allowed within the sliding windowperiod.period: Duration of the sliding window (defaults to1.seconds).interval: Optional minimum delay between consecutive request dispatches to smooth burst traffic (defaults toDuration.ZERO).shouldLimit: Optional predicate(HttpUrl) -> Booleanto restrict rate limiting to specific hosts or paths. MultiplerateLimitrules can be chained, evaluated in definition order:
override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder =
rateLimit(permits = 2, period = 1.seconds) { it.host == "api.example.com" }
.rateLimit(permits = 10, period = 1.seconds) { it.host == "img.example.com" }Use keiyoushi.network.addCookie on an OkHttpClient.Builder (inside configureClient()) to inject custom cookies while
preserving unrelated cookies already attached to the request. If the request already contains a
cookie with the same name, the configured value replaces it. Inside a KeiSource, the domain
defaults to the source's current baseUrl and is resolved again for every request, so generated
mirror and custom-URL preferences keep working after a runtime change:
import keiyoushi.network.addCookie
override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder =
addCookie(
listOf(
"adult" to "1",
"reader" to "web",
"quality" to "high",
),
)Pass a lambda when cookie values must also be resolved for every request:
override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder =
addCookie { listOf("locale" to localePreference()) }For a domain unrelated to baseUrl, pass a domain lambda. Calls can be chained to configure
multiple domains; configurations are checked in call order and the first matching domain is used:
override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder =
addCookie("site-cookie" to "1")
.addCookie({ apiUrl.toHttpUrl().host }, "api-cookie" to "1")Do not manually set the Cookie header for this purpose. Doing so replaces all existing cookies,
including Cloudflare cookies set through WebView, which can break login and challenge solving.
When a source needs a real browser environment (e.g. to solve a JS challenge, run obfuscated
client-side code, or read a token out of localStorage), use keiyoushi.utils.runWebView instead
of instantiating a WebView manually.
import keiyoushi.utils.runWebView
val html = runWebView<String> {
onPageFinished { url ->
evaluateJs("document.documentElement.outerHTML") { html ->
resolve(html)
}
}
loadUrl(targetUrl)
}runWebView(timeout, configure)suspends untilresolve/rejectis called insideconfigure, ortimeout(default 30s) elapses; it always runs on the main thread internally.- The
WebViewScopeDSL exposes:javaScriptEnabled/domStorageEnabled/blockImages/useWideViewPort/loadWithOverviewMode/userAgentsettings,onPageStarted/onPageFinished/onReceivedErrorhooks,interceptRequestto inspect/replace/block resource loads,jsBridge(name, handler)to expose awindow.<name>.post(message)callback from the page,loadUrl/loadData,evaluateJs, andpoll(interval)to repeat a check until resolved. SettinguserAgentalso spoofs theSec-CH-UAclient hints to match it, instead of advertising the real WebView brand and version. - For the common case of just reading a
localStoragevalue after loading a page, use the ready-madegetLocalStorage(url, key)helper instead of writing your ownrunWebViewcall. runWebViewBlocking(call, ...)exists for non-suspend call sites (e.g. inside an OkHttp interceptor where you must pass the interceptor's chain.call()) - only use it there, never from a suspend function.
Use these instead of filterIsInstance<T>().first() / filterIsInstance<T>().firstOrNull().
import keiyoushi.utils.firstInstance
import keiyoushi.utils.firstInstanceOrNull
val genreFilter = filters.firstInstanceOrNull<GenreFilter>()Use these instead of accessing Injekt manually. Both accept an optional migration: SharedPreferences.() -> Unit lambda that executes on the SharedPreferences instance upon initialization, allowing you to migrate old keys cleanly:
import keiyoushi.utils.getPreferences
import keiyoushi.utils.getPreferencesLazy
// Inside your KeiSource / HttpSource class:
private val preferences by getPreferencesLazy {
// Optional migration block executed on initialization:
val oldSetting = getString("old_key", null)
if (oldSetting != null) {
edit().putString("new_key", oldSetting).remove("old_key").apply()
}
}Note
getPreferences() and getPreferencesLazy() are extension functions on HttpSource. If you need to access preferences from a context without a source receiver (e.g. inside a helper class), use the top-level getPreferences(sourceId) function instead.
If the site is built with Next.js, use keiyoushi.utils.extractNextJs on a Document or Response,
or keiyoushi.utils.extractNextJsRsc on a raw RSC response string to pull typed data out of the
hydration payload without fragile HTML scraping.
import keiyoushi.utils.extractNextJs
val data = response.extractNextJs<MyDto>()
// Or with an explicit predicate:
val data = document.extractNextJs<MyDto> { element ->
element is JsonObject && "slug" in element
}For client-side navigation responses (text/x-component content type), pass the rsc: 1
request header. You can call response.extractNextJs<T>() directly on the Response object;
the utility automatically inspects the Content-Type header and safely routes to extractNextJsRsc
for you without needing to manually extract the response body string.
When extracting URLs from HTML, prefer element.absUrl("href") or element.attr("abs:href") over manually concatenating baseUrl + path. Combined with setUrlWithoutDomain(), this safely handles both absolute and relative links.
// Risky - setUrlWithoutDomain cannot resolve all relative URLs:
setUrlWithoutDomain(element.attr("href"))
// Safe:
setUrlWithoutDomain(element.absUrl("href"))If a source uses a GraphQL API, use the dedicated keiyoushi.utils helpers to build requests and
parse responses. These utilities automatically serialize variables, encode payload structures, and
throw a GraphQLException if the response contains GraphQL errors.
import keiyoushi.utils.graphQLBody
import keiyoushi.utils.parseGraphQLAs
// Define your variables as a @Serializable class
val variables = MyVariablesDto(page = 1)
// Build the POST RequestBody:
val body = graphQLBody(
query = $$"""
query SearchManga($page: Int!) {
mangas(page: $page) {
id
}
}
""",
operationName = "SearchManga",
variables = variables, // supports @Serializable types or JsonElement
)
val response = client.post("$baseUrl/graphql", headers, body)
// Parsing the response (automatically extracts the "data" object):
val data = response.parseGraphQLAs<MyResponseDto>()Inside a KeiSource, build the payload with graphQLBody and send it with
client.post:
val data = client.post(
url = "$baseUrl/graphql",
body = graphQLBody(
operationName = "SearchManga",
query = SEARCH_QUERY,
variables = variables,
),
).parseGraphQLAs<MyResponseDto>()For sources that send GraphQL over HTTP GET instead of POST, use graphQLGet:
import keiyoushi.utils.graphQLGet
val request = graphQLGet(
url = "$baseUrl/graphql",
headers = headers,
query = $$"""
query SearchManga($page: Int!) {
mangas(page: $page) {
id
}
}
""",
variables = variables
)Inside a KeiSource, use the suspend OkHttpClient overload instead: it sends the request and
returns the Response, taking the source's own headers when none are passed. ensureSuccess
behaves as in HTTP requests, and it
additionally takes a cacheControl, defaulting to a 10-minute max-age.
val data = client.graphQLGet(
url = "$baseUrl/graphql",
operationName = "SearchManga",
query = SEARCH_QUERY,
variables = variables,
).parseGraphQLAs<MyResponseDto>()For sources that use Automatic Persisted Queries (APQ), pass the result of persistedQueryExtension(sha256Hash) as the extensions parameter and omit query:
import keiyoushi.utils.graphQLBody
import keiyoushi.utils.persistedQueryExtension
val body = graphQLBody(
operationName = "SearchManga",
variables = variables,
extensions = persistedQueryExtension("abc123sha256...")
)
val response = client.post("$baseUrl/graphql", headers, body)Add GraphQLErrorInterceptor to the OkHttpClient to also convert a non-2xx response carrying a GraphQL error payload into a GraphQLException instead of a generic HTTP error. It only inspects non-2xx responses - a 200 OK response whose body contains a GraphQL errors array is not covered by the interceptor, so you still need parseGraphQLAs/response.parseGraphQLAs<T>() to catch that common case:
import keiyoushi.utils.GraphQLErrorInterceptor
override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder =
addInterceptor(GraphQLErrorInterceptor())keiyoushi.utils provides concise read-only accessors for traversing raw JsonElement trees. Import individually as needed:
import keiyoushi.utils.array
import keiyoushi.utils.boolean
import keiyoushi.utils.booleanOrNull
import keiyoushi.utils.get
import keiyoushi.utils.getArray
import keiyoushi.utils.getArrayOrNull
import keiyoushi.utils.getObject
import keiyoushi.utils.getObjectOrNull
import keiyoushi.utils.int
import keiyoushi.utils.intOrNull
import keiyoushi.utils.long
import keiyoushi.utils.longOrNull
import keiyoushi.utils.obj
import keiyoushi.utils.string
import keiyoushi.utils.stringOrNull
val root: JsonElement = response.parseAs()
// Accessing fields and array indices:
val title = root["data"]["title"].string
val count = root["data"]["count"].int
val firstItem = root["data"]["items"][0]
val items = root["data"]["items"].array
val nested = root["data"]["meta"].obj
// Null-safe accessors (return null when element is missing or null):
val subtitle = root["data"]["subtitle"]?.stringOrNull
val score = root["data"]["score"]?.intOrNull
val id = root["data"]["id"]?.longOrNull
val isFinished = root["data"]["is_finished"]?.booleanOrNull
// JsonObject-specific array and object accessors (on a non-null JsonObject):
val chapters = root["data"]!!.obj.getArray("chapters") // or getArrayOrNull
val meta = root["data"]!!.obj.getObject("metadata") // or getObjectOrNullelement[key] and element[index] return JsonElement? (null-safe). The non-null terminal accessors (.string, .int, .long, .boolean) throw if the element is null. The null-safe accessors (.stringOrNull, .intOrNull, .longOrNull, .booleanOrNull) return null instead. JsonObject also provides getStringOrNull, getIntOrNull, getLongOrNull, and getBooleanOrNull variants for optional fields.
Prefer these over writing element.jsonObject["key"]?.jsonPrimitive?.content manually.
For sources that serve manga pages as remote ZIP archives, the keiyoushi.zip package lets you read the central directory and individual entries using HTTP Range requests - no need to download the entire file. Import from keiyoushi.zip:
import keiyoushi.zip.readZipEntry
import keiyoushi.zip.zipDirectoryAsync
import okio.buffer
// 1. Fetch the ZIP central directory asynchronously:
val directory = client.zipDirectoryAsync(zipUrl, headers)
// 2. Find an entry by name and read its decompressed bytes:
val entry = directory.entries.first { it.name == "001.jpg" }
val imageBytes = client.readZipEntry(zipUrl, entry, headers).buffer().readByteArray()client.zipDirectoryAsync(...) is the public OkHttpClient extension method that performs HTTP range requests to fetch and parse the ZIP central directory without downloading the entire file. It automatically resolves total archive size from the Content-Range header and handles ZIP64 archives. readZipDirectory is a low-level internal helper that can be used directly when the raw bytes or size have already been obtained. client.readZipEntry streams and decompresses only the bytes required for that one entry. Use this instead of downloading the full ZIP into a ZipInputStream, which forces the entire archive into memory.
keiyoushi.utils provides convenient extensions for Jsoup parsing:
import keiyoushi.utils.asJsoup
import keiyoushi.utils.attrOrNull
import keiyoushi.utils.ownTextOrNull
import keiyoushi.utils.selectLast
import keiyoushi.utils.textOrNull
import org.jsoup.parser.Parser
// Parse a standard HTML response (streams body and closes response):
val doc = response.asJsoup()
// Parse an HTML string resolving relative links against baseUrl:
val doc = htmlString.asJsoup(baseUrl)
// Parse an XML/RSS response with a custom parser (automatically closes response):
val xmlDoc = response.asJsoup(Parser.xmlParser())
// Null-safe attribute (returns null if attribute is missing or blank):
val imageUrl = element.attrOrNull("data-src") ?: element.attrOrNull("src")
// Select the last matching element in a query:
val lastChapterElement = document.selectLast(".chapter-item")
// Null-safe text extraction (returns null when empty):
val title = element.textOrNull()
val directText = element.ownTextOrNull() // excludes text of nested child elementsFor encrypted APIs and descramblers:
import keiyoushi.utils.decodeHex
import keiyoushi.utils.rc4
// Decode an even-length hex string into a ByteArray:
val keyBytes = "616263".decodeHex()
// Symmetric RC4 stream cipher (serves for both encryption and decryption; skip defaults to 0):
val decrypted = cipherBytes.rc4(key = keyBytes)For binary protocols, packed streams, or custom file headers:
import keiyoushi.utils.readIntBigEndian
import keiyoushi.utils.readIntLittleEndian
import keiyoushi.utils.readLongLittleEndian
import keiyoushi.utils.readUIntLittleEndian
import keiyoushi.utils.readUShortBigEndian
import keiyoushi.utils.readUShortLittleEndian
import keiyoushi.utils.writeIntBigEndian
import keiyoushi.utils.writeIntLittleEndian
val intLE = bytes.readIntLittleEndian(offset = 0)
val intBE = bytes.readIntBigEndian(offset = 4)
val uShortLE = bytes.readUShortLittleEndian(offset = 8)
val uIntLE = bytes.readUIntLittleEndian(offset = 10)
val longLE = bytes.readLongLittleEndian(offset = 14)
bytes.writeIntLittleEndian(offset = 0, value = 1234)
bytes.writeIntBigEndian(offset = 4, value = 5678)For decompressing zlib or raw DEFLATE streams and byte arrays:
import keiyoushi.utils.inflate
import okio.Source
// nowrap = false (default) for standard zlib streams:
val decompressedBuffer = source.inflate(nowrap = false) // source is an okio.Source
val decompressedBytes = byteArray.inflate(nowrap = false)
// nowrap = true for raw DEFLATE streams without zlib headers:
val rawBytes = byteArray.inflate(nowrap = true)A pure Kotlin port of David Bau's seedrandom (formerly in :lib:seedrandom, now built into keiyoushi.utils):
import keiyoushi.utils.SeedRandom
val rng = SeedRandom("seed_key")
val randomDouble = rng.nextDouble() // returns a Double in [0, 1)
val shuffled = rng.shuffle(listOf("A", "B", "C"))If you find yourself needing additional functionality, you can add more dependencies to your build.gradle.kts
file. Many of the dependencies
from the app are exposed to extensions by default.
Note
Several dependencies are already exposed to all extensions via Gradle's version catalog.
To view which are available check the gradle/libs.versions.toml file.
Notice that we're using compileOnly instead of implementation if the app already contains it.
You could use implementation instead for a new dependency, or if you prefer not to rely on whatever
the main app has at the expense of app size.
Tip
Use compileOnlyApi (not compileOnly) when a dependency is provided by the app but also needs to be visible to consumers of your module (e.g. when building a theme or a library).
Important
Using compileOnly restricts you to versions that must be compatible with those used in
the latest stable version of the app.
The class annotated with @Source and referenced by your source {} block(s). All new
extensions must implement KeiSource (libVersion = "1.6"). Existing extensions/themes still
extending HttpSource directly are legacy (libVersion = "1.4"); do not use HttpSource directly
for new sources.
Note
className is set to keiyoushi.source.Generated automatically by the build system.
| Class | Description |
|---|---|
SourceFactory |
Obsolete. Used to expose multiple Sources manually. With source {} blocks, this is generated automatically. |
KeiSource |
The base class for all new online sources (libVersion = "1.6"). A suspend-based API. Use this directly or extend a KeiSource-based theme base class. See KeiSource below. |
HttpSource |
Legacy base class (libVersion = "1.4"). Only found in extensions/themes not yet migrated to KeiSource. Do not use for new sources. |
ParsedHttpSource |
Deprecated, use KeiSource instead. |
KeiSource (package keiyoushi.source) is a suspend-function-based source API. Use it for every
new source.
Methods you must implement (no default - the class will not compile without them):
| Method | Notes |
|---|---|
suspend fun getPopularManga(page: Int): MangasPage |
Make your HTTP calls with client (see HTTP requests) directly inside this function. |
suspend fun getLatestUpdates(page: Int): MangasPage |
Only called when supportsLatest is true (defaults to true on KeiSource). |
suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage |
Plain-text/filter search only; URL search queries are already routed to getMangaByUrl/getMangasByUrl instead. |
suspend fun fetchMangaUpdate(manga: SManga, chapters: List<SChapter>, fetchDetails: Boolean, fetchChapters: Boolean): SMangaUpdate |
See below - whether to honor the flags depends on whether details and chapters come from the same request. |
suspend fun getPageList(chapter: SChapter): List<Page> |
Fetch and parse the page list directly here. |
Optional overrides (each has a working default; override only when your source needs different behavior):
| Method | Default | Override when... |
|---|---|---|
fun getHomeUrl(): String |
Returns baseUrl. |
The browse screen's "Open in WebView" action should land somewhere other than baseUrl (e.g. the source's real site differs from the API host used as baseUrl). |
suspend fun getImageUrl(page: Page): String |
Not called unless a Page.imageUrl is empty. |
A page's image URL can't be determined upfront in getPageList and must be resolved lazily once the chapter is opened - see Chapter Pages. Make your request with client and return the URL directly. |
fun getMangaUrl(manga: SManga): String |
Returns baseUrl + manga.url. |
That doesn't land on the manga's real page. Since SManga.url is recommended to hold just an ID/slug rather than a full relative path (see Misc notes), the default concatenation often isn't a valid path by itself - override to build the real URL from the ID/slug (e.g. "$baseUrl/manga/${manga.url}"). |
fun getChapterUrl(chapter: SChapter): String |
Returns baseUrl + chapter.url. |
Same reasoning as getMangaUrl, for chapters. |
suspend fun getMangaByUrl(url: HttpUrl): SManga? |
Throws (unimplemented). | Almost always: this backs URL search (pasting/opening a manga link for this source). Return null if the URL isn't recognized - leaving this unimplemented means URL search crashes for this source. |
suspend fun getMangasByUrl(url: HttpUrl, page: Int): MangasPage |
Wraps getMangaByUrl's result into a single-item MangasPage. |
A URL can resolve to more than one manga (e.g. a listing/category page URL) - override this instead of/alongside getMangaByUrl. |
val supportsRelatedMangas: Boolean |
false. |
Recommended whenever the site itself surfaces related/similar manga - set to true and implement fetchRelatedMangaList. Only works on Komikku. |
val supportRelatedMangasBySearch: Boolean |
false. |
Related manga should fall back to searching the source by title when a direct related-manga list isn't available. Only works on Komikku. |
suspend fun fetchRelatedMangaList(manga: SManga): List<SManga> |
Returns emptyList(). |
Only called when supportsRelatedMangas is true. |
val supportsFilterFetching: Boolean |
false. |
The source needs to fetch its filter options from the network - see filter fetching below. |
suspend fun fetchFilterData(): JsonElement |
Returns JsonNull. |
Paired with supportsFilterFetching = true. |
fun getFilterList(data: JsonElement?): FilterList |
Returns an empty FilterList. |
The source has any filters at all, fetched or static. |
fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder |
No changes. | Timeouts, extra interceptors, cookie/cache/DNS config - client itself is final on KeiSource. |
fun Headers.Builder.configureHeaders(): Headers.Builder |
No changes. | Extra default headers beyond the Referer/Origin KeiSource already sets - headersBuilder() itself is final. |
fetchMangaUpdate: if manga details and the chapter list come from the same page or the same
API response, fetch and parse it once and return both the updated SManga and the full chapter
list, regardless of the fetchDetails/fetchChapters flags - there's no separate request to skip,
so honoring the flags would just mean discarding data you already parsed. If details and chapters
instead live behind separate pages/endpoints, only fetch what each flag actually asks for; when
both fetchDetails and fetchChapters are true, prefer firing both requests concurrently (e.g.
with coroutineScope { async { ... } }) instead of awaiting them sequentially.
Behavior KeiSource gives you for free:
- No manual request/parse split. There is no
popularMangaRequest/popularMangaParse,pageListRequest/pageListParse, etc. - make the request and parse the response in the same suspend function. - Automatic URL search:
getSearchMangainspects the query; if it parses as anHttpUrl, it callsgetMangasByUrl/getMangaByUrlinstead ofgetSearchMangaList. You do not need to handle this manually. - Headers:
headersBuilder()already setsRefererto"$baseUrl/"(with trailing slash) andOrigintobaseUrl(without trailing slash); overrideHeaders.Builder.configureHeaders()instead ofheadersBuilder()to add more. - Filter fetching: if a source needs to fetch its filter options from the network, set
supportsFilterFetching = true, implementfetchFilterData()(returns the raw filter data as aJsonElement, called on a background coroutine, attempted up to 3 times total on failure, and cached to disk for 3 days), and implementgetFilterList(data: JsonElement?)(pure, synchronous - build aFilterListfrom cached data,nullon first launch or if fetching failed/hasn't completed yet).
Important
Since source {} blocks are required, these fields are generated and injected automatically by KSP.
id, lang, and versionId are strictly owned by the DSL/codegen; overriding or declaring any of them in a KeiSource class throws fatal KSP compiler errors.
id, lang, and versionId must be configured in the source { ... } block in build.gradle.kts.
You can access name, baseUrl, lang, and id within your class (as they are part of the HttpSource contract), but you must not declare or override them manually.
| Field | Description |
|---|---|
name |
Name displayed in the "Sources" tab in the app. Configured in source { name = ... }. |
baseUrl |
Base URL of the source without any trailing slashes. Configured in source { baseUrl = ... }. |
lang |
An ISO 639-1 compliant language code. Configured in source { lang = ... }. Strictly owned by DSL; do not declare or override. |
id |
Identifier of your source. Owned by the source {} DSL - derived via a hash of name + lang + versionId unless set explicitly there. Only set it explicitly in the source {} block (never in the source class) when renaming a source or preserving an existing autogenerated ID - see Renaming existing sources. |
versionId |
Version salt used for generating source ID. Owned by source { versionId = ... } in build.gradle.kts. Do not declare or override in the source class. |
-
Parsing partial HTML: If an API returns a JSON response containing an HTML string, use
Jsoup.parseBodyFragment(html, baseUrl)instead ofJsoup.parse(html). Passing thebaseUrlensures thatabs:hrefandabsUrl()correctly resolve relative links. -
Formatting Chapter Numbers: Do not write custom
DecimalFormatlogic solely to remove trailing zeros from float chapter numbers. Instead, use.toString().removeSuffix(".0"). -
Generating Page lists: The app ignores the
indexpassed to thePageobject, but you must ensure the list itself is sorted correctly according to the source. You can use Kotlin'smapIndexedto easily instantiatePageobjects, or rely on the index provided by the source API if available:return document.select(".pages img").mapIndexed { index, img -> Page(index, imageUrl = img.attr("abs:src")) }
-
Memory-efficient Image Interceptors: When implementing interceptors for descrambling, stitching, or decrypting images, avoid loading the entire image into a
ByteArray, as this can causeOutOfMemoryErroron low-end devices. Prefer stream-based processing:- Read: Use
response.body.byteStream()withBitmapFactory.decodeStream()to decode images directly from the stream. - Write: Write the processed bitmap into an Okio
Bufferviaoutput.outputStream()and convert it usingasResponseBody(mediaType). - Decryption: Use Okio's
cipherSourceextension for stream-based decryption rather than decrypting a full byte array in memory. - Note:
readByteArray()should generally be avoided here because it forces full in-memory buffering of the image. Streaming directly keeps memory usage lower and more stable. - Always wrap network responses in
response.use { ... }to ensure the response body is properly closed and memory leaks are prevented. - If applicable, call
bitmap.recycle()after use to free native memory early.
- Read: Use
-
Do not manually check for Cloudflare: Do not manually check for Cloudflare challenges (e.g., checking for "Just a moment..." text) in
parsemethods. The app handles this before calling the parser. -
Prefer stable selectors: Avoid relying on volatile auto-generated CSS class names (e.g.,
styles_Card__jN8og) or complex regex for parsing. Prefer stable structural selectors. -
Use
ownText()to avoid mutation: To get text from an element without including text from its children, use.ownText(). This avoids the need to select and remove child elements (.select().remove()) or mutate the document. -
Parse status using
.lowercase(): When comparing strings for status parsing (e.g.,contains("ongoing")), prefer calling.lowercase()on the source string once instead of usingignoreCase = trueon multiplecontainschecks.
This section walks through the app-level behavior (pagination, caching, mandatory fields, etc.)
behind each KeiSource method - see KeiSource for the method signatures themselves.
a.k.a. the Browse source entry point in the app (invoked by tapping on the source name).
- The app calls
getPopularMangawhich should return aMangasPagecontaining the first batch of foundSMangaentries.- This method supports pagination. When user scrolls the manga list and more results must be fetched,
the app calls it again with increasing
pagevalues (starting withpage=1). This continues whileMangasPage.hasNextPageis passed astrueandMangasPage.mangasis not empty.
- This method supports pagination. When user scrolls the manga list and more results must be fetched,
the app calls it again with increasing
- To show the list properly, the app needs
url,titleandthumbnail_url. You must set them here. The rest of the fields could be filled later (refer to Manga Details below).
a.k.a. the Latest source entry point in the app (invoked by tapping on the "Latest" button beside the source name).
- Enabled if
supportsLatestistruefor a source (defaults totrueonKeiSource; set it tofalseif the source has no separate latest listing). - Similar to popular manga, but should be fetching the latest entries from a source via
getLatestUpdates.
- When the user searches inside the app,
getSearchMangaListwill be called and the rest of the flow is similar to what happens withgetPopularManga.- If search functionality is not available, return
MangasPage(emptyList(), false). - If the query itself is a URL,
getMangaByUrlis called instead - see KeiSource.
- If search functionality is not available, return
getFilterListwill be called to get all filters and filter types.
The search flow has support for filters that can be added to a FilterList inside the getFilterList
method. When the user changes the filters' state, they are passed into getSearchMangaList as its
filters parameter, and can be iterated to build the request (by getting the filter.state value,
where the type varies depending on the Filter used). You can check the filter types available in Filter.kt
and in the table below.
| Filter | State type | Description |
|---|---|---|
Filter.Header |
None | A simple header. Useful for separating sections in the list or showing any note or warning to the user. |
Filter.Separator |
None | A line separator. Useful for visual distinction between sections. |
Filter.Select<V> |
Int |
A select control, similar to HTML's <select>. Only one item can be selected, and the state is the index of the selected one. |
Filter.Text |
String |
A text control, similar to HTML's <input type="text">. |
Filter.CheckBox |
Boolean |
A checkbox control, similar to HTML's <input type="checkbox">. The state is true if it's checked. |
Filter.TriState |
Int |
A enhanced checkbox control that supports an excluding state. The state can be compared with STATE_IGNORE, STATE_INCLUDE and STATE_EXCLUDE constants of the class. |
Filter.Group<V> |
List<V> |
A group of filters (preferentially of the same type). The state will be a List with all the states. |
Filter.Sort |
Selection |
A control for sorting, with support for the ordering. The state indicates which item index is selected and if the sorting is ascending. |
All control filters can have a default state set. It's usually recommended, if the source has filters to make the initial state match the popular manga list. This way, when the user opens the filter sheet the state accurately represents the currently displayed manga.
The Filter classes can also be extended, so you can create new custom filters like the UriPartFilter:
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}- When a user taps on a manga,
fetchMangaUpdateis called (withfetchDetails/fetchChaptersset according to what's needed) and the results are cached.- A
SMangaentry is identified by itsurl.
- A
fetchDetails = trueasks for updated manga details.SManga.initializedtells the app whether details need fetching. You do not need to set it yourself:getMangaUpdate(thefinalwrapper aroundfetchMangaUpdate) always sets it totrueon the returned manga.SManga.genreis a string containing a list of all genres separated by", ".SManga.statusis an "enum" value. Refer to the values in theSMangacompanion object.- Backups store nearly all known manga fields (author, genre, description, thumbnail, chapters,
etc.), not just
url/title- restoring a backup writes that stored data straight into the local library without a fresh network fetch. Fill in every field you can duringfetchMangaUpdate(details), since there's no guarantee of another automatic re-fetch beyond a manual Swipe-to-Refresh. - If a
SMangais cached, details are only re-fetched when the user performs a manual update (Swipe-to-Refresh).
fetchChapters = trueasks for the chapter list.- The list should be sorted descending by the source order.
getMangaUrlis called when the user taps "Open in WebView". It defaults tobaseUrl + manga.url; only override it if that doesn't already resolve to the manga's page on the website. In practice this is common, sinceSManga.urlis recommended to hold just an ID/slug rather than a full relative path - override to build the real URL from it (e.g."$baseUrl/manga/${manga.url}").
-
SChapter.date_uploadis the UNIX Epoch time expressed in milliseconds.- If you do not pass
SChapter.date_uploadand leave it at zero, the app will use the default date instead, but it is recommended to fill it if available. - Use the
keiyoushi.utilsdate helpers described in Date parsing -tryParsehelpers to convert the source's date format into epoch milliseconds - they already return0Lon failure, so no extrarunCatching/fallback handling is needed on your end. - If parsing fails, return
0Lso the app uses the default date instead. - The app will overwrite the dates of existing chapters UNLESS
0Lis returned. - If the source only provides the manga's update date, assign it to the latest chapter only.
- If you do not pass
-
getChapterUrlis called when the user taps "Open in WebView" in the reader. LikegetMangaUrl, it defaults tobaseUrl + chapter.url; override it only when that doesn't resolve to the chapter's page on the website.
- When a user opens a chapter,
getPageList(chapter)is called and should return the full list ofPages directly - fetch and parse the page data in this same suspend function. - If a page's
imageUrlisn't known upfront, leavePage.imageUrlempty; while the chapter is open in the reader or being downloaded,getImageUrl(page)is then called lazily to resolve it. This is a plainsuspend fun String- make your request withclient(see HTTP requests) and return the URL directly; there's no separate request/parse split to override, and you don't need to overridegetImageUrlat all if everyPage.imageUrlis already filled bygetPageList. - If the source provides all
Page.imageUrlvalues directly, you can fill them and leavePage.urlempty. When set,Page.urlandPage.imageUrlmust be absolute URLs.Page.urlmay be empty ifimageUrlis already filled. - The list of
Pages should be returned already sorted; theindexfield is ignored. - If you need to pass additional data to the image fetcher, pass it as a URL fragment on
Page.url(e.g.,url + "#data"). OkHttp does not send fragments to the server, so there is no need to strip it afterward. For manga- or chapter-level metadata, usememoinstead (see below).
-
Passing state via
memoinstead of URL fragments: Instead of hacking URL fragments (#data) intoSManga.urlorSChapter.urlto preserve internal IDs or metadata (which corrupts URLs and breaks when URLs are stripped or parsed), use thememo: JsonObjectproperty onSMangaandSChapter. You can attach structured JSON viabuildJsonObject { put("key", value) }during search or popular listings, and read it back cleanly infetchMangaUpdate,getChapterList, orgetPageListviamanga.memo["key"]?.stringOrNullorchapter.memo["key"]?.stringOrNull. URL fragments should only be used onPage.urlto pass transient parameters to OkHttp image interceptors:import keiyoushi.utils.stringOrNull import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put // Setting memo when building SManga or SChapter: val manga = SManga.create().apply { url = "/manga/$slug" title = titleText memo = buildJsonObject { put("internal_id", id) } } // Reading memo back in fetchMangaUpdate, getChapterList, or getPageList: val internalId = manga.memo["internal_id"]?.stringOrNull
-
Use
asJsoup(): Instead of manually reading the response body and parsing it with Jsoup (Jsoup.parse(response.body.string())), use the built-in extension function:response.asJsoup()(import keiyoushi.utils.asJsoup), which streams the body and closes the response when done. -
Jsoup
.text()is already trimmed: Callingelement.text().trim()is redundant because Jsoup automatically normalizes and trims whitespace. Just useelement.text(). -
Omit default
joinToStringseparator: The default separator forjoinToStringis already", ". Do not pass it explicitly. UsejoinToString { it.text() }instead ofjoinToString(", ") { it.text() }, andjoinToString()instead ofjoinToString(", "). -
Use named parameters for
Page: When instantiatingPageobjects, use the named parameter for the image URL:Page(index, imageUrl = url)instead of passing an empty string as the second argument (Page(index, "", url)). -
Don't override
getImageUrlunnecessarily: Only override it if some pages need their image URL resolved lazily (see Chapter Pages). IfgetPageListalready fills in everyPage.imageUrl, leave it alone - there's no unused method to stub out or throw from. -
Cache Regex instances: Define
Regexinstances at the class level or in acompanion objectso they aren't recompiled on every method call. -
Don't cache large data in memory: Do not hold on to large or unbounded data (e.g. a fetched manga list, parsed DTOs, an entire chapter's page list) in a top-level/class-level
var/val. A source instance is long-lived for the app's lifetime, so anything stored there stays resident and never gets garbage collected. Re-fetch or re-derive the data instead; if a single value genuinely needs caching (e.g. a small auth token), scope it narrowly and keep it small. -
Do not hardcode
User-Agent: Unless absolutely necessary (e.g., to bypass Cloudflare/protection, or to retrieve a specific mobile layout/different selectors), do not hardcode a specificUser-Agent. Callingsuper.headersBuilder()already provides the app's default User-Agent. -
Use
buildString { }: When building descriptions or dynamic strings, use Kotlin'sbuildString { ... }instead of manually instantiating aStringBuilder(). -
Media Types:
application/jsonis intrinsically UTF-8. Avoid usingapplication/json; charset=utf-8. Prefer helper functions liketoJsonRequestBody()instead of manually specifying media types (e.g.,"application/json".toMediaType()). -
setUrlWithoutDomain()already URL-encodes spaces: It replaces literal spaces with%20internally before parsing the URL, so you do not need to encode them yourself. Other invalid URI characters, however, make it silently fall back to the full original URL (domain included) instead of stripping it - double-check unusual scraped URLs so a malformed one doesn't silently defeat future domain migrations. -
Manga/chapter URLs: Prefer storing just the ID or slug in
SManga.urlandSChapter.url. Storing the relative URL withsetUrlWithoutDomain()is also acceptable. Avoid absolute URLs to make future domain migrations easier. -
Separate custom headers: When adding custom headers to a request (e.g., for AJAX endpoints), avoid building them inline within a
client.get/client.postcall. Instead, assign the modified headers to a separate variable or define them as a class-level property. This improves readability and allows for reuse across multiple requests. -
Configurable sources: By implementing
ConfigurableSource, you can add settings backed bySharedPreferences. -
Code organization: For readability, group related methods together in your extension class (e.g., all popular manga methods, then all latest manga methods, then search methods, and so on). A logical ordering like Popular → Latest → Search → Details → Chapters → Pages → Filters → Utilities makes the class easier to navigate and maintain without needing explicit section header comments.
-
DTO extensions: Move mapping extensions for DTOs (like
fun MyDto.toSManga()) into the DTO file itself to keep the main source class clean.
- Mandatory fields: A manga's
titleandurlare mandatory. A chapter'snameis also mandatory, though generic values like"Chapter"are acceptable for sources providing only a single chapter (e.g., gallery sources). Do not provide generic fallbacks like"Untitled","Unknown", or empty strings if the site fails to provide a title or URL, as this breaks downloads and library management. Prefer failing loudly (e.g., throwing an exception or using!!) so broken selectors are detected early. Silent fallbacks or empty values can hide issues and make debugging harder. If a mandatory field is missing, it is better to throw an exception or skip the entry entirely. - Optional fields: For all other fields, prefer safe calls (
?.) and avoid the non-null assertion (!!). Missing data like thumbnails or descriptions should not crash the parsing process. Consider using Kotlin'smapNotNullwhen parsing lists of elements so that if a single item fails, the rest of the list can still load successfully. - Extension
namefield: Do not add a language suffix or other qualifier toname(e.g.,"MySite EN"). The app already groups sources by language. supportsLatestconvention: If a source only has the latest listing, use that for the popular listing and setsupportsLatest = false.- When to bump
source { versionId = ... }: TheversionIdfield in thesource {}block dictates how the source's ID is auto-computed. Only bump it if the source's URL structure fundamentally changes (e.g., old manga URLs no longer work and there is no way to create a redirect). Bumping this forces all users to re-migrate their bookmarks. - Self-hosted sources: If you are adding a source for a self-hosted server (e.g., StashApp, Komga, Suwayomi), implement the
UnmeteredSourceinterface in your class. This tells the app not to apply standard rate-limiting to the user's local server. - Preference listeners: When implementing
ConfigurableSource, you do not need to manually save values insidesetOnPreferenceChangeListener. The Android preference framework saves the value toSharedPreferencesautomatically. - Update Strategy: For gallery sources or sources where entries are completed upon upload, set
update_strategy = UpdateStrategy.ONLY_FETCH_ONCEto prevent unnecessary update checks. - Preserving Source ID: If you change a source's
nameorlang, its auto-generatedidchanges, disconnecting existing users' libraries. To prevent this, setidexplicitly to the old value (found inindex.json) in thesource {}block inbuild.gradle.kts. Do NOT overrideidin your source class, as doing so throws a fatal KSP compiler error. See Renaming existing sources. - Avoid hardcoded host checks: When checking URLs in deep links or search overrides, avoid hardcoding the host string (e.g.,
queryUrl.host == "site.com"). This breaks if mirrors are added. Prefer dynamically checking against the source'sbaseUrl. - Empty Lists vs. Exceptions: If
getPageListor the chapter list fromfetchMangaUpdatefinds no items (e.g., a locked or empty chapter), returnemptyList()instead of throwing a hardcoded exception. The app will display a localized error message. - Keep comments concise and preferably in English: We recommend writing code comments and KDocs in English to make them accessible to all contributors. Additionally, avoid verbose, redundant, or AI-generated comments explaining obvious code. Keep the code clean and self-documenting.
- Base URL and Mirrors: Custom base URLs and mirror selectors are generated automatically by the
baseUrl { mirrors(...) }andbaseUrl { custom(...) }DSL blocks inbuild.gradle.kts. Do not implement these manually usingSharedPreferencesorListPreference, as the KSP codegen handles preference migration, index coercion, and property overriding automatically. - Other preferences: For other custom settings (like image quality or language), implement the
ConfigurableSourceinterface and use thegetPreferences()orgetPreferencesLazy()helpers to manage your settings.
Extensions can handle URLs from browsers or other apps by declaring deeplinks in build.gradle.kts. When a matching URL is opened on the device, Mihon launches and receives the URL as a search query.
Add one or more deeplink {} blocks inside the keiyoushi {} block:
keiyoushi {
name = "My Source"
// ...
deeplink {
host("example.com")
path("/manga/..*")
path("/chapter/..*")
}
}| DSL call | Description |
|---|---|
host("example.com") |
A hostname to match. Call multiple times to register multiple hosts. If omitted, the host is derived from baseUrl automatically. |
path("/manga/..*") |
A path pattern in Android pathPattern syntax. Call multiple times to match multiple paths. At least one path() call is required; a deeplink {} block with no paths produces no intent filter. |
Multiple deeplink {} blocks create independent intent filters, which is useful when different hosts or path groups must be handled separately:
deeplink {
host("example.com")
path("/manga/..*")
}
deeplink {
host("cdn.example.com")
path("/images/..*")
}No AndroidManifest.xml or UrlActivity.kt is needed; they are generated and provided automatically by the build system.
If the extension uses a theme (via theme = "<theme_name>"), deeplinks defined in the theme's build.gradle.kts are automatically merged, so individual extensions using that theme do not need to repeat shared URL patterns.
Once deeplinks are declared, implement URL handling. When a deeplink is opened on the device, the app fires a search with the full URL as the query.
On KeiSource (libVersion = "1.6"), simply implement getMangaByUrl:
import eu.kanade.tachiyomi.source.model.SManga
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
override suspend fun getMangaByUrl(url: HttpUrl): SManga? {
if (url.host != baseUrl.toHttpUrl().host) return null
val id = url.pathSegments.getOrNull(1) ?: return null
return SManga.create().apply {
this.url = "/manga/$id"
title = ...
}
}KeiSource.getSearchManga automatically detects when the query is a URL and invokes getMangaByUrl(url) without executing keyword search logic.
Note
Avoid checking for hardcoded host strings (e.g., url.host == "site.com"). Prefer dynamically comparing against the source's baseUrl to maintain mirror and custom URL support.
To test whether the URL intent filter is working as expected, use the adb command below:
adb shell am start -d "<your-link>" -a android.intent.action.VIEWIn some cases, titles in a source always have the same chapter list (i.e., they are immutable).
These do not need inclusion in global app updates. Excluding them saves network requests
and prevents unnecessary load on source servers. To change the update strategy of a SManga,
use the update_strategy field. Description of the current possible values follows:
UpdateStrategy.ALWAYS_UPDATE: Titles marked as always update will be included in the library update unless excluded by additional restrictions.UpdateStrategy.ONLY_FETCH_ONCE: Titles marked as only fetch once are automatically skipped during library updates. This is useful for cases where the series is known to be finished and has only a single chapter, for example.
If not set, it defaults to ALWAYS_UPDATE.
If existing sources change their names on the website, you must explicitly set the id to the previous value to reflect these changes correctly. Otherwise, it will change based on the new name value, forcing users to re-migrate to the source.
To get the current id value before a name change, search the source name in the repository JSON file
under the sources attribute of the extension.
Set id directly in the source {} block in build.gradle.kts (never override id in your source class, as doing so throws a fatal KSP compiler error):
source {
name = "New Name" // or lang = "xx" if lang is what changed
lang = "en"
baseUrl = "https://example.com"
id = 1234567890123456789L // Replace with the actual old source ID
}The class name and the name property in the source {} block can then be changed. Also, update the extension name and class name in the individual Gradle file.
Important
The package name must remain the same (even if it uses the old name); otherwise, users will not receive the extension update when published in the repository.
The id also must be explicitly set to the old value in the source {} block if you change the lang property.
Note
If the source has also changed its theme, you can simply change
the name property in the source {} block in the Gradle file. By doing so,
a new id is generated and users will be forced to migrate.
The application ID is derived from the module path (src/<lang>/<name> becomes
eu.kanade.tachiyomi.extension.<lang>.<name>). When moving a source to a different directory
(e.g. en to all, or a rename of the module directory), set pkgName in the new
module's build.gradle.kts to the old path-derived suffix so the package name stays the same:
keiyoushi {
name = "New Name"
pkgName = "en.oldname" // <old lang>.<old dir name>
// ...
}The published APK keeps its old package name and file name, so users receive the move as a
regular update instead of having to uninstall and reinstall. The Kotlin package declarations
of the source code don't participate in the published identity - the entry point is generated
at the fixed location keiyoushi.source.Generated - so they may be updated to match the new
directory or left as-is. A PR check (.github/scripts/audit-packages.py) fails the build if
two modules would produce the same application ID.
The lib-multisrc directory houses source code useful when multiple source
sites use the same site generator tool (usually a CMS). Their similarity prompts code reuse through inheritance or composition, referred to here as a theme.
Themes are provided as libraries within lib-multisrc. You can apply a theme to an extension by specifying the theme property in its build.gradle.kts file.
To create a new theme, set up a new module inside the lib-multisrc directory. The structure is similar to a regular extension, but it acts as a base library that other extensions can depend on.
$ tree lib-multisrc/<theme_name>/
lib-multisrc/<theme_name>/
├── build.gradle.kts
└── src
└── eu
└── kanade
└── tachiyomi
└── multisrc
└── <theme_name>
└── <ThemeName>.kt<theme_name> should be adapted from the CMS or theme name and can only contain lowercase ASCII letters and digits. Your theme code must be placed in the package eu.kanade.tachiyomi.multisrc.<theme_name>.
Ensure that your new theme's build.gradle.kts file follows this structure:
plugins {
alias(kei.plugins.multisrc)
}
keiyoushi {
baseVersionCode = 1
libVersion = "1.6"
}If the CMS generates URLs with a consistent structure shared by all sites built on it, you can declare deeplinks here as well. Every extension using this theme inherits them automatically:
keiyoushi {
baseVersionCode = 1
libVersion = "1.6"
deeplink {
path("/manga/..*")
path("/chapter/..*")
}
}When no host() is specified in a theme deeplink {} block, the host is resolved at build time from each individual extension's baseUrl, so the same path patterns apply to every site without hardcoding hostnames in the theme.
| Field | Description |
|---|---|
baseVersionCode |
The theme's base version code. This must be a positive integer and incremented whenever a change is made to the theme implementation that affects the extensions. |
libVersion |
The extension library version. New themes must set this to "1.6" and have their main class extend KeiSource. "1.4" is legacy and only found in themes not yet migrated. Every extension using the theme inherits this value and must not set its own libVersion. |
deeplink {} |
Declares URL deeplink patterns inherited by all extensions using this theme. See URL intent filter. |
The theme's main class (e.g., <ThemeName>.kt) contains the default implementation for the source sites. It should be declared as an abstract class extending KeiSource (or HttpSource for themes still on libVersion = "1.4"), allowing individual extensions to inherit and override its properties and methods.
package eu.kanade.tachiyomi.multisrc.example // Replace 'example' with your theme name
import keiyoushi.source.KeiSource
abstract class ExampleTheme : KeiSource() {
// name, lang, and baseUrl are inherited from KeiSource/HttpSource. They are automatically
// overridden and injected by the KSP generator via each extension's `source {}`
// block. Do not declare them in the constructor.
// Theme default implementation...
}To use a theme in your extension, follow the regular extension creation steps and configure theme in your build.gradle.kts:
// build.gradle.kts
keiyoushi {
name = "Example" // Replace with your actual source name
theme = "example" // Replace with your actual theme name
versionCode = 1
contentWarning = ContentWarning.NSFW
libVersion = "1.6" // Must match the theme's own libVersion
source {
lang = "en"
baseUrl = "https://example.com"
}
}// MySource.kt
package eu.kanade.tachiyomi.extension.en.mysource
import keiyoushi.annotation.Source
@Source
abstract class MySource : ExampleTheme() { // Replace 'ExampleTheme' with your theme name
// name, lang, id, baseUrl injected automatically
// override theme defaults as needed
}The final extension version code is automatically calculated during the build as theme.baseVersionCode + versionCode.
Because themes are provided as libraries, your extension's main class inherits directly from the theme's base class.
Site-specific overrides, custom functions, or custom icons are implemented directly in your extension's module (src/<lang>/<mysourcename>) by overriding the inherited theme properties and functions.
For local development, use the following run configuration to launch the app directly into the Browse panel.
Copy the following into Launch Flags for the Debug build of Mihon:
-W -S -n app.mihon.dev/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUESFor other builds, replace app.mihon.dev with the corresponding package IDs:
- Release build:
app.mihon - Preview build:
app.mihon.debug
If the extension builds and runs successfully, the code changes should be ready to test in your local app.
Important
If you are deploying to Android 11 or higher, enable the Always install with package manager option in the run configurations. Otherwise, you might face issues such as Android Studio running an older version of the extension without your modifications.
Note
It is generally recommended to rely on logging instead of the Android Debugger. Using standard logs (like Log.d or viewing OkHttp logs) is typically much faster, easier to set up, and sufficient for debugging web scraping logic.
Important
If you did not build the main app from source with debug enabled and are using a release or beta APK, you need a rooted device. If you are using an emulator, ensure you choose a profile without Google Play.
Follow the steps above for building and running locally if you haven't already. Debugging will not work if you did not follow those steps.
You can leverage the Android Debugger to add breakpoints and step through your extension while debugging.
You cannot simply use Android Studio's Debug 'module.name'; this will likely result in an
error while launching.
Instead, once you've built and installed your extension on the target device, use
Attach Debugger to Android Process to start debugging the app.
Inside the Attach Debugger to Android Process window, once the app is running on your device and Show all processes is checked, you should be able to select app.mihon.dev and press OK.
You can also elect to rely on logs printed from your extension, which
show up in the Logcat panel of Android Studio.
One of the easiest ways to inspect network issues (such as HTTP errors 404, 429, no chapter found, etc.)
is to use the Logcat panel of Android Studio
and filter by the OkHttpClient tag.
To check the calls made by OkHttp, you must enable verbose logging in the app; it is not enabled by default. To enable it, go to More → Settings → Advanced → Verbose logging. Afterward, restart the app.
Inspecting the Logcat allows you to see the call flow and is sufficient in most
cases. Alternatively, you can use an external tool like mitmproxy.
For that, refer to the subsequent sections.
On newer Android Studio versions, you can use the built-in Network Inspector inside the App Inspection tool window. This feature provides a GUI to inspect the requests made in the app.
To use it, follow the official documentation and select the app's package name in the process list.
If you want a deeper look into the network flow, such as inspecting the request and response bodies,
you can use an external tool like mitmproxy.
We are going to use mitmproxy, but you can replace it with any other Web Debugger (e.g., Charles, Burp Suite, Fiddler). To install and execute, follow the commands below.
Warning
Do NOT use sudo pip to install mitmproxy. Follow the official installation methods.
After installing it, you can run mitmweb to open the web interface.
Alternatively, use the Docker image:
$ docker run --rm -it -p 8080:8080 \
-p 127.0.0.1:8081:8081 \
--web-host 0.0.0.0 \
mitmproxy/mitmproxy mitmwebAfter installing and running, open your browser and navigate to http://127.0.0.1:8081.
Since most manga sources use HTTPS, we must disable SSL verification to use the web debugger.
Caution
The following code disables certificate and hostname verification. Use it only in a local debug build, never submit it as production source code, and remove it before opening a Pull Request.
For that, add this code inside your source class:
package eu.kanade.tachiyomi.extension.en.mysource
import android.annotation.SuppressLint
import keiyoushi.annotation.Source
import keiyoushi.source.KeiSource
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
@Source
abstract class MySource : KeiSource() {
private fun OkHttpClient.Builder.ignoreAllSSLErrors(): OkHttpClient.Builder {
val naiveTrustManager = @SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
override fun checkClientTrusted(certs: Array<X509Certificate>, authType: String) = Unit
override fun checkServerTrusted(certs: Array<X509Certificate>, authType: String) = Unit
}
val insecureSocketFactory = SSLContext.getInstance("TLSv1.2").apply {
val trustAllCerts = arrayOf<TrustManager>(naiveTrustManager)
init(null, trustAllCerts, SecureRandom())
}.socketFactory
sslSocketFactory(insecureSocketFactory, naiveTrustManager)
hostnameVerifier { _, _ -> true }
return this
}
override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder =
ignoreAllSSLErrors()
.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress("10.0.2.2", 8080)))
}Note: 10.0.2.2 is usually the address of your loopback interface in the Android emulator. If
the app tells you that it's unable to connect to 10.0.2.2:8080, you will likely need to change it
(the same if you are using a hardware device).
If all went well, you should see all requests and responses made by the source in the web interface
of mitmweb.
APKs can be created in Android Studio via Build > Build Bundle(s) / APK(s) > Build APK(s) or
Build > Generate Signed Bundle / APK.
If you decide to build the APK from the command line, use the following command:
// For a single apk, use this command
$ ./gradlew :src:<lang>:<source>:assembleDebugBefore committing or linting, format all Kotlin files and Gradle scripts to match repository standards:
$ ./gradlew spotlessApplyAs a final check before submitting, run release lint on every Android module you touched:
$ ./gradlew :src:<lang>:<source>:lintReleaseRun lint directly on shared modules, such as
./gradlew :lib-multisrc:<theme>:lintRelease or ./gradlew :lib:<name>:lintRelease.
Linting only an extension that depends on a shared module does not reliably report issues in the
dependency itself. If you explicitly changed core/, run ./gradlew :core:lintRelease as well.
When you feel confident about your changes, submit a new Pull Request for review. We encourage following a GitHub Standard Fork & Pull Request Workflow; avoid committing directly to main and always create a new branch for your changes.
If you prefer using Git GUI-based tools, refer to this guide about Git integration in Android Studio. Specifically, check the "How to Contribute to an Existing Git Repository in Android Studio" section.
Important
Ensure you have generated the extension icon using the Icon Generator tool in the Tools
section. The icon must follow the pattern adopted by all extensions: a square with rounded
corners. Remove the generated web_hi_res_512.png.
Please test your changes by compiling through Android Studio before submitting. Untested PRs will not be merged. Also, ensure you follow the PR checklist in the PR body field when creating a new PR; it is provided below for reference.
- Updated
versionCodevalue inbuild.gradle.kts - Updated
baseVersionCodeinbuild.gradle.kts(if updated multisrc theme code) - Referenced all related issues in the PR body (e.g. "Closes #xyz")
- Set the
contentWarningconfiguration inbuild.gradle.ktsappropriately - Have not changed source names
- Have explicitly kept the
idif a source's name or language were changed - Have tested the modifications by compiling and running the extension through Android Studio
- Have removed
web_hi_res_512.pngwhen adding a new extension - This PR is AI-assisted, I have reviewed the changes manually and confirmed they are not slop

