diff --git a/src/fr/manganova/AndroidManifest.xml b/src/fr/manganova/AndroidManifest.xml
new file mode 100644
index 000000000..8ce70cc98
--- /dev/null
+++ b/src/fr/manganova/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/fr/manganova/build.gradle b/src/fr/manganova/build.gradle
new file mode 100644
index 000000000..a25b712ec
--- /dev/null
+++ b/src/fr/manganova/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'MangaNova'
+ extClass = '.MangaNova'
+ extVersionCode = 1
+ isNsfw = false
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/fr/manganova/res/mipmap-hdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..916f83016
Binary files /dev/null and b/src/fr/manganova/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/fr/manganova/res/mipmap-mdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..8e094cf07
Binary files /dev/null and b/src/fr/manganova/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/fr/manganova/res/mipmap-xhdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..02dc66e0f
Binary files /dev/null and b/src/fr/manganova/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/fr/manganova/res/mipmap-xxhdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..f3965a447
Binary files /dev/null and b/src/fr/manganova/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/fr/manganova/res/mipmap-xxxhdpi/ic_launcher.png b/src/fr/manganova/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..ec8ebb87d
Binary files /dev/null and b/src/fr/manganova/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNova.kt b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNova.kt
new file mode 100644
index 000000000..72a8de83d
--- /dev/null
+++ b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNova.kt
@@ -0,0 +1,176 @@
+package eu.kanade.tachiyomi.extension.fr.manganova
+
+import android.webkit.CookieManager
+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 keiyoushi.utils.parseAs
+import okhttp3.Headers
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import java.net.URI
+
+class MangaNova : HttpSource() {
+
+ override val name = "MangaNova"
+ override val baseUrl = "https://www.manga-nova.com"
+ val api = "https://api.manga-nova.com"
+ override val lang = "fr"
+ override val supportsLatest = true
+
+ private val webViewCookieManager: CookieManager by lazy { CookieManager.getInstance() }
+
+ // Default static token, shouldn't change
+ private val defaultToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZW1icmVfaWQiOjAsIm1lbWJyZV91c2VybmFtZSI6bnVsbCwiaWF0IjoxNzA1NTc5MDQ1fQ.51qivLd2l3OKbDaYYzlntZJNnreRSBWO7p5Nsa2mAsA"
+
+ override fun headersBuilder(): Headers.Builder {
+ val cookies = webViewCookieManager.getCookie(baseUrl)
+ var token = defaultToken
+ if (cookies != null && cookies.isNotEmpty()) {
+ val cookieHeaders = cookies.split("; ").toList()
+ val tokenCookie = cookieHeaders.firstOrNull { it.startsWith("token=") }
+ if (tokenCookie != null) {
+ token = tokenCookie.replace("token=", "")
+ }
+ }
+ return super.headersBuilder()
+ .add("Authorization", "Bearer $token")
+ }
+
+ // Popular
+ override fun popularMangaRequest(page: Int): Request {
+ return GET("$api/catalogue/", headers)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
+
+ override fun latestUpdatesRequest(page: Int): Request = popularMangaRequest(page)
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val catalogue = response.parseAs()
+ val mangaList = mutableListOf()
+
+ for (serie in catalogue.newSeries) {
+ mangaList.add(serie.toDetailedSManga())
+ }
+ return MangasPage(mangaList, false)
+ }
+
+ // Search
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = if (query.isNotBlank()) {
+ "$api/catalogue/#$query"
+ } else {
+ "$api/catalogue/"
+ }
+ return GET(url, headers)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val catalogue = response.parseAs()
+ val mangaList = mutableListOf()
+
+ val fragment = response.request.url.fragment
+ val searchQuery = fragment ?: ""
+
+ if (searchQuery.startsWith("SLUG:")) {
+ val serie = catalogue.series.find { it.slug == searchQuery.removePrefix("SLUG:") }
+ if (serie != null) {
+ mangaList.add(serie.toDetailedSManga())
+ }
+ return MangasPage(mangaList, false)
+ }
+
+ for (serie in catalogue.series) {
+ if (searchQuery.isBlank() ||
+ serie.title.contains(searchQuery, ignoreCase = true) ||
+ serie.titleJap.contains(searchQuery, ignoreCase = true)
+ ) {
+ mangaList.add(serie.toDetailedSManga())
+ }
+ }
+
+ return MangasPage(mangaList, false)
+ }
+
+ // Details
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ val splitedPath = URI(manga.url).path.split("/")
+ val slug = splitedPath[2]
+ return client.newCall(GET("$api/catalogue/", headers))
+ .asObservableSuccess()
+ .map { response ->
+ mangaDetailsParse(response, slug)
+ }
+ }
+
+ private fun mangaDetailsParse(response: Response, slug: String = ""): SManga {
+ val catalogue = response.parseAs()
+ val series = catalogue.series
+ val serie = series.find { it.slug == slug }
+ if (serie == null) {
+ throw UnsupportedOperationException("Bad SLUG")
+ }
+ return serie.toDetailedSManga()
+ }
+
+ // Pages
+ override fun pageListRequest(chapter: SChapter): Request {
+ val splitedPath = URI(chapter.url).path.split("/")
+ val slug = splitedPath[2]
+ val chapterNumber = splitedPath[4]
+ return GET("$api/mangas/$slug/chapitres/$chapterNumber", headers)
+ }
+
+ override fun pageListParse(response: Response): List {
+ val images = response.parseAs().images
+ return images.mapIndexed { index, pageData ->
+ Page(pageData.pageNumber, imageUrl = pageData.image)
+ }
+ }
+
+ // Chapters
+ override fun chapterListRequest(manga: SManga): Request {
+ val splitedPath = URI(manga.url).path.split("/")
+ val slug = splitedPath[2]
+ return GET("$api/mangas/$slug", headers)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val serie = response.parseAs().serie
+ val categories = serie.chapitres
+ val chapterList = mutableListOf()
+
+ val currentEpoch = System.currentTimeMillis()
+ for (category in categories) {
+ for (chapter in category.chapitres) {
+ if (chapter.amount != 0) continue
+
+ val chapter = SChapter.create().apply {
+ name = category.title + " - " + chapter.title + " - " + chapter.subTitle
+ setUrlWithoutDomain("$baseUrl/lecture-en-ligne/${serie.slug}/chapitre/${chapter.number}")
+ chapter_number = chapter.number
+ date_upload = currentEpoch + (chapter.availableTime * 1000L)
+ }
+ chapterList.add(chapter)
+ }
+ }
+
+ return chapterList.sortedByDescending { it.chapter_number }
+ }
+
+ // Unsupported stuff
+ override fun imageUrlParse(response: Response): String {
+ throw UnsupportedOperationException()
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ throw UnsupportedOperationException()
+ }
+}
diff --git a/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaDto.kt b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaDto.kt
new file mode 100644
index 000000000..4dff6086a
--- /dev/null
+++ b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaDto.kt
@@ -0,0 +1,122 @@
+package eu.kanade.tachiyomi.extension.fr.manganova
+
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.floatOrNull
+
+/**
+ * Data Transfer Objects for MangaNova extension
+ */
+
+object SafeFloatDeserializer : KSerializer {
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("SafeFloat", PrimitiveKind.FLOAT)
+
+ override fun serialize(encoder: Encoder, value: Float) {
+ encoder.encodeFloat(value)
+ }
+
+ override fun deserialize(decoder: Decoder): Float {
+ val jsonDecoder = decoder as? JsonDecoder ?: return try {
+ decoder.decodeFloat()
+ } catch (_: Exception) {
+ -1F
+ }
+
+ return try {
+ val element = jsonDecoder.decodeJsonElement()
+ when (element) {
+ is JsonPrimitive -> {
+ element.floatOrNull ?: element.content.toFloatOrNull() ?: -1F
+ }
+
+ else -> -1F
+ }
+ } catch (_: Exception) {
+ -1F
+ }
+ }
+}
+
+@Serializable
+class Catalogue(
+ val series: List,
+ @SerialName("new_series")
+ val newSeries: List,
+)
+
+@Serializable
+class Serie(
+ val title: String,
+ @SerialName("title_jap")
+ val titleJap: String,
+ val slug: String,
+ val description: String,
+ val genres: String,
+ val poster: String,
+ val author: String,
+ val dessinateur: String,
+ val running: Int,
+)
+
+@Serializable
+class DetailedSerieContainer(
+ val serie: DetailedSerie,
+)
+
+@Serializable
+class DetailedSerie(
+ val slug: String,
+ val chapitres: List,
+)
+
+@Serializable
+class Category(
+ val title: String,
+ val chapitres: List,
+)
+
+@Serializable
+class Chapter(
+ val title: String,
+ @SerialName("sub_title")
+ val subTitle: String,
+ @Serializable(with = SafeFloatDeserializer::class)
+ val number: Float,
+ @SerialName("available_time")
+ val availableTime: Long,
+ val amount: Int,
+)
+
+@Serializable
+class ChapterDetails(
+ val images: List,
+)
+
+@Serializable
+class Image(
+ val image: String,
+ @SerialName("page_number")
+ val pageNumber: Int,
+)
+
+// DTO to SManga extension functions
+fun Serie.toDetailedSManga(): SManga = SManga.create().apply {
+ title = this@toDetailedSManga.title
+ description = this@toDetailedSManga.description
+ artist = this@toDetailedSManga.dessinateur
+ author = this@toDetailedSManga.author
+ status = if (this@toDetailedSManga.running == 0) SManga.COMPLETED else SManga.ONGOING
+ thumbnail_url = this@toDetailedSManga.poster
+ url = "/manga/${this@toDetailedSManga.slug}"
+ genre = this@toDetailedSManga.genres.replace(",", ", ")
+}
diff --git a/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaUrlActivity.kt b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaUrlActivity.kt
new file mode 100644
index 000000000..61cb98ef5
--- /dev/null
+++ b/src/fr/manganova/src/eu/kanade/tachiyomi/extension/fr/manganova/MangaNovaUrlActivity.kt
@@ -0,0 +1,39 @@
+package eu.kanade.tachiyomi.extension.fr.manganova
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+/**
+ * Springboard that accepts https://www.manganova.fr/lecture-en-ligne/xxxxxx &&
+ * https://www.manganova.fr/manga/xxxxxx intents and redirects them to
+ * the main Tachiyomi process.
+ */
+class MangaNovaUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size >= 2) {
+ val slug = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "SLUG:$slug")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("MangaNovaUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("MangaNovaUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}