diff --git a/src/ja/twi4/AndroidManifest.xml b/src/ja/twi4/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/ja/twi4/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/ja/twi4/build.gradle b/src/ja/twi4/build.gradle
new file mode 100644
index 000000000..912782397
--- /dev/null
+++ b/src/ja/twi4/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Twi4'
+ pkgNameSuffix = 'ja.twi4'
+ extClass = '.Twi4'
+ extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/ja/twi4/res/mipmap-hdpi/ic_launcher.png b/src/ja/twi4/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..d524e436b
Binary files /dev/null and b/src/ja/twi4/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/ja/twi4/res/mipmap-mdpi/ic_launcher.png b/src/ja/twi4/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..a249fe14e
Binary files /dev/null and b/src/ja/twi4/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/ja/twi4/res/mipmap-xhdpi/ic_launcher.png b/src/ja/twi4/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..d47078eff
Binary files /dev/null and b/src/ja/twi4/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/ja/twi4/res/mipmap-xxhdpi/ic_launcher.png b/src/ja/twi4/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..96537a943
Binary files /dev/null and b/src/ja/twi4/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/ja/twi4/res/mipmap-xxxhdpi/ic_launcher.png b/src/ja/twi4/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..944cbe873
Binary files /dev/null and b/src/ja/twi4/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/ja/twi4/res/web_hi_res_512.png b/src/ja/twi4/res/web_hi_res_512.png
new file mode 100644
index 000000000..1f3654ee2
Binary files /dev/null and b/src/ja/twi4/res/web_hi_res_512.png differ
diff --git a/src/ja/twi4/src/eu/kanade/tachiyomi/extension/ja/twi4/Twi4.kt b/src/ja/twi4/src/eu/kanade/tachiyomi/extension/ja/twi4/Twi4.kt
new file mode 100644
index 000000000..3dcf1637c
--- /dev/null
+++ b/src/ja/twi4/src/eu/kanade/tachiyomi/extension/ja/twi4/Twi4.kt
@@ -0,0 +1,240 @@
+package eu.kanade.tachiyomi.extension.ja.twi4
+
+import android.app.Application
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.model.FilterList
+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 eu.kanade.tachiyomi.source.online.HttpSource
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.Headers
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.Jsoup
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+
+class Twi4 : HttpSource() {
+ // The domain sai-zen-sen.jp directs to their main site rather than Twi4. It has to be /comics/twi4
+ override val baseUrl: String = "https://sai-zen-sen.jp/comics/twi4/"
+ override val lang: String = "ja"
+ override val name: String = "Twi4"
+ override val supportsLatest: Boolean = false
+ private val validPageTest: Regex = Regex("/comics/twi4/[a-z]+/works/\\d{4}\\.[0-9a-f]{32}\\.jpg")
+ private val application: Application by injectLazy()
+
+ private fun getUrlDomain(): String = baseUrl.substring(0, 22)
+
+ private fun getChromeHeaders(): Headers = headersBuilder().add(
+ "User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36"
+ ).build()
+
+ // Popular manga == All manga in the site
+ override fun fetchPopularManga(page: Int): Observable {
+ return client.newCall(popularMangaRequest(page))
+ .asObservableSuccess()
+ .map { response ->
+ parsePopularMangaRequest(response, page < 2)
+ }
+ }
+
+ private fun parsePopularMangaRequest(response: Response, hasNextPage: Boolean): MangasPage {
+ val doc = Jsoup.parse(response.body?.string())
+ val ret = mutableListOf()
+ // One of the manga is a link to Twi4's zadankai, which is a platform for anyone to post oneshot 4-koma with judges to comment
+ // It has a completely different page layout and it is pretty much its own "manga site".
+ // Therefore, for simplicity sake. This extension (or at least this source) will not include that as a "Manga"
+ val mangas = doc.select("section:not(.zadankai):not([id])")
+ for (manga in mangas) {
+ ret.add(
+ SManga.create().apply {
+ thumbnail_url =
+ getUrlDomain() + manga.select("header > div.figgroup > figure > a > img")
+ .attr("src")
+ setUrlWithoutDomain(
+ getUrlDomain() + manga.select("header > div.hgroup > h3 > a").attr("href")
+ )
+ title = manga.select("header > div.hgroup > h3 > a > strong").text()
+ }
+ )
+ }
+ return MangasPage(ret, hasNextPage)
+ }
+
+ // We have to fetch all manga from two different pages
+ // One from the homepage (which contains all ongoing manga), one from the completed manga page
+ // The menu at the top relies on JS which JSoup doesn't load
+ override fun popularMangaRequest(page: Int): Request {
+ return if (page == 1) {
+ GET(baseUrl, getChromeHeaders())
+ } else {
+ GET(baseUrl + "completed.html", getChromeHeaders())
+ }
+ }
+
+ override fun mangaDetailsRequest(manga: SManga): Request =
+ GET(getUrlDomain() + manga.url, getChromeHeaders())
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val document = Jsoup.parse(response.body?.string())
+ return SManga.create().apply {
+ description =
+ document.select("#introduction > div > div > p").text()
+ // Determine who are the authors and artists
+ // 作者, 原作 -> Author (Also the artist) / Original author (Such as light novel adaptation)
+ // 漫画 -> Artist only
+ // 提供, etc, etc -> Sponsors, irrelevant stuff
+ val staffs = document.select("#introduction > div > section > header > div > h3")
+ for (staff in staffs) {
+ val role = staff.select("small")
+ if (role.isEmpty())
+ continue
+ when (role.text().replace(":", "").trim()) {
+ "作者" -> {
+ author = staff.select("span").text()
+ artist = staff.select("span").text()
+ }
+ // If 作者 and 原作 appear at the same time, 原作 will overwrite the author field
+ "原作" -> {
+ author = staff.select("span").text()
+ }
+ "漫画" -> {
+ artist = staff.select("span").text()
+ }
+ }
+ }
+ status = SManga.UNKNOWN
+ }
+ }
+
+ override fun chapterListRequest(manga: SManga): Request =
+ GET(getUrlDomain() + manga.url, getChromeHeaders())
+
+ // They have a