diff --git a/src/fr/scanvforg/AndroidManifest.xml b/src/fr/scanvforg/AndroidManifest.xml
new file mode 100644
index 000000000..96bcc3495
--- /dev/null
+++ b/src/fr/scanvforg/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/fr/scanvforg/build.gradle b/src/fr/scanvforg/build.gradle
new file mode 100644
index 000000000..c619764b1
--- /dev/null
+++ b/src/fr/scanvforg/build.gradle
@@ -0,0 +1,7 @@
+ext {
+ extName = "scanvf.org"
+ extClass = ".ScanVF"
+ extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/fr/scanvforg/res/mipmap-hdpi/ic_launcher.png b/src/fr/scanvforg/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..a264f402c
Binary files /dev/null and b/src/fr/scanvforg/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/fr/scanvforg/res/mipmap-mdpi/ic_launcher.png b/src/fr/scanvforg/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..71914a9f7
Binary files /dev/null and b/src/fr/scanvforg/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/fr/scanvforg/res/mipmap-xhdpi/ic_launcher.png b/src/fr/scanvforg/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..b04680a18
Binary files /dev/null and b/src/fr/scanvforg/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/fr/scanvforg/res/mipmap-xxhdpi/ic_launcher.png b/src/fr/scanvforg/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..a991fbc37
Binary files /dev/null and b/src/fr/scanvforg/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/fr/scanvforg/res/mipmap-xxxhdpi/ic_launcher.png b/src/fr/scanvforg/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..2492fce5c
Binary files /dev/null and b/src/fr/scanvforg/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/fr/scanvforg/src/eu/kanade/tachiyomi/extension/fr/scanvforg/ScanVF.kt b/src/fr/scanvforg/src/eu/kanade/tachiyomi/extension/fr/scanvforg/ScanVF.kt
new file mode 100644
index 000000000..0f3431b08
--- /dev/null
+++ b/src/fr/scanvforg/src/eu/kanade/tachiyomi/extension/fr/scanvforg/ScanVF.kt
@@ -0,0 +1,231 @@
+package eu.kanade.tachiyomi.extension.fr.scanvforg
+
+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.ParsedHttpSource
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class ScanVF : ParsedHttpSource() {
+
+ override val name = "scanvf.org"
+
+ override val baseUrl = "https://scanvf.org"
+
+ override val lang = "fr"
+
+ override val supportsLatest = true
+
+ override val client = network.cloudflareClient
+
+ private val json: Json by injectLazy()
+
+ private val dateFormat by lazy {
+ SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
+ }
+
+ override fun headersBuilder() = super.headersBuilder()
+ .add("Referer", "$baseUrl/")
+
+ override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga?q=p&page=$page", headers)
+
+ override fun popularMangaSelector() = "div.series"
+
+ override fun popularMangaFromElement(element: Element) = SManga.create().apply {
+ val anchor = element.selectFirst("a.link-series")!!
+
+ setUrlWithoutDomain(anchor.attr("href"))
+ title = anchor.text()
+ thumbnail_url = element.selectFirst("div.series-img-wrapper img")?.absUrl("data-src")
+ }
+
+ override fun popularMangaNextPageSelector() = "ul.pagination a.page-link[rel=next]"
+
+ override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/manga?q=u&page=$page", headers)
+
+ override fun latestUpdatesSelector() = popularMangaSelector()
+
+ override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
+
+ override fun fetchSearchManga(
+ page: Int,
+ query: String,
+ filters: FilterList,
+ ): Observable {
+ return if (query.startsWith(PREFIX_SLUG_SEARCH)) {
+ val slug = query.removePrefix(PREFIX_SLUG_SEARCH)
+ val url = "/manga/$slug"
+ val manga = SManga.create().apply {
+ this.url = url
+ }
+
+ client.newCall(mangaDetailsRequest(manga))
+ .asObservableSuccess()
+ .map {
+ MangasPage(
+ listOf(
+ mangaDetailsParse(it).apply {
+ this.url = url
+ },
+ ),
+ false,
+ )
+ }
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = baseUrl.toHttpUrl().newBuilder().apply {
+ addPathSegment("search")
+ addQueryParameter("q", query)
+ }.build()
+
+ return GET(url, headers)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val document = Jsoup.parseBodyFragment(
+ json.parseToJsonElement(response.body.string()).jsonPrimitive.content,
+ baseUrl,
+ )
+
+ val manga = document.select(searchMangaSelector()).map {
+ searchMangaFromElement(it)
+ }
+
+ return MangasPage(manga, false)
+ }
+
+ override fun searchMangaSelector() = popularMangaSelector()
+
+ override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun searchMangaNextPageSelector() = null
+
+ override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+ title = document.selectFirst("div.card h1")!!.text().removeSuffix(" Scan")
+ author = document.select("div.card-series-detail div:contains(Auteur) div.badge").joinToString { it.text() }
+ genre = document.select("div.card-series-detail div:contains(Categories) div.badge").joinToString { it.text() }
+ description = document.select("main div.card div:has(h5:contains(Résumé)) p").text()
+ thumbnail_url = document.selectFirst("div.series-picture-lg img")?.absUrl("src")
+ }
+
+ override fun chapterListSelector() = "div.chapters-list div.col-chapter"
+
+ override fun chapterFromElement(element: Element) = SChapter.create().apply {
+ val h5 = element.selectFirst("h5")!!
+
+ setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
+ name = h5.ownText()
+ date_upload = try {
+ dateFormat.parse(h5.selectFirst("div")!!.text())!!.time
+ } catch (e: Exception) {
+ 0L
+ }
+ }
+
+ override fun pageListParse(document: Document): List {
+ // This should be the URL we got from chapterListParse, i.e. /scan/:id
+ // However, if there's a page number stuck onto it, we remove it first,
+ // just in case.
+ val url = document.location().removeSuffix("/1")
+ val pageCount = findPageCount(url)
+
+ return (1..pageCount).map {
+ Page(it, "$url/$it")
+ }
+ }
+
+ override fun imageUrlParse(document: Document): String =
+ document.selectFirst("div.book-page img")!!.absUrl("src")
+
+ // Disable redirects, since an out of range page request redirects us back
+ // to the manga details page.
+ private val pageListClient = client.newBuilder()
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .build()
+
+ private fun findPageCount(url: String): Int {
+ val path = url.toHttpUrl().encodedPath
+
+ // Since there's nothing to tell us about the page count (or I'm blind),
+ // we resort to good old binary search.
+ //
+ // The website redirects us back to the manga details page if we have
+ // gone too far.
+ //
+ // We can't be sure of the maximum number of pages this source has
+ // (sometimes stuff is uploaded in volumes) so before we begin we need
+ // to find the upper bound by doubling up until we get a redirect.
+ var high = 24
+
+ while (true) {
+ val (code, location) = pageListClient.newCall(GET("$url/$high", headers)).execute()
+ .use {
+ it.code to it.headers["location"]
+ }
+
+ if (code == 301 || code == 302) {
+ // For some reason, on the last page, the website redirects to the same URL
+ // with a ?bypass=1 query parameter added.
+ if (location!!.startsWith(path)) {
+ return high
+ }
+
+ break
+ }
+
+ high *= 2
+ }
+
+ // Now we begin the actual binary search.
+ var low = 1
+ var pageCount: Int
+
+ while (true) {
+ pageCount = low + (high - low) / 2
+
+ val (code, location) = pageListClient.newCall(GET("$url/$pageCount", headers)).execute()
+ .use {
+ it.code to it.headers["location"]
+ }
+
+ if (code == 301 || code == 302) {
+ // For some reason, on the last page, the website redirects to the same URL
+ // with a ?bypass=1 query parameter added.
+ if (location!!.startsWith(path)) {
+ return pageCount
+ }
+
+ high = pageCount - 1
+ continue
+ }
+
+ low = pageCount + 1
+ }
+ }
+
+ companion object {
+ internal const val PREFIX_SLUG_SEARCH = "slug:"
+ }
+}
diff --git a/src/fr/scanvforg/src/eu/kanade/tachiyomi/extension/fr/scanvforg/ScanVFUrlActivity.kt b/src/fr/scanvforg/src/eu/kanade/tachiyomi/extension/fr/scanvforg/ScanVFUrlActivity.kt
new file mode 100644
index 000000000..101b65e12
--- /dev/null
+++ b/src/fr/scanvforg/src/eu/kanade/tachiyomi/extension/fr/scanvforg/ScanVFUrlActivity.kt
@@ -0,0 +1,40 @@
+package eu.kanade.tachiyomi.extension.fr.scanvforg
+
+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://scanvf.org/manga/ intents
+ * and redirects them to the main Tachiyomi process.
+ */
+class ScanVFUrlActivity : Activity() {
+ private val tag = javaClass.simpleName
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val id = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${ScanVF.PREFIX_SLUG_SEARCH}$id")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e(tag, "Could not start activity", e)
+ }
+ } else {
+ Log.e(tag, "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}