diff --git a/src/zh/roumanwu/AndroidManifest.xml b/src/zh/roumanwu/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/zh/roumanwu/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/zh/roumanwu/build.gradle b/src/zh/roumanwu/build.gradle
new file mode 100644
index 000000000..6244ab8cc
--- /dev/null
+++ b/src/zh/roumanwu/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Roumanwu'
+ pkgNameSuffix = 'zh.roumanwu'
+ extClass = '.Roumanwu'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/zh/roumanwu/res/mipmap-hdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..9fa8c768d
Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/zh/roumanwu/res/mipmap-mdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..4bf9906ff
Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/zh/roumanwu/res/mipmap-xhdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..2a9e663bd
Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/zh/roumanwu/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..adb6dc920
Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/zh/roumanwu/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..8bd5df0f8
Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/zh/roumanwu/res/web_hi_res_512.png b/src/zh/roumanwu/res/web_hi_res_512.png
new file mode 100644
index 000000000..d4ee5b66b
Binary files /dev/null and b/src/zh/roumanwu/res/web_hi_res_512.png differ
diff --git a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt
new file mode 100644
index 000000000..4c5813d7a
--- /dev/null
+++ b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt
@@ -0,0 +1,130 @@
+package eu.kanade.tachiyomi.extension.zh.roumanwu
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.ConfigurableSource
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.Response
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
+import kotlin.math.max
+
+class Roumanwu : HttpSource(), ConfigurableSource {
+ override val name = "肉漫屋"
+ override val lang = "zh"
+ override val supportsLatest = true
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ override val baseUrl = MIRRORS[
+ max(MIRRORS.size - 1, preferences.getString(MIRROR_PREF, MIRROR_DEFAULT)!!.toInt())
+ ]
+
+ override val client = network.client.newBuilder().addInterceptor(ScrambledImageInterceptor()).build()
+
+ private val json: Json by injectLazy()
+
+ override fun popularMangaRequest(page: Int) = GET("$baseUrl/home", headers)
+ override fun popularMangaParse(response: Response) = response.nextjsData().getPopular().toMangasPage()
+
+ override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page)
+ override fun latestUpdatesParse(response: Response) = response.nextjsData().recentUpdatedBooks.toMangasPage()
+
+ override fun searchMangaParse(response: Response) = response.nextjsData().toMangasPage()
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
+ if (query.isNotBlank()) {
+ GET("$baseUrl/search?term=$query&page=${page - 1}", headers)
+ } else {
+ val parts = filters.filterIsInstance().joinToString("") { it.toUriPart() }
+ GET("$baseUrl/books?page=${page - 1}$parts", headers)
+ }
+
+ override fun mangaDetailsParse(response: Response) = response.nextjsData().book.toSManga()
+
+ override fun chapterListParse(response: Response) = response.nextjsData().book.getChapterList().reversed()
+
+ override fun pageListParse(response: Response): List {
+ val chapter = response.nextjsData()
+ if (chapter.statusCode != null) throw Exception("服务器错误: ${chapter.statusCode}")
+ return if (chapter.images != null) {
+ chapter.getPageList()
+ } else {
+ val response = client.newCall(GET(baseUrl + chapter.chapterAPIPath!!, headers)).execute()
+ if (!response.isSuccessful) throw Exception("服务器错误: ${response.code}")
+ json.decodeFromString(response.body!!.string()).chapter.getPageList()
+ }
+ }
+
+ override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
+
+ override fun getFilterList() = FilterList(
+ Filter.Header("提示:搜索时筛选无效"),
+ TagFilter(),
+ StatusFilter(),
+ SortFilter(),
+ )
+
+ private abstract class UriPartFilter(name: String, values: Array) : Filter.Select(name, values) {
+ abstract fun toUriPart(): String
+ }
+
+ private class TagFilter : UriPartFilter("標籤", TAGS) {
+ override fun toUriPart() = if (state == 0) "" else "&tag=${TAGS[state]}"
+ }
+
+ private class StatusFilter : UriPartFilter("狀態", arrayOf("全部", "連載中", "已完結")) {
+ override fun toUriPart() =
+ when (state) {
+ 1 -> "&continued=true"
+ 2 -> "&continued=false"
+ else -> ""
+ }
+ }
+
+ private class SortFilter : UriPartFilter("排序", arrayOf("更新日期", "評分")) {
+ override fun toUriPart() = if (state == 0) "" else "&sort=rating"
+ }
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ val mirrorPref = androidx.preference.ListPreference(screen.context).apply {
+ key = MIRROR_PREF
+ title = MIRROR_PREF_TITLE
+ entries = MIRRORS_DESC
+ entryValues = MIRRORS.indices.map(Int::toString).toTypedArray()
+ summary = MIRROR_PREF_SUMMARY
+
+ setDefaultValue(MIRROR_DEFAULT)
+ setOnPreferenceChangeListener { _, newValue ->
+ preferences.edit().putString(MIRROR_PREF, newValue as String).commit()
+ }
+ }
+ screen.addPreference(mirrorPref)
+ }
+
+ companion object {
+ private const val MIRROR_PREF = "MIRROR"
+ private const val MIRROR_PREF_TITLE = "使用镜像网址"
+ private const val MIRROR_PREF_SUMMARY = "使用镜像网址。重启软件生效。"
+
+ // 地址: https://rou.pub/dizhi
+ private val MIRRORS = arrayOf("https://rouman5.com", "https://rouman01.xyz")
+ private val MIRRORS_DESC = arrayOf("主站", "镜像")
+ private const val MIRROR_DEFAULT = 1.toString() // use mirror
+
+ private val TAGS = arrayOf("全部", "正妹", "恋爱", "出版漫画", "肉慾", "浪漫", "大尺度", "巨乳", "有夫之婦", "女大生", "狗血劇", "同居", "好友", "調教", "动作", "後宮", "不倫")
+ }
+
+ private inline fun Response.nextjsData() =
+ json.decodeFromString>(this.asJsoup().select("#__NEXT_DATA__").html()).props.pageProps
+}
diff --git a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/RoumanwuDto.kt b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/RoumanwuDto.kt
new file mode 100644
index 000000000..b309f8998
--- /dev/null
+++ b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/RoumanwuDto.kt
@@ -0,0 +1,94 @@
+package eu.kanade.tachiyomi.extension.zh.roumanwu
+
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+@Serializable
+data class NextData(val props: Props)
+
+@Serializable
+data class Props(val pageProps: T)
+
+@Serializable
+data class Book(
+ val id: String,
+ val name: String,
+// val alias: List,
+ val description: String,
+ val coverUrl: String,
+ val author: String,
+ val continued: Boolean,
+ val tags: List,
+ val updatedAt: String? = null, // TODO: 2022-06-02T00:00:00.000Z
+ val activeResource: Resource? = null,
+) {
+ fun toSManga() = SManga.create().apply {
+ url = "/books/$id"
+ title = name
+ author = this@Book.author
+ description = this@Book.description
+ genre = tags.joinToString(", ")
+ status = if (continued) SManga.ONGOING else SManga.COMPLETED
+ thumbnail_url = coverUrl
+ }
+
+ /** 正序 */
+ fun getChapterList() = activeResource!!.chapters.mapIndexed { i, it ->
+ SChapter.create().apply {
+ url = "/books/$id/$i"
+ name = it
+ }
+ }
+
+ private val uuid by lazy { UUID.fromString(id) }
+ override fun hashCode() = uuid.hashCode()
+ override fun equals(other: Any?) = other is Book && uuid == other.uuid
+}
+
+@Serializable
+data class Resource(val chapters: List)
+
+@Serializable
+data class BookList(val books: List, val hasNextPage: Boolean) {
+ fun toMangasPage() = MangasPage(books.map(Book::toSManga), hasNextPage)
+}
+
+@Serializable
+data class HomePage(
+ val headline: Book,
+ val best: List,
+ val hottest: List,
+ val daily: List,
+ val recentUpdatedBooks: List,
+ val endedBooks: List,
+) {
+ fun getPopular() = (listOf(headline) + best + hottest + daily + endedBooks).distinct()
+}
+
+fun List.toMangasPage() = MangasPage(this.map(Book::toSManga), false)
+
+@Serializable
+data class BookDetails(val book: Book)
+
+@Serializable
+data class Chapter(
+ val statusCode: Int? = null,
+ val images: List? = null,
+ val chapterAPIPath: String? = null,
+) {
+ fun getPageList() = images!!.mapIndexed { i, it ->
+ Page(i, imageUrl = it.src + if (it.scramble) SCRAMBLED_SUFFIX else "")
+ }
+}
+
+@Serializable
+data class ChapterWrapper(val chapter: Chapter)
+
+@Serializable
+data class Image(val src: String, val scramble: Boolean)
+
+const val SCRAMBLED_SUFFIX = "?scrambled"
diff --git a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt
new file mode 100644
index 000000000..a87222682
--- /dev/null
+++ b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt
@@ -0,0 +1,55 @@
+package eu.kanade.tachiyomi.extension.zh.roumanwu
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.util.Base64
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import java.io.ByteArrayOutputStream
+import java.security.MessageDigest
+
+class ScrambledImageInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val response = chain.proceed(request)
+ val url = request.url.toString()
+ if (!url.endsWith(SCRAMBLED_SUFFIX)) return response
+ val image = BitmapFactory.decodeStream(response.body!!.byteStream())
+ val width = image.width
+ val height = image.height
+ val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(result)
+
+ // https://rouman01.xyz/_next/static/chunks/pages/books/%5Bbookid%5D/%5Bid%5D-6f60a589e82dc8db.js
+ // Scrambled images are reversed by blocks. Remainder is included in the bottom (scrambled) block.
+ val blocks = url.removeSuffix(SCRAMBLED_SUFFIX).substringAfterLast('/').removeSuffix(".jpg")
+ .let { Base64.decode(it, Base64.DEFAULT) }
+ .let { MessageDigest.getInstance("MD5").digest(it) } // thread-safe
+ .let { it.last().toPositiveInt() % 10 + 5 }
+ val blockHeight = height / blocks
+ var iy = blockHeight * (blocks - 1)
+ var cy = 0
+ for (i in 0 until blocks) {
+ val h = if (i == 0) height - iy else blockHeight
+ val src = Rect(0, iy, width, iy + h)
+ val dst = Rect(0, cy, width, cy + h)
+ canvas.drawBitmap(image, src, dst, null)
+ iy -= blockHeight
+ cy += h
+ }
+
+ val output = ByteArrayOutputStream()
+ result.compress(Bitmap.CompressFormat.JPEG, 90, output)
+ val responseBody = output.toByteArray().toResponseBody(jpegMediaType)
+ return response.newBuilder().body(responseBody).build()
+ }
+
+ companion object {
+ private val jpegMediaType = "image/jpeg".toMediaType()
+ private fun Byte.toPositiveInt() = toInt() and 0xFF
+ }
+}