diff --git a/src/all/mangaup/AndroidManifest.xml b/src/all/mangaup/AndroidManifest.xml
new file mode 100644
index 000000000..02005096f
--- /dev/null
+++ b/src/all/mangaup/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/all/mangaup/README.md b/src/all/mangaup/README.md
new file mode 100644
index 000000000..ab000dc15
--- /dev/null
+++ b/src/all/mangaup/README.md
@@ -0,0 +1,16 @@
+# Manga UP!
+
+Table of Content
+- [FAQ](#FAQ)
+ - [Why chapters are missing in some titles?](#why-chapters-are-missing-in-some-titles)
+
+## FAQ
+
+### Why chapters are missing in some titles?
+
+Manga UP! have series with paid chapters. These will be filtered out from the chapter
+list by default, even if you have bought then before. To read these chapters, use their
+official app to purchase with their coin system and read there.
+
+To check if a chapter is paid, open the WebView and check if the chapter thumbnail is
+grayed out with a "Exclusive on app" warning or has an "Advanced" badge.
diff --git a/src/all/mangaup/build.gradle b/src/all/mangaup/build.gradle
new file mode 100644
index 000000000..fd180f392
--- /dev/null
+++ b/src/all/mangaup/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Manga UP!'
+ pkgNameSuffix = 'all.mangaup'
+ extClass = '.MangaUpFactory'
+ extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/mangaup/res/mipmap-hdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..180dc064d
Binary files /dev/null and b/src/all/mangaup/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/mangaup/res/mipmap-mdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..05827ee90
Binary files /dev/null and b/src/all/mangaup/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/mangaup/res/mipmap-xhdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..9281fd97a
Binary files /dev/null and b/src/all/mangaup/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/mangaup/res/mipmap-xxhdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..59d1d2b7e
Binary files /dev/null and b/src/all/mangaup/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/mangaup/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..a82fdcee6
Binary files /dev/null and b/src/all/mangaup/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/mangaup/res/web_hi_res_512.png b/src/all/mangaup/res/web_hi_res_512.png
new file mode 100644
index 000000000..2bd22fc93
Binary files /dev/null and b/src/all/mangaup/res/web_hi_res_512.png differ
diff --git a/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUp.kt b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUp.kt
new file mode 100644
index 000000000..8b85840cf
--- /dev/null
+++ b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUp.kt
@@ -0,0 +1,194 @@
+package eu.kanade.tachiyomi.extension.all.mangaup
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
+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.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.internal.closeQuietly
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+
+class MangaUp(override val lang: String) : HttpSource() {
+
+ override val name = "Manga UP!"
+
+ override val baseUrl = "https://global.manga-up.com"
+
+ override val supportsLatest = false
+
+ override fun headersBuilder(): Headers.Builder = Headers.Builder()
+ .add("Origin", baseUrl)
+ .add("Referer", baseUrl)
+ .add("User-Agent", USER_AGENT)
+
+ override val client: OkHttpClient = network.client.newBuilder()
+ .addInterceptor(::thumbnailIntercept)
+ .rateLimitHost(API_URL.toHttpUrl(), 1)
+ .rateLimitHost(baseUrl.toHttpUrl(), 2)
+ .build()
+
+ private val json: Json by injectLazy()
+
+ private var titleList: List? = null
+
+ override fun popularMangaRequest(page: Int): Request {
+ return GET("$API_URL/search?format=json", headers)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ titleList = response.parseAs().titles
+
+ val titles = titleList!!
+ .sortedByDescending { it.bookmarkCount ?: 0 }
+ .map(MangaUpTitle::toSManga)
+
+ return MangasPage(titles, hasNextPage = false)
+ }
+
+ override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException("Not used")
+
+ override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException("Not used")
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ if (query.startsWith(PREFIX_ID_SEARCH) && query.matches(ID_SEARCH_PATTERN)) {
+ return titleDetailsRequest(query.removePrefix(PREFIX_ID_SEARCH))
+ }
+
+ val apiUrl = "$API_URL/manga/search".toHttpUrl().newBuilder()
+ .addQueryParameter("word", query)
+ .addQueryParameter("format", "json")
+ .toString()
+
+ return GET(apiUrl, headers)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ if (response.request.url.toString().contains("manga/detail")) {
+ val titleId = response.request.url.queryParameter("title_id")!!
+
+ val title = response.parseAs().toSManga().apply {
+ url = "/manga/$titleId"
+ }
+
+ return MangasPage(listOf(title), hasNextPage = false)
+ }
+
+ val titles = response.parseAs().titles
+
+ val query = response.request.url.queryParameter("word")
+
+ if (query.isNullOrEmpty()) {
+ fetchAllTitles()
+ } else {
+ titleList = titles
+ }
+
+ return MangasPage(titles.map(MangaUpTitle::toSManga), hasNextPage = false)
+ }
+
+ private fun titleDetailsRequest(mangaUrl: String): Request {
+ val titleId = mangaUrl.substringAfterLast("/")
+
+ val apiUrl = "$API_URL/manga/detail".toHttpUrl().newBuilder()
+ .addQueryParameter("title_id", titleId)
+ .addQueryParameter("ui_lang", lang)
+ .addQueryParameter("format", "json")
+ .toString()
+
+ return GET(apiUrl, headers)
+ }
+
+ // Workaround to allow "Open in browser" use the real URL.
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ return client.newCall(titleDetailsRequest(manga.url))
+ .asObservableSuccess()
+ .map { response ->
+ mangaDetailsParse(response).apply { initialized = true }
+ }
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ return response.parseAs().toSManga()
+ }
+
+ override fun chapterListRequest(manga: SManga): Request = titleDetailsRequest(manga.url)
+
+ override fun chapterListParse(response: Response): List {
+ val titleId = response.request.url.queryParameter("title_id")!!.toInt()
+
+ return response.parseAs().readableChapters
+ .map { it.toSChapter(titleId) }
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val chapterId = chapter.url.substringAfterLast("/")
+
+ return GET("$API_URL/manga/viewer?chapter_id=$chapterId&format=json", headers)
+ }
+
+ override fun pageListParse(response: Response): List {
+ return response.parseAs().pages
+ .mapIndexed { i, page -> Page(i, "", page.imageUrl) }
+ }
+
+ override fun fetchImageUrl(page: Page): Observable = Observable.just(page.imageUrl!!)
+
+ override fun imageUrlParse(response: Response): String = ""
+
+ // Fetch all titles to get newer thumbnail URLs in the interceptor.
+ private fun fetchAllTitles() = runCatching {
+ val popularResponse = client.newCall(popularMangaRequest(1)).execute()
+ titleList = popularResponse.parseAs().titles
+ }
+
+ private fun thumbnailIntercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val response = chain.proceed(request)
+
+ if (response.code == 401 && request.url.toString().contains(TITLE_THUMBNAIL_PATH)) {
+ val titleId = request.url.toString()
+ .substringAfter("/$TITLE_THUMBNAIL_PATH/")
+ .substringBefore(".webp")
+ .toInt()
+ val title = titleList?.find { it.id == titleId } ?: return response
+
+ val thumbnailUrl = title.mainThumbnailUrl
+ ?: title.thumbnailUrl
+ ?: return response
+
+ response.closeQuietly()
+ val thumbnailRequest = GET(thumbnailUrl, request.headers)
+ return chain.proceed(thumbnailRequest)
+ }
+
+ return response
+ }
+
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromString(body?.string().orEmpty())
+ }
+
+ companion object {
+ private const val API_URL = "https://global-web-api.manga-up.com/api"
+ private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"
+
+ private const val TITLE_THUMBNAIL_PATH = "manga_list"
+
+ const val PREFIX_ID_SEARCH = "id:"
+ private val ID_SEARCH_PATTERN = "^id:(\\d+)$".toRegex()
+ }
+}
diff --git a/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpDto.kt b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpDto.kt
new file mode 100644
index 000000000..795972f2e
--- /dev/null
+++ b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpDto.kt
@@ -0,0 +1,97 @@
+package eu.kanade.tachiyomi.extension.all.mangaup
+
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.util.Date
+
+@Serializable
+data class MangaUpSearch(
+ val titles: List = emptyList()
+)
+
+@Serializable
+data class MangaUpViewer(
+ val pages: List = emptyList()
+)
+
+@Serializable
+data class MangaUpTitle(
+ @SerialName("titleId") val id: Int? = null,
+ @SerialName("titleName") val name: String,
+ val authorName: String? = null,
+ val description: String? = null,
+ val copyright: String? = null,
+ val thumbnailUrl: String? = null,
+ val mainThumbnailUrl: String? = null,
+ val bookmarkCount: Int? = null,
+ val genres: List = emptyList(),
+ val chapters: List = emptyList()
+) {
+
+ private val fullDescription: String
+ get() = buildString {
+ description?.let { append(it) }
+ copyright?.let { append("\n\n" + it.replace("(C)", "© ")) }
+ }
+
+ private val isFinished: Boolean
+ get() = chapters.any { it.mainName.contains("final chapter", ignoreCase = true) }
+
+ val readableChapters: List
+ get() = chapters.filter(MangaUpChapter::isReadable).reversed()
+
+ fun toSManga(): SManga = SManga.create().apply {
+ title = name
+ author = authorName
+ description = fullDescription.trim()
+ genre = genres.joinToString { it.name }
+ status = if (isFinished) SManga.COMPLETED else SManga.ONGOING
+ thumbnail_url = mainThumbnailUrl ?: thumbnailUrl
+ url = "/manga/$id"
+ }
+}
+
+@Serializable
+data class MangaUpGenre(
+ val id: Int,
+ val name: String
+)
+
+@Serializable
+data class MangaUpChapter(
+ val id: Int,
+ val mainName: String,
+ val subName: String? = null,
+ val price: Int? = null,
+ val published: Int,
+ val badge: MangaUpBadge = MangaUpBadge.FREE,
+ val available: Boolean = false
+) {
+
+ val isReadable: Boolean
+ get() = badge == MangaUpBadge.FREE && available
+
+ fun toSChapter(titleId: Int): SChapter = SChapter.create().apply {
+ name = mainName.replace(WRONG_SPACING_REGEX, "-$1") +
+ if (!subName.isNullOrEmpty()) ": $subName" else ""
+ date_upload = (published * 1000L).takeIf { it <= Date().time } ?: 0L
+ url = "/manga/$titleId/$id"
+ }
+
+ companion object {
+ private val WRONG_SPACING_REGEX = "\\s+-(\\d+)$".toRegex()
+ }
+}
+
+enum class MangaUpBadge {
+ FREE,
+ ADVANCE,
+ UPDATE
+}
+
+@Serializable
+data class MangaUpPage(
+ val imageUrl: String
+)
diff --git a/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpFactory.kt b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpFactory.kt
new file mode 100644
index 000000000..146ca1e2d
--- /dev/null
+++ b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpFactory.kt
@@ -0,0 +1,16 @@
+package eu.kanade.tachiyomi.extension.all.mangaup
+
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class MangaUpFactory : SourceFactory {
+ /**
+ * They only have English for now, but the website does have a
+ * language selector and the API also supports that.
+ *
+ * Probably it's something they will add in the future, so better
+ * to already make the extension a multilang to avoid users having
+ * to migrate to an All extension after.
+ */
+ override fun createSources(): List = listOf(MangaUp("en"))
+}
diff --git a/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpUrlActivity.kt b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpUrlActivity.kt
new file mode 100644
index 000000000..81dffe7bc
--- /dev/null
+++ b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpUrlActivity.kt
@@ -0,0 +1,41 @@
+package eu.kanade.tachiyomi.extension.all.mangaup
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class MangaUpUrlActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val query = pathSegments[1]
+
+ if (query != null) {
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", MangaUp.PREFIX_ID_SEARCH + query)
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("MangaPlusUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("MangaUpUrlActivity", "Missing the title ID from the URL")
+ }
+ } else {
+ Log.e("MangaUpUrlActivity", "Could not parse URI from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}