diff --git a/src/ja/ganma/AndroidManifest.xml b/src/ja/ganma/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/ja/ganma/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest package="eu.kanade.tachiyomi.extension" />
diff --git a/src/ja/ganma/build.gradle b/src/ja/ganma/build.gradle
new file mode 100644
index 000000000..5c0835975
--- /dev/null
+++ b/src/ja/ganma/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+    extName = 'GANMA!'
+    pkgNameSuffix = 'ja.ganma'
+    extClass = '.GanmaFactory'
+    extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/ja/ganma/res/mipmap-hdpi/ic_launcher.png b/src/ja/ganma/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..cfde0e8ae
Binary files /dev/null and b/src/ja/ganma/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/ja/ganma/res/mipmap-mdpi/ic_launcher.png b/src/ja/ganma/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..7c470f43e
Binary files /dev/null and b/src/ja/ganma/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/ja/ganma/res/mipmap-xhdpi/ic_launcher.png b/src/ja/ganma/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..74b20b2b9
Binary files /dev/null and b/src/ja/ganma/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/ja/ganma/res/mipmap-xxhdpi/ic_launcher.png b/src/ja/ganma/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..842d63c8e
Binary files /dev/null and b/src/ja/ganma/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/ja/ganma/res/mipmap-xxxhdpi/ic_launcher.png b/src/ja/ganma/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..8e1c57783
Binary files /dev/null and b/src/ja/ganma/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/ja/ganma/res/web_hi_res_512.png b/src/ja/ganma/res/web_hi_res_512.png
new file mode 100644
index 000000000..416635d5b
Binary files /dev/null and b/src/ja/ganma/res/web_hi_res_512.png differ
diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt
new file mode 100644
index 000000000..ccb5bc40a
--- /dev/null
+++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt
@@ -0,0 +1,126 @@
+package eu.kanade.tachiyomi.extension.ja.ganma
+
+import androidx.preference.EditTextPreference
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservable
+import eu.kanade.tachiyomi.network.asObservableSuccess
+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.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.json.decodeFromStream
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+
+open class Ganma : HttpSource(), ConfigurableSource {
+    override val id = sourceId
+    override val name = sourceName
+    override val lang = sourceLang
+    override val versionId = sourceVersionId
+    override val baseUrl = "https://ganma.jp"
+    override val supportsLatest = true
+
+    override fun headersBuilder() = super.headersBuilder().add("X-From", baseUrl)
+
+    override fun popularMangaRequest(page: Int) =
+        when (page) {
+            1 -> GET("$baseUrl/api/1.0/ranking", headers)
+            else -> GET("$baseUrl/api/1.1/ranking?flag=Finish", headers)
+        }
+
+    override fun popularMangaParse(response: Response): MangasPage {
+        val list: List<Magazine> = response.parseAs()
+        return MangasPage(list.map { it.toSManga() }, false)
+    }
+
+    override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/2.2/top", headers)
+
+    override fun latestUpdatesParse(response: Response): MangasPage {
+        val list = response.parseAs<Top>().boxes.flatMap { it.panels }
+            .filter { it.newestStoryItem != null }
+            .sortedByDescending { it.newestStoryItem!!.release }
+        return MangasPage(list.map { it.toSManga() }, false)
+    }
+
+    override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
+        val pageNumber = when (filters.size) {
+            0 -> 1
+            else -> (filters[0] as TypeFilter).state + 1
+        }
+        return fetchPopularManga(pageNumber).map { mangasPage ->
+            MangasPage(mangasPage.mangas.filter { it.title.contains(query) }, false)
+        }
+    }
+
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
+        throw UnsupportedOperationException("Not used.")
+
+    override fun searchMangaParse(response: Response): MangasPage =
+        throw UnsupportedOperationException("Not used.")
+
+    // navigate Webview to web page
+    override fun mangaDetailsRequest(manga: SManga) =
+        GET("$baseUrl/${manga.url.alias()}", headers)
+
+    protected open fun realMangaDetailsRequest(manga: SManga) =
+        GET("$baseUrl/api/1.0/magazines/web/${manga.url.alias()}", headers)
+
+    override fun chapterListRequest(manga: SManga) = realMangaDetailsRequest(manga)
+
+    override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
+        client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess()
+            .map { mangaDetailsParse(it).apply { initialized = true } }
+
+    override fun mangaDetailsParse(response: Response): SManga =
+        response.parseAs<Magazine>().toSMangaDetails()
+
+    protected open fun List<SChapter>.sortedDescending() = this.asReversed()
+
+    override fun chapterListParse(response: Response): List<SChapter> =
+        response.parseAs<Magazine>().getSChapterList().sortedDescending()
+
+    override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
+        client.newCall(pageListRequest(chapter)).asObservable()
+            .map { pageListParse(chapter, it) }
+
+    override fun pageListRequest(chapter: SChapter) =
+        GET("$baseUrl/api/1.0/magazines/web/${chapter.url.alias()}", headers)
+
+    protected open fun pageListParse(chapter: SChapter, response: Response): List<Page> {
+        val manga: Magazine = response.parseAs()
+        val chapterId = chapter.url.substringAfter('/')
+        return manga.items.find { it.id == chapterId }!!.toPageList()
+    }
+
+    final override fun pageListParse(response: Response): List<Page> =
+        throw UnsupportedOperationException("Not used.")
+
+    override fun imageUrlParse(response: Response): String =
+        throw UnsupportedOperationException("Not used.")
+
+    protected open class TypeFilter : Filter.Select<String>("Type", arrayOf("Popular", "Completed"))
+
+    override fun getFilterList() = FilterList(TypeFilter())
+
+    protected inline fun <reified T> Response.parseAs(): T = use {
+        json.decodeFromStream<Result<T>>(it.body!!.byteStream()).root
+    }
+
+    override fun setupPreferenceScreen(screen: PreferenceScreen) {
+        EditTextPreference(screen.context).apply {
+            key = METADATA_PREF
+            title = "Metadata (Debug)"
+            setDefaultValue("")
+            setOnPreferenceChangeListener { _, newValue ->
+                preferences.edit().putString(METADATA_PREF, newValue as String).apply()
+                true
+            }
+        }.let { screen.addPreference(it) }
+    }
+}
diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaApp.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaApp.kt
new file mode 100644
index 000000000..b11ca6dcb
--- /dev/null
+++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaApp.kt
@@ -0,0 +1,135 @@
+package eu.kanade.tachiyomi.extension.ja.ganma
+
+import android.widget.Toast
+import androidx.preference.PreferenceScreen
+import androidx.preference.SwitchPreferenceCompat
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.Cookie
+import okhttp3.CookieJar
+import okhttp3.FormBody
+import okhttp3.Headers
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.Response
+
+class GanmaApp(private val metadata: Metadata) : Ganma() {
+
+    override val client = network.client.newBuilder()
+        .cookieJar(Cookies(metadata.baseUrl.toHttpUrl().host, metadata.cookieName))
+        .build()
+
+    private val appHeaders: Headers = Headers.Builder().apply {
+        add("User-Agent", metadata.userAgent)
+        add("X-From", metadata.baseUrl)
+    }.build()
+
+    override fun chapterListRequest(manga: SManga): Request {
+        checkSession()
+        return GET(metadata.baseUrl + String.format(metadata.magazineUrl, manga.url.mangaId()), appHeaders)
+    }
+
+    override fun List<SChapter>.sortedDescending() = this
+
+    override fun pageListRequest(chapter: SChapter): Request {
+        checkSession()
+        val (mangaId, chapterId) = chapter.url.chapterDir()
+        return GET(metadata.baseUrl + String.format(metadata.storyUrl, mangaId, chapterId), appHeaders)
+    }
+
+    override fun pageListParse(chapter: SChapter, response: Response): List<Page> =
+        try {
+            response.parseAs<AppStory>().toPageList()
+        } catch (e: Exception) {
+            throw Exception("Chapter not available!")
+        }
+
+    private fun checkSession() {
+        val expiration = preferences.getLong(SESSION_EXPIRATION_PREF, 0)
+        if (System.currentTimeMillis() + 60 * 1000 <= expiration) return // at least 1 minute
+        var field1 = preferences.getString(TOKEN_FIELD1_PREF, "")!!
+        var field2 = preferences.getString(TOKEN_FIELD2_PREF, "")!!
+        if (field1.isEmpty() || field2.isEmpty()) {
+            val response = client.newCall(POST(metadata.baseUrl + metadata.tokenUrl, appHeaders)).execute()
+            val token: JsonObject = response.parseAs()
+            field1 = token[metadata.tokenField1]!!.jsonPrimitive.content
+            field2 = token[metadata.tokenField2]!!.jsonPrimitive.content
+        }
+        val requestBody = FormBody.Builder().apply {
+            add(metadata.tokenField1, field1)
+            add(metadata.tokenField2, field2)
+        }.build()
+        val response = client.newCall(POST(metadata.baseUrl + metadata.sessionUrl, appHeaders, requestBody)).execute()
+        val session: Session = response.parseAs()
+        preferences.edit().apply {
+            putString(TOKEN_FIELD1_PREF, field1)
+            putString(TOKEN_FIELD2_PREF, field2)
+            putLong(SESSION_EXPIRATION_PREF, session.expire)
+        }.apply()
+    }
+
+    private fun clearSession(clearToken: Boolean) {
+        preferences.edit().apply {
+            putString(SESSION_PREF, "")
+            putLong(SESSION_EXPIRATION_PREF, 0)
+            if (clearToken) {
+                putString(TOKEN_FIELD1_PREF, "")
+                putString(TOKEN_FIELD2_PREF, "")
+            }
+        }.apply()
+    }
+
+    override fun setupPreferenceScreen(screen: PreferenceScreen) {
+        super.setupPreferenceScreen(screen)
+        SwitchPreferenceCompat(screen.context).apply {
+            title = "Clear session"
+            setOnPreferenceClickListener {
+                clearSession(clearToken = false)
+                Toast.makeText(screen.context, "Session cleared", Toast.LENGTH_SHORT).show()
+                false
+            }
+        }.let { screen.addPreference(it) }
+        SwitchPreferenceCompat(screen.context).apply {
+            title = "Clear token"
+            setOnPreferenceClickListener {
+                clearSession(clearToken = true)
+                Toast.makeText(screen.context, "Token cleared", Toast.LENGTH_SHORT).show()
+                false
+            }
+        }.let { screen.addPreference(it) }
+    }
+
+    class Cookies(private val host: String, private val name: String) : CookieJar {
+        override fun loadForRequest(url: HttpUrl): List<Cookie> {
+            if (url.host != host) return emptyList()
+            val cookie = Cookie.Builder().apply {
+                name(name)
+                value(preferences.getString(SESSION_PREF, "")!!)
+                domain(host)
+            }.build()
+            return listOf(cookie)
+        }
+
+        override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
+            if (url.host != host) return
+            for (cookie in cookies) {
+                if (cookie.name == name) {
+                    preferences.edit().putString(SESSION_PREF, cookie.value).apply()
+                }
+            }
+        }
+    }
+
+    companion object {
+        private const val TOKEN_FIELD1_PREF = "TOKEN_FIELD1"
+        private const val TOKEN_FIELD2_PREF = "TOKEN_FIELD2"
+        private const val SESSION_PREF = "SESSION"
+        private const val SESSION_EXPIRATION_PREF = "SESSION_EXPIRATION"
+    }
+}
diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt
new file mode 100644
index 000000000..31aa51073
--- /dev/null
+++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt
@@ -0,0 +1,195 @@
+package eu.kanade.tachiyomi.extension.ja.ganma
+
+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
+
+@Serializable
+data class Result<T>(val root: T)
+
+// Manga
+@Serializable
+data class Magazine(
+    val id: String,
+    val alias: String? = null,
+    val title: String,
+    val description: String? = null,
+    val squareImage: File? = null,
+//  val squareWithLogoImage: File? = null,
+    val author: Author? = null,
+    val newestStoryItem: Story? = null,
+    val flags: Flags? = null,
+    val announcement: Announcement? = null,
+    val items: List<Story> = emptyList(),
+) {
+    fun toSManga() = SManga.create().apply {
+        url = "${alias!!}#$id"
+        title = this@Magazine.title
+        thumbnail_url = squareImage!!.url
+    }
+
+    fun toSMangaDetails() = toSManga().apply {
+        author = this@Magazine.author?.penName
+        val flagsText = flags?.toText()
+        description = generateDescription(flagsText)
+        status = when {
+            flags?.isFinish == true -> SManga.COMPLETED
+            !flagsText.isNullOrEmpty() -> SManga.ONGOING
+            else -> SManga.UNKNOWN
+        }
+    }
+
+    private fun generateDescription(flagsText: String?): String {
+        val result = mutableListOf<String>()
+        if (!flagsText.isNullOrEmpty()) result.add("Updates: $flagsText")
+        if (announcement != null) result.add("Announcement: ${announcement.text}")
+        if (description != null) result.add(description)
+        return result.joinToString("\n\n")
+    }
+
+    fun getSChapterList() = items.map {
+        SChapter.create().apply {
+            url = "${alias!!}#$id/${it.id ?: it.storyId}"
+            val prefix = if (it.kind == "free") "" else "🔒 "
+            name = if (it.subtitle != null) "$prefix${it.title} ${it.subtitle}" else "$prefix${it.title}"
+            date_upload = it.releaseStart ?: -1
+        }
+    }
+}
+
+fun String.alias() = this.substringBefore('#')
+fun String.mangaId() = this.substringAfter('#')
+fun String.chapterDir(): Pair<String, String> =
+    with(this.substringAfter('#')) {
+        // this == [mangaId-UUID]/[chapterId-UUID]
+        Pair(substring(0, 36), substring(37, 37 + 36))
+    }
+
+// Chapter
+@Serializable
+data class Story(
+    val id: String? = null,
+    val storyId: String? = null,
+    val title: String,
+    val subtitle: String? = null,
+    val release: Long = 0,
+    val releaseStart: Long? = null,
+    val page: Directory? = null,
+    val afterwordImage: File? = null,
+    val kind: String? = null,
+) {
+    fun toPageList(): List<Page> {
+        val result = page!!.toPageList()
+        if (afterwordImage != null) {
+            result.add(Page(result.size, imageUrl = afterwordImage.url))
+        }
+        return result
+    }
+}
+
+@Serializable
+data class File(val url: String)
+
+@Serializable
+data class Author(val penName: String? = null)
+
+@Serializable
+data class Top(val boxes: List<Box>)
+
+@Serializable
+data class Box(val panels: List<Magazine>)
+
+@Serializable
+data class Flags(
+    val isMonday: Boolean = false,
+    val isTuesday: Boolean = false,
+    val isWednesday: Boolean = false,
+    val isThursday: Boolean = false,
+    val isFriday: Boolean = false,
+    val isSaturday: Boolean = false,
+    val isSunday: Boolean = false,
+
+    val isWeekly: Boolean = false,
+    val isEveryOtherWeek: Boolean = false,
+    val isThreeConsecutiveWeeks: Boolean = false,
+    val isMonthly: Boolean = false,
+
+    val isFinish: Boolean = false,
+//  val isMGAward: Boolean = false,
+//  val isNew: Boolean = false,
+) {
+    fun toText(): String {
+        val result = mutableListOf<String>()
+        val days = mutableListOf<String>()
+        arrayOf(isWeekly, isEveryOtherWeek, isThreeConsecutiveWeeks, isMonthly)
+            .forEachIndexed { i, value -> if (value) result.add(weekText[i]) }
+        arrayOf(isMonday, isTuesday, isWednesday, isThursday, isFriday, isSaturday, isSunday)
+            .forEachIndexed { i, value -> if (value) days.add(dayText[i] + "s") }
+        if (days.size == 7) {
+            result.add("every day")
+        } else if (days.size != 0) {
+            days[0] = "on " + days[0]
+            result += days
+        }
+        return result.joinToString(", ")
+    }
+
+    companion object {
+        private val weekText = arrayOf("every week", "every other week", "three weeks in a row", "every month")
+        private val dayText = arrayOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
+    }
+}
+
+@Serializable
+data class Announcement(val text: String)
+
+@Serializable
+data class Directory(
+    val baseUrl: String,
+    val token: String,
+    val files: List<String>,
+) {
+    fun toPageList(): MutableList<Page> =
+        files.mapIndexedTo(ArrayList(files.size + 1)) { i, file ->
+            Page(i, imageUrl = "$baseUrl$file?$token")
+        }
+}
+
+@Serializable
+data class AppStory(val pages: List<AppPage>) {
+    fun toPageList(): List<Page> {
+        val result = ArrayList<Page>(pages.size)
+        pages.forEach {
+            if (it.imageURL != null)
+                result.add(Page(result.size, imageUrl = it.imageURL.url))
+            else if (it.afterwordImageURL != null)
+                result.add(Page(result.size, imageUrl = it.afterwordImageURL.url))
+        }
+        return result
+    }
+}
+
+@Serializable
+data class AppPage(
+    val imageURL: File? = null,
+    val afterwordImageURL: File? = null,
+)
+
+// Please keep the data private to support the site,
+// otherwise they might change their APIs.
+@Serializable
+data class Metadata(
+    val userAgent: String,
+    val baseUrl: String,
+    val tokenUrl: String,
+    val tokenField1: String,
+    val tokenField2: String,
+    val sessionUrl: String,
+    val cookieName: String,
+    val magazineUrl: String,
+    val storyUrl: String,
+)
+
+@Serializable
+data class Session(val expire: Long)
diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaFactory.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaFactory.kt
new file mode 100644
index 000000000..862c4afbe
--- /dev/null
+++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaFactory.kt
@@ -0,0 +1,48 @@
+package eu.kanade.tachiyomi.extension.ja.ganma
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.util.Base64
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceFactory
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.security.MessageDigest
+
+// source ID needed before class construction
+// generated by running main() below
+const val sourceId = 8045942616403978870
+const val sourceName = "GANMA!"
+const val sourceLang = "ja"
+const val sourceVersionId = 1 // != extension version code
+const val METADATA_PREF = "METADATA"
+
+val json: Json = Injekt.get()
+val preferences: SharedPreferences =
+    Injekt.get<Application>().getSharedPreferences("source_$sourceId", 0x0000)
+
+class GanmaFactory : SourceFactory {
+    override fun createSources(): List<Source> {
+        val source = try {
+            val metadata = preferences.getString(METADATA_PREF, "")!!
+                .also { if (it.isEmpty()) throw Exception() }
+                .let { Base64.decode(it.toByteArray(), Base64.DEFAULT) }
+            GanmaApp(json.decodeFromString(String(metadata)))
+        } catch (e: Exception) {
+            Ganma()
+        }
+        return listOf(source)
+    }
+}
+
+fun main() {
+    println(getSourceId()) // unfortunately there's no constexpr in Kotlin
+}
+
+fun getSourceId() = run { // copied from HttpSource
+    val key = "${sourceName.lowercase()}/$sourceLang/$sourceVersionId"
+    val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
+    (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
+}