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

@@ -36,6 +36,10 @@ android {
disable 'MissingTranslation' disable 'MissingTranslation'
abortOnError false abortOnError false
} }
testOptions {
unitTests.includeAndroidResources = true
unitTests.returnDefaultValues = true
}
} }
androidExtensions { androidExtensions {
experimental = true experimental = true
@@ -74,5 +78,6 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
testImplementation 'androidx.test:core:1.2.0'
testImplementation 'org.mockito:mockito-core:2.23.0' testImplementation 'org.mockito:mockito-core:2.23.0'
} }

View File

@@ -13,14 +13,15 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity android:name="org.koitharu.kotatsu.ui.main.MainActivity"> <activity android:name=".ui.main.MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name="org.koitharu.kotatsu.ui.details.MangaDetailsActivity" /> <activity android:name=".ui.details.MangaDetailsActivity" />
<activity android:name=".ui.reader.ReaderActivity" />
</application> </application>
</manifest> </manifest>

View File

@@ -10,81 +10,103 @@ import org.koitharu.kotatsu.utils.ext.*
class ReadmangaRepository(loaderContext: MangaLoaderContext) : MangaRepository(loaderContext) { class ReadmangaRepository(loaderContext: MangaLoaderContext) : MangaRepository(loaderContext) {
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, sortOrder: SortOrder?,
tags: Set<String>? tags: Set<String>?
): List<Manga> { ): List<Manga> {
val doc = loaderContext.get("https://readmanga.me/list?sortType=updated&offset=$offset") val doc = loaderContext.get("https://readmanga.me/list?sortType=updated&offset=$offset")
.parseHtml() .parseHtml()
val root = doc.body().getElementById("mangaBox") val root = doc.body().getElementById("mangaBox")
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root") ?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
return root.select("div.tile").mapNotNull { node -> return root.select("div.tile").mapNotNull { node ->
val imgDiv = node.selectFirst("div.img") ?: return@mapNotNull null val imgDiv = node.selectFirst("div.img") ?: return@mapNotNull null
val descDiv = node.selectFirst("div.desc") ?: return@mapNotNull null val descDiv = node.selectFirst("div.desc") ?: return@mapNotNull null
val href = imgDiv.selectFirst("a").attr("href")?.withDomain("readmanga.me") val href = imgDiv.selectFirst("a").attr("href")?.withDomain("readmanga.me")
?: return@mapNotNull null ?: return@mapNotNull null
val title = descDiv.selectFirst("h3")?.selectFirst("a")?.text() val title = descDiv.selectFirst("h3")?.selectFirst("a")?.text()
?: return@mapNotNull null ?: return@mapNotNull null
Manga( Manga(
id = href.longHashCode(), id = href.longHashCode(),
url = href, url = href,
localizedTitle = title, localizedTitle = title,
title = descDiv.selectFirst("h4")?.text() ?: title, title = descDiv.selectFirst("h4")?.text() ?: title,
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(), coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(),
summary = "", summary = "",
rating = safe { rating = safe {
node.selectFirst("div.rating") node.selectFirst("div.rating")
?.attr("title") ?.attr("title")
?.substringBefore(' ') ?.substringBefore(' ')
?.toFloatOrNull() ?.toFloatOrNull()
?.div(10f) ?.div(10f)
} ?: Manga.NO_RATING, } ?: Manga.NO_RATING,
tags = safe { tags = safe {
descDiv.selectFirst("div.tile-info") descDiv.selectFirst("div.tile-info")
?.select("a.element-link") ?.select("a.element-link")
?.map { ?.map {
MangaTag( MangaTag(
title = it.text(), title = it.text(),
key = it.attr("href").substringAfterLast('/') key = it.attr("href").substringAfterLast('/')
) )
}?.toSet() }?.toSet()
}.orEmpty(), }.orEmpty(),
state = when { state = when {
node.selectFirst("div.tags") node.selectFirst("div.tags")
?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED ?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED
else -> null else -> null
}, },
source = MangaSource.READMANGA_RU source = MangaSource.READMANGA_RU
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.get(manga.url).parseHtml() val doc = loaderContext.get(manga.url).parseHtml()
val root = doc.body().getElementById("mangaBox") val root = doc.body().getElementById("mangaBox")
return manga.copy( return manga.copy(
description = root.selectFirst("div.manga-description").firstChild()?.html()?.parseAsHtml(), description = root.selectFirst("div.manga-description").firstChild()?.html()?.parseAsHtml(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr( largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
"data-full" "data-full"
), ),
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table") chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("a")?.asReversed()?.mapIndexedNotNull { i, a -> ?.select("a")?.asReversed()?.mapIndexedNotNull { i, a ->
val href = val href =
a.attr("href")?.withDomain("readmanga.me") ?: return@mapIndexedNotNull null a.attr("href")?.withDomain("readmanga.me") ?: return@mapIndexedNotNull null
MangaChapter( MangaChapter(
id = href.longHashCode(), id = href.longHashCode(),
name = a.ownText(), name = a.ownText(),
number = i + 1, number = i + 1,
url = href, url = href,
source = MangaSource.READMANGA_RU source = MangaSource.READMANGA_RU
) )
} }
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates. 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 package org.koitharu.kotatsu.ui.common
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import moxy.MvpPresenter import moxy.MvpPresenter
import moxy.MvpView import moxy.MvpView
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
@@ -17,7 +14,7 @@ abstract class BasePresenter<V : MvpView> : MvpPresenter<V>(), KoinComponent, Co
get() = Dispatchers.Main + job get() = Dispatchers.Main + job
override fun onDestroy() { override fun onDestroy() {
coroutineContext.cancelChildren() coroutineContext.cancel()
super.onDestroy() 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.core.model.MangaChapter
import org.koitharu.kotatsu.ui.common.BaseFragment import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.reader.ReaderActivity
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView, class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView,
OnRecyclerItemClickListener<MangaChapter> { OnRecyclerItemClickListener<MangaChapter> {
@@ -19,6 +20,8 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
@Suppress("unused") @Suppress("unused")
private val presenter by moxyPresenter { (activity as MangaDetailsActivity).presenter } private val presenter by moxyPresenter { (activity as MangaDetailsActivity).presenter }
private var manga: Manga? = null
private lateinit var adapter: ChaptersAdapter private lateinit var adapter: ChaptersAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -29,6 +32,7 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
} }
override fun onMangaUpdated(manga: Manga) { override fun onMangaUpdated(manga: Manga) {
this.manga = manga
adapter.replaceData(manga.chapters.orEmpty()) 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) { 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 package org.koitharu.kotatsu.ui.main.list
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import kotlinx.android.synthetic.main.dialog_list_mode.* import kotlinx.android.synthetic.main.dialog_list_mode.*
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode 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>() private val setting by inject<AppSettings>()
override fun onCreateView( override fun onBuildDialog(builder: AlertDialog.Builder) {
inflater: LayoutInflater, builder.setTitle(R.string.list_mode)
container: ViewGroup?, .setCancelable(true)
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 onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -57,7 +44,7 @@ class ListModeSelectDialog : DialogFragment(), View.OnClickListener {
companion object { companion object {
private const val TAG = "LIST_MODE" private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG) 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) append(this@withDomain)
} }
else -> this 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
} }

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:textColorPrimary"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M7,10l5,5 5,-5z" />
</vector>

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:tint="@color/error"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z" />
</vector>

View File

@@ -1,14 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path android:pathData="M0,0h24v24H0V0z M 0,0" />
<path
android:fillColor="@android:color/white"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4V6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
</group>
</vector>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.circularreveal.CircularRevealFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/dim"
android:fitsSystemWindows="true"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.MaterialComponents.Light" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/bottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@color/dim"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.MaterialComponents.Light" />
<LinearLayout
android:id="@+id/layout_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true" />
<TextView
android:id="@+id/textView_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/loading_"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2" />
</LinearLayout>
</com.google.android.material.circularreveal.CircularRevealFrameLayout>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/recyclerView_chapters"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_chapter" />

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:layout_marginStart="60dp"
android:layout_marginEnd="60dp"
android:gravity="center_horizontal"
android:id="@+id/layout_error" >
<TextView
android:drawableTop="@drawable/ic_error_large"
android:id="@+id/textView_error"
android:layout_width="wrap_content"
tools:text="@tools:sample/lorem[6]"
android:drawablePadding="12dp"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:gravity="center_horizontal"
android:layout_height="wrap_content" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:text="@string/try_again" />
</LinearLayout>
</FrameLayout>

View File

@@ -8,7 +8,7 @@
android:title="@string/local_storage" /> android:title="@string/local_storage" />
<item <item
android:id="@+id/nav_favourites" android:id="@+id/nav_favourites"
android:icon="@drawable/ic_star_half" android:icon="@drawable/ic_favourites"
android:title="@string/favourites" /> android:title="@string/favourites" />
<item <item
android:id="@+id/nav_history" android:id="@+id/nav_history"

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
</menu>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_chapters"
android:icon="@drawable/ic_drop_down"
android:title="@string/chapters"
android:orderInCategory="0"
app:showAsAction="always" />
</menu>

View File

@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="colorPrimary">#0288D1</color> <color name="primary">#0288D1</color>
<color name="colorPrimaryDark">#0D47A1</color> <color name="primary_dark">#0D47A1</color>
<color name="colorAccent">#F4511E</color> <color name="accent">#F4511E</color>
<color name="dim">#99000000</color>
<color name="error">#D32F2F</color>
</resources> </resources>

View File

@@ -15,4 +15,8 @@
<string name="list_mode">List mode</string> <string name="list_mode">List mode</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="remote_sources">Remote sources</string> <string name="remote_sources">Remote sources</string>
<string name="loading_">Loading…</string>
<string name="chapter_d_of_d">Chapter %d of %d</string>
<string name="close">Close</string>
<string name="try_again">Try again</string>
</resources> </resources>

View File

@@ -3,9 +3,9 @@
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar"> <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. --> <!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/colorAccent</item> <item name="colorAccent">@color/accent</item>
</style> </style>
</resources> </resources>

View File

@@ -3,4 +3,8 @@ package org.koitharu.kotatsu.parsers
interface MangaParserTest { interface MangaParserTest {
fun testMangaList() fun testMangaList()
fun testMangaDetails()
fun testMangaPages()
} }

View File

@@ -8,27 +8,46 @@ import org.junit.runner.RunWith
import org.koitharu.kotatsu.domain.repository.ReadmangaRepository import org.koitharu.kotatsu.domain.repository.ReadmangaRepository
import org.koitharu.kotatsu.parsers.MangaParserTest import org.koitharu.kotatsu.parsers.MangaParserTest
import org.koitharu.kotatsu.parsers.RepositoryTestEnvironment import org.koitharu.kotatsu.parsers.RepositoryTestEnvironment
import org.koitharu.kotatsu.utils.MyAsserts import org.koitharu.kotatsu.utils.TestUtil
import org.mockito.junit.MockitoJUnitRunner import org.mockito.junit.MockitoJUnitRunner
@RunWith(MockitoJUnitRunner::class) @RunWith(MockitoJUnitRunner::class)
class ReadmangaRuTest : MangaParserTest { class ReadmangaRuTest : MangaParserTest {
@Test @Test
override fun testMangaList() { override fun testMangaList() {
val list = runBlocking { repository.getList(1) } val list = runBlocking { repository.getList(1) }
Assert.assertTrue(list.size == 70) Assert.assertTrue(list.size == 70)
val item = list[40] val item = list[40]
Assert.assertTrue(item.title.isNotEmpty()) Assert.assertTrue(item.title.isNotEmpty())
Assert.assertTrue(item.rating in 0f..1f) Assert.assertTrue(item.rating in 0f..1f)
MyAsserts.assertValidUrl(item.url) TestUtil.assertValidUrl(item.url)
MyAsserts.assertValidUrl(item.coverUrl) TestUtil.assertValidUrl(item.coverUrl)
} }
companion object : RepositoryTestEnvironment() { @Test
override fun testMangaDetails() {
val manga = runBlocking { repository.getDetails(repository.getList(1).last()) }
Assert.assertNotNull(manga.largeCoverUrl)
TestUtil.assertValidUrl(manga.largeCoverUrl!!)
Assert.assertNotNull(manga.chapters)
val chapter = manga.chapters!!.last()
TestUtil.assertValidUrl(chapter.url)
}
@JvmStatic @Test
@BeforeClass override fun testMangaPages() {
fun setUp() = initialize(ReadmangaRepository::class.java) val chapter = runBlocking { repository.getDetails(repository.getList(1).last()).chapters!!.first() }
} val pages = runBlocking { repository.getPages(chapter) }
Assert.assertFalse(pages.isEmpty())
TestUtil.assertValidUrl(runBlocking { repository.getPageFullUrl(pages.first()) })
TestUtil.assertValidUrl(runBlocking { repository.getPageFullUrl(pages.last()) })
}
companion object : RepositoryTestEnvironment() {
@JvmStatic
@BeforeClass
fun setUp() = initialize(ReadmangaRepository::class.java)
}
} }

View File

@@ -4,7 +4,7 @@ import org.junit.Assert
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
object MyAsserts { object TestUtil {
private val VALID_RESPONSE_CODES = arrayOf( private val VALID_RESPONSE_CODES = arrayOf(
HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_OK,