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 +}