pull/1/head
Admin 6 years ago
parent d46bbda0d0
commit 82fda9394d

@ -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'
} }

@ -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>

@ -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}")
}
} }

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

@ -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()
} }
} }

@ -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
))
} }
} }

@ -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)
} }

@ -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)
}
}

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

@ -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()
}
}

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

@ -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)
}
}

@ -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)
}
}
}
}

@ -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)
}

@ -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()
}

@ -21,3 +21,15 @@ fun String.withDomain(domain: String, ssl: Boolean = true) = when {
} }
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
}

@ -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>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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" />

@ -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>

@ -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"

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

@ -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>

@ -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>

@ -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>

@ -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>

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

@ -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)
}
} }

@ -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,
Loading…
Cancel
Save