diff --git a/src/all/mangahosted/AndroidManifest.xml b/src/all/mangahosted/AndroidManifest.xml
new file mode 100644
index 000000000..89ee9a464
--- /dev/null
+++ b/src/all/mangahosted/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/all/mangahosted/build.gradle b/src/all/mangahosted/build.gradle
new file mode 100644
index 000000000..f2fb40324
--- /dev/null
+++ b/src/all/mangahosted/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'Manga Hosted'
+ extClass = '.MangaHostedFactory'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/mangahosted/res/mipmap-hdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..c67ab461b
Binary files /dev/null and b/src/all/mangahosted/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/mangahosted/res/mipmap-mdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..ae0fd67b4
Binary files /dev/null and b/src/all/mangahosted/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/mangahosted/res/mipmap-xhdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..f1059244d
Binary files /dev/null and b/src/all/mangahosted/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/mangahosted/res/mipmap-xxhdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..167181495
Binary files /dev/null and b/src/all/mangahosted/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/mangahosted/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..fe00773e6
Binary files /dev/null and b/src/all/mangahosted/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHosted.kt b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHosted.kt
new file mode 100644
index 000000000..3c6bd55f1
--- /dev/null
+++ b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHosted.kt
@@ -0,0 +1,202 @@
+package eu.kanade.tachiyomi.extension.all.mangahosted
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimit
+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.Request
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class MangaHosted(private val langOption: LanguageOption) : HttpSource() {
+
+ override val lang = langOption.lang
+
+ override val name: String = "Manga Hosted${langOption.nameSuffix}"
+
+ override val baseUrl: String = "https://mangahosted.org"
+
+ override val supportsLatest = true
+
+ private val json: Json by injectLazy()
+
+ override val client = network.client.newBuilder()
+ .rateLimit(2)
+ .build()
+
+ override fun headersBuilder(): Headers.Builder = super.headersBuilder()
+ .set("Referer", "$baseUrl/")
+
+ // ================================= Popular ==========================================
+
+ override fun popularMangaRequest(page: Int): Request {
+ val maxResult = 24
+ return GET("$apiUrl/${langOption.infix}/HomeTopFllow/$maxResult/${page - 1}")
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val dto = response.parseAs>()
+ val mangas = dto.data.map(::mangaParse)
+ return MangasPage(
+ mangas = mangas,
+ hasNextPage = dto.hasNextPage(),
+ )
+ }
+
+ // ================================= Latest ===========================================
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ val maxResult = 24
+ val url = "$apiUrl/${langOption.infix}/HomeLastUpdate".toHttpUrl().newBuilder()
+ .addPathSegment("$maxResult")
+ .addPathSegment("${page - 1}")
+ .build()
+ return GET(url, headers)
+ }
+
+ override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
+
+ // ================================= Search ===========================================
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val maxResult = 20
+ val url = "$apiUrl/${langOption.infix}/SeachPage/$maxResult/${page - 1}".toHttpUrl().newBuilder()
+ .addPathSegment(query)
+ .build()
+ return GET(url, headers)
+ }
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ if (query.startsWith(SEARCH_PREFIX)) {
+ val url = "$baseUrl/${langOption.infix}/${query.substringAfter(SEARCH_PREFIX)}"
+ return client.newCall(GET(url, headers))
+ .asObservableSuccess().map { response ->
+ val mangas = try { listOf(mangaDetailsParse(response)) } catch (_: Exception) { emptyList() }
+ MangasPage(mangas, false)
+ }
+ }
+ return super.fetchSearchManga(page, query, filters)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val dto = response.parseAs()
+ return MangasPage(
+ dto.mangas.map(::mangaParse),
+ false,
+ )
+ }
+
+ // ================================= Details ==========================================
+
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ val url = "$apiUrl/${langOption.infix}/getInfoManga".toHttpUrl().newBuilder()
+ .addPathSegment(manga.slug())
+ .build()
+ return GET(url, headers)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val dto = response.parseAs()
+ return mangaParse(dto.details)
+ }
+
+ override fun getMangaUrl(manga: SManga): String {
+ return baseUrl + manga.url.replace(langOption.infix, langOption.mangaSubstring)
+ }
+
+ // ================================= Chapter ==========================================
+
+ override fun fetchChapterList(manga: SManga): Observable> {
+ val chapters = mutableListOf()
+ var currentPage = 0
+ do {
+ val chaptersDto = fetchChapterListPageable(manga, currentPage++)
+ chapters += chaptersDto.data.map { chapter ->
+ SChapter.create().apply {
+ name = chapter.name
+ date_upload = chapter.date.toDate()
+ url = chapter.toChapterUrl(langOption.infix)
+ }
+ }
+ } while (chaptersDto.hasNextPage())
+ return Observable.just(chapters)
+ }
+
+ private fun fetchChapterListPageable(manga: SManga, page: Int): Pageable {
+ val maxResult = 100
+ val url = "$apiUrl/${langOption.infix}/GetChapterListFilter/${manga.slug()}/$maxResult/$page/all/${langOption.orderBy}"
+ return client.newCall(GET(url, headers)).execute()
+ .parseAs>()
+ }
+
+ override fun chapterListParse(response: Response) = throw UnsupportedOperationException()
+
+ // ================================= Pages ============================================
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val chapterSlug = chapter.url.substringAfter(langOption.infix)
+ val url = "$apiUrl/${langOption.infix}/GetImageChapter$chapterSlug"
+ return GET(url, headers)
+ }
+
+ override fun imageRequest(page: Page): Request {
+ val imageHeaders = headers.newBuilder()
+ .set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
+ .removeAll("Referer")
+ .build()
+ return super.imageRequest(page).newBuilder()
+ .headers(imageHeaders)
+ .build()
+ }
+
+ override fun pageListParse(response: Response): List {
+ val location = response.request.url.toString()
+ val dto = response.parseAs()
+ return dto.pages.mapIndexed { index, url ->
+ Page(index, location, imageUrl = url)
+ }
+ }
+
+ override fun imageUrlParse(response: Response): String = ""
+
+ // ================================= Utilities =======================================
+
+ private inline fun Response.parseAs(): T {
+ return json.decodeFromString(body.string())
+ }
+
+ private fun SManga.slug() = this.url.split("/").last()
+
+ private fun mangaParse(dto: MangaDto): SManga {
+ return SManga.create().apply {
+ title = dto.title
+ thumbnail_url = dto.thumbnailUrl
+ status = dto.status
+ url = "/${langOption.infix}/${dto.slug}"
+ genre = dto.genres
+ initialized = true
+ }
+ }
+
+ private fun String.toDate(): Long =
+ try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L }
+
+ companion object {
+ const val SEARCH_PREFIX = "slug:"
+ val baseApiUrl = "https://api.novelfull.us"
+ val apiUrl = "$baseApiUrl/api"
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ENGLISH)
+ }
+}
diff --git a/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedDto.kt b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedDto.kt
new file mode 100644
index 000000000..4ef2d8c42
--- /dev/null
+++ b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedDto.kt
@@ -0,0 +1,68 @@
+package eu.kanade.tachiyomi.extension.all.mangahosted
+
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+class MangaDetailsDto(private val data: Props) {
+ val details: MangaDto get() = data.details
+
+ @Serializable
+ class Props(
+ @SerialName("infoDoc") val details: MangaDto,
+ )
+}
+
+@Serializable
+open class Pageable(
+ var currentPage: Int,
+ var totalPage: Int,
+ val data: List,
+) {
+ fun hasNextPage() = (currentPage + 1) <= totalPage
+}
+
+@Serializable
+class ChapterDto(
+ val date: String,
+ @SerialName("idDoc") val slugManga: String,
+ @SerialName("idDetail") val id: String,
+ @SerialName("nameChapter") val name: String,
+) {
+ fun toChapterUrl(lang: String) = "/$lang/${this.slugManga}/$id"
+}
+
+@Serializable
+class MangaDto(
+ @SerialName("name") val title: String,
+ @SerialName("image") private val _thumbnailUrl: String,
+ @SerialName("idDoc") val slug: String,
+ @SerialName("genresName") val genres: String,
+ @SerialName("status") val _status: String,
+) {
+ val thumbnailUrl get() = "${MangaHosted.baseApiUrl}$_thumbnailUrl"
+
+ val status get() = when (_status) {
+ "ongoing" -> SManga.ONGOING
+ "completed" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+}
+
+@Serializable
+class SearchDto(
+ @SerialName("data")
+ val mangas: List,
+)
+
+@Serializable
+class PageDto(val `data`: Data) {
+ val pages: List get() = `data`.detailDocuments.source.split("#")
+
+ @Serializable
+ class Data(@SerialName("detail_documents") val detailDocuments: DetailDocuments)
+
+ @Serializable
+ class DetailDocuments(val source: String)
+}
diff --git a/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedFactory.kt b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedFactory.kt
new file mode 100644
index 000000000..5cd1d05b2
--- /dev/null
+++ b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedFactory.kt
@@ -0,0 +1,30 @@
+package eu.kanade.tachiyomi.extension.all.mangahosted
+
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class MangaHostedFactory : SourceFactory {
+ override fun createSources(): List = languages.map { MangaHosted(it) }
+}
+
+class LanguageOption(
+ val lang: String,
+ val infix: String = lang,
+ val mangaSubstring: String = infix,
+ val nameSuffix: String = "",
+ val orderBy: String = "DESC",
+)
+
+val languages = listOf(
+ LanguageOption("en", "manga", "scan"),
+ LanguageOption("en", "manga-v2", "kaka", " v2"),
+ LanguageOption("en", "comic", "comic-dc", " Comics"),
+ LanguageOption("es", "manga-spanish", "manga-es"),
+ LanguageOption("id", "manga-indo", "id"),
+ LanguageOption("it", "manga-italia", "manga-it"),
+ LanguageOption("ja", "mangaraw", "raw"),
+ LanguageOption("pt-BR", "manga-br", orderBy = "ASC"),
+ LanguageOption("ru", "manga-ru", "mangaru"),
+ LanguageOption("ru", "manga-ru-hentai", "hentai", " +18"),
+ LanguageOption("ru", "manga-ru-yaoi", "yaoi", " +18 Yaoi"),
+)
diff --git a/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedUrlActivity.kt b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedUrlActivity.kt
new file mode 100644
index 000000000..98c94933e
--- /dev/null
+++ b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedUrlActivity.kt
@@ -0,0 +1,36 @@
+package eu.kanade.tachiyomi.extension.all.mangahosted
+
+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 MangaHostedUrlActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+
+ if (pathSegments != null && pathSegments.size > 1) {
+ val intent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", slug(pathSegments))
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("UnionMangasUrlActivity", e.toString())
+ }
+ }
+
+ finish()
+ exitProcess(0)
+ }
+
+ private fun slug(pathSegments: List) =
+ "${MangaHosted.SEARCH_PREFIX}${pathSegments[1]}"
+}