This commit is contained in:
Admin
2020-02-02 09:44:13 +02:00
parent d46bbda0d0
commit 82fda9394d
32 changed files with 719 additions and 140 deletions

View File

@@ -10,81 +10,103 @@ import org.koitharu.kotatsu.utils.ext.*
class ReadmangaRepository(loaderContext: MangaLoaderContext) : MangaRepository(loaderContext) {
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tags: Set<String>?
): List<Manga> {
val doc = loaderContext.get("https://readmanga.me/list?sortType=updated&offset=$offset")
.parseHtml()
val root = doc.body().getElementById("mangaBox")
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
return root.select("div.tile").mapNotNull { node ->
val imgDiv = node.selectFirst("div.img") ?: return@mapNotNull null
val descDiv = node.selectFirst("div.desc") ?: return@mapNotNull null
val href = imgDiv.selectFirst("a").attr("href")?.withDomain("readmanga.me")
?: return@mapNotNull null
val title = descDiv.selectFirst("h3")?.selectFirst("a")?.text()
?: return@mapNotNull null
Manga(
id = href.longHashCode(),
url = href,
localizedTitle = title,
title = descDiv.selectFirst("h4")?.text() ?: title,
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(),
summary = "",
rating = safe {
node.selectFirst("div.rating")
?.attr("title")
?.substringBefore(' ')
?.toFloatOrNull()
?.div(10f)
} ?: Manga.NO_RATING,
tags = safe {
descDiv.selectFirst("div.tile-info")
?.select("a.element-link")
?.map {
MangaTag(
title = it.text(),
key = it.attr("href").substringAfterLast('/')
)
}?.toSet()
}.orEmpty(),
state = when {
node.selectFirst("div.tags")
?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED
else -> null
},
source = MangaSource.READMANGA_RU
)
}
}
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tags: Set<String>?
): List<Manga> {
val doc = loaderContext.get("https://readmanga.me/list?sortType=updated&offset=$offset")
.parseHtml()
val root = doc.body().getElementById("mangaBox")
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
return root.select("div.tile").mapNotNull { node ->
val imgDiv = node.selectFirst("div.img") ?: return@mapNotNull null
val descDiv = node.selectFirst("div.desc") ?: return@mapNotNull null
val href = imgDiv.selectFirst("a").attr("href")?.withDomain("readmanga.me")
?: return@mapNotNull null
val title = descDiv.selectFirst("h3")?.selectFirst("a")?.text()
?: return@mapNotNull null
Manga(
id = href.longHashCode(),
url = href,
localizedTitle = title,
title = descDiv.selectFirst("h4")?.text() ?: title,
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(),
summary = "",
rating = safe {
node.selectFirst("div.rating")
?.attr("title")
?.substringBefore(' ')
?.toFloatOrNull()
?.div(10f)
} ?: Manga.NO_RATING,
tags = safe {
descDiv.selectFirst("div.tile-info")
?.select("a.element-link")
?.map {
MangaTag(
title = it.text(),
key = it.attr("href").substringAfterLast('/')
)
}?.toSet()
}.orEmpty(),
state = when {
node.selectFirst("div.tags")
?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED
else -> null
},
source = MangaSource.READMANGA_RU
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.get(manga.url).parseHtml()
val root = doc.body().getElementById("mangaBox")
return manga.copy(
description = root.selectFirst("div.manga-description").firstChild()?.html()?.parseAsHtml(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
"data-full"
),
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("a")?.asReversed()?.mapIndexedNotNull { i, a ->
val href =
a.attr("href")?.withDomain("readmanga.me") ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.ownText(),
number = i + 1,
url = href,
source = MangaSource.READMANGA_RU
)
}
)
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.get(manga.url).parseHtml()
val root = doc.body().getElementById("mangaBox")
return manga.copy(
description = root.selectFirst("div.manga-description").firstChild()?.html()?.parseAsHtml(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
"data-full"
),
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("a")?.asReversed()?.mapIndexedNotNull { i, a ->
val href =
a.attr("href")?.withDomain("readmanga.me") ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.ownText(),
number = i + 1,
url = href,
source = MangaSource.READMANGA_RU
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = loaderContext.get(chapter.url).parseHtml()
val scripts = doc.select("script")
for (script in scripts) {
val data = script.html()
val pos = data.indexOf("rm_h.init")
if (pos == -1) {
continue
}
val json = data.substring(pos).substringAfter('[').substringBeforeLast(']')
val matches = Regex("\\[.*?]").findAll(json).toList()
val regex = Regex("['\"].*?['\"]")
return matches.map { x ->
val parts = regex.findAll(x.value).toList()
val url = parts[1].value.removeSurrounding('"', '\'') +
parts[2].value.removeSurrounding('"', '\'')
MangaPage(
id = url.longHashCode(),
url = url,
source = MangaSource.READMANGA_RU
)
}
}
throw ParseException("Pages list not found at ${chapter.url}")
}
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.ui.common
import android.app.Dialog
import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : DialogFragment() {
private var rootView: View? = null
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val view = activity?.layoutInflater?.inflate(layoutResId, null)
rootView = view
if (view != null) {
onViewCreated(view, savedInstanceState)
}
return AlertDialog.Builder(requireContext(), theme)
.setView(view)
.also(::onBuildDialog)
.create()
}
override fun onDestroyView() {
rootView = null
super.onDestroyView()
}
override fun getView(): View? {
return rootView
}
open fun onBuildDialog(builder: AlertDialog.Builder) = Unit
}

View File

@@ -1,9 +1,6 @@
package org.koitharu.kotatsu.ui.common
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.*
import moxy.MvpPresenter
import moxy.MvpView
import org.koin.core.KoinComponent
@@ -17,7 +14,7 @@ abstract class BasePresenter<V : MvpView> : MvpPresenter<V>(), KoinComponent, Co
get() = Dispatchers.Main + job
override fun onDestroy() {
coroutineContext.cancelChildren()
coroutineContext.cancel()
super.onDestroy()
}
}

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.reader.ReaderActivity
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView,
OnRecyclerItemClickListener<MangaChapter> {
@@ -19,6 +20,8 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
@Suppress("unused")
private val presenter by moxyPresenter { (activity as MangaDetailsActivity).presenter }
private var manga: Manga? = null
private lateinit var adapter: ChaptersAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -29,6 +32,7 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
}
override fun onMangaUpdated(manga: Manga) {
this.manga = manga
adapter.replaceData(manga.chapters.orEmpty())
}
@@ -41,6 +45,10 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
}
override fun onItemClick(item: MangaChapter, position: Int, view: View) {
//TODO
startActivity(ReaderActivity.newIntent(
context ?: return,
manga ?: return,
item.id
))
}
}

View File

@@ -1,36 +1,23 @@
package org.koitharu.kotatsu.ui.main.list
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import kotlinx.android.synthetic.main.dialog_list_mode.*
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.AlertDialogFragment
class ListModeSelectDialog : DialogFragment(), View.OnClickListener {
class ListModeSelectDialog : AlertDialogFragment(R.layout.dialog_list_mode), View.OnClickListener {
private val setting by inject<AppSettings>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.dialog_list_mode, container, false)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).apply {
setTitle(R.string.list_mode)
}
override fun onBuildDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.list_mode)
.setCancelable(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -57,7 +44,7 @@ class ListModeSelectDialog : DialogFragment(), View.OnClickListener {
companion object {
private const val TAG = "LIST_MODE"
private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG)
}

View File

@@ -0,0 +1,47 @@
package org.koitharu.kotatsu.ui.reader
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.dialog_chapters.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.ui.common.AlertDialogFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.details.ChaptersAdapter
import org.koitharu.kotatsu.utils.ext.withArgs
class ChaptersDialog : AlertDialogFragment(R.layout.dialog_chapters), OnRecyclerItemClickListener<MangaChapter> {
override fun onBuildDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.chapters)
.setNegativeButton(R.string.close, null)
.setCancelable(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
recyclerView_chapters.addItemDecoration(DividerItemDecoration(requireContext(), RecyclerView.VERTICAL))
recyclerView_chapters.adapter = ChaptersAdapter(this).apply {
arguments?.getParcelableArrayList<MangaChapter>(ARG_CHAPTERS)?.let(this::replaceData)
}
}
override fun onItemClick(item: MangaChapter, position: Int, view: View) {
}
companion object {
private const val TAG = "ChaptersDialog"
private const val ARG_CHAPTERS = "chapters"
fun show(fm: FragmentManager, chapters: List<MangaChapter>) = ChaptersDialog()
.withArgs(1) {
putParcelableArrayList(ARG_CHAPTERS, ArrayList(chapters))
}.show(fm, TAG)
}
}

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.ui.reader
import android.view.ViewGroup
import androidx.core.net.toUri
import androidx.core.view.isVisible
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.android.synthetic.main.item_page.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class PageHolder(parent: ViewGroup, private val loader: PageLoader) : BaseViewHolder<MangaPage>(parent, R.layout.item_page),
SubsamplingScaleImageView.OnImageEventListener {
init {
ssiv.setOnImageEventListener(this)
button_retry.setOnClickListener {
onBind(boundData ?: return@setOnClickListener)
}
}
override fun onBind(data: MangaPage) {
layout_error.isVisible = false
progressBar.show()
ssiv.recycle()
loader.load(data.url) {
ssiv.setImage(ImageSource.uri(it.toUri()))
}
}
override fun onReady() {
progressBar.hide()
}
override fun onImageLoadError(e: Exception) {
textView_error.text = e.getDisplayMessage(context.resources)
layout_error.isVisible = true
}
override fun onImageLoaded() = Unit
override fun onTileLoadError(e: Exception?) = Unit
override fun onPreviewReleased() = Unit
override fun onPreviewLoadError(e: Exception?) = Unit
}

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.ui.reader
import android.content.Context
import android.util.LongSparseArray
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.Closeable
import java.io.File
import kotlin.coroutines.CoroutineContext
class PageLoader(context: Context) : KoinComponent, CoroutineScope, DisposableHandle {
private val job = SupervisorJob()
private val tasks = HashMap<String, Job>()
private val okHttp by inject<OkHttpClient>()
private val cacheDir = File(context.externalCacheDir ?: context.cacheDir, "pages")
init {
if (!cacheDir.exists()) {
cacheDir.mkdir()
}
}
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
fun load(url: String, callback: (File) -> Unit) = launch {
val result = withContext(Dispatchers.IO) {
loadFile(url, false)
}
callback(result)
}
private suspend fun loadFile(url: String, force: Boolean): File {
val file = File(cacheDir, url.longHashCode().toString())
if (!force && file.exists()) {
return file
}
val request = Request.Builder()
.url(url)
.get()
.build()
okHttp.newCall(request).await().use { response ->
file.outputStream().use { out ->
response.body!!.byteStream().copyTo(out)
}
return file
}
}
override fun dispose() {
coroutineContext.cancel()
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.ui.reader
import android.view.ViewGroup
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
class PagesAdapter(private val loader: PageLoader) : BaseRecyclerAdapter<MangaPage>() {
override fun onCreateViewHolder(parent: ViewGroup) = PageHolder(parent, loader)
override fun onGetItemId(item: MangaPage) = item.id
}

View File

@@ -0,0 +1,99 @@
package org.koitharu.kotatsu.ui.reader
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_reader.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.utils.ext.showDialog
class ReaderActivity : BaseActivity(), ReaderView {
private val presenter by moxyPresenter { ReaderPresenter() }
private val manga by lazy(LazyThreadSafetyMode.NONE) {
intent.getParcelableExtra<Manga>(EXTRA_MANGA)!!
}
private val chapterId by lazy(LazyThreadSafetyMode.NONE) {
intent.getLongExtra(EXTRA_CHAPTER_ID, 0L)
}
private lateinit var loader: PageLoader
private lateinit var adapter: PagesAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_reader)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
bottomBar.inflateMenu(R.menu.opt_reader_bottom)
val chapter = manga.chapters?.find { x -> x.id == chapterId }
if (chapter == null) {
// TODO
finish()
return
}
title = chapter.name
manga.chapters?.run {
supportActionBar?.subtitle = getString(R.string.chapter_d_of_d, chapter.number, size)
}
loader = PageLoader(this)
adapter = PagesAdapter(loader)
pager.adapter = adapter
presenter.loadChapter(chapter)
}
override fun onDestroy() {
loader.dispose()
super.onDestroy()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_reader_top, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when(item.itemId) {
R.id.action_chapters -> {
ChaptersDialog.show(supportFragmentManager, manga.chapters.orEmpty())
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onPagesReady(pages: List<MangaPage>) {
adapter.replaceData(pages)
}
override fun onLoadingStateChanged(isLoading: Boolean) {
layout_loading.isVisible = isLoading
}
override fun onError(e: Exception) {
showDialog {
setTitle(R.string.error_occurred)
setMessage(e.message)
setPositiveButton(R.string.close, null)
}
}
companion object {
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTER_ID = "chapter_id"
fun newIntent(context: Context, manga: Manga, chapterId: Long) = Intent(context, ReaderActivity::class.java)
.putExtra(EXTRA_MANGA, manga)
.putExtra(EXTRA_CHAPTER_ID, chapterId)
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.ui.reader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.InjectViewState
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BasePresenter
@InjectViewState
class ReaderPresenter() : BasePresenter<ReaderView>() {
fun loadChapter(chapter: MangaChapter) {
launch {
viewState.onLoadingStateChanged(isLoading = true)
try {
val pages = withContext(Dispatchers.IO) {
MangaProviderFactory.create(chapter.source).getPages(chapter)
}
viewState.onPagesReady(pages)
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
viewState.onError(e)
} finally {
viewState.onLoadingStateChanged(isLoading = false)
}
}
}
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.ui.reader
import moxy.MvpView
import moxy.viewstate.strategy.AddToEndSingleStrategy
import moxy.viewstate.strategy.OneExecutionStateStrategy
import moxy.viewstate.strategy.StateStrategyType
import org.koitharu.kotatsu.core.model.MangaPage
interface ReaderView : MvpView {
@StateStrategyType(AddToEndSingleStrategy::class)
fun onPagesReady(pages: List<MangaPage>)
@StateStrategyType(AddToEndSingleStrategy::class)
fun onLoadingStateChanged(isLoading: Boolean)
@StateStrategyType(OneExecutionStateStrategy::class)
fun onError(e: Exception)
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
import androidx.appcompat.app.AlertDialog
fun Context.showDialog(block: AlertDialog.Builder.() -> Unit): AlertDialog {
return AlertDialog.Builder(this)
.apply(block)
.show()
}

View File

@@ -20,4 +20,16 @@ fun String.withDomain(domain: String, ssl: Boolean = true) = when {
append(this@withDomain)
}
else -> this
}
fun String.removeSurrounding(vararg chars: Char): String {
if (length == 0) {
return this
}
for (c in chars) {
if (first() == c && last() == c) {
return substring(1, length - 1)
}
}
return this
}