Merge branch 'devel' into feature/suggestions
commit
920ea6959c
@ -1,16 +0,0 @@
|
|||||||
<component name="ProjectDictionaryState">
|
|
||||||
<dictionary name="admin">
|
|
||||||
<words>
|
|
||||||
<w>amoled</w>
|
|
||||||
<w>chucker</w>
|
|
||||||
<w>desu</w>
|
|
||||||
<w>failsafe</w>
|
|
||||||
<w>koin</w>
|
|
||||||
<w>kotatsu</w>
|
|
||||||
<w>manga</w>
|
|
||||||
<w>snackbar</w>
|
|
||||||
<w>upsert</w>
|
|
||||||
<w>webtoon</w>
|
|
||||||
</words>
|
|
||||||
</dictionary>
|
|
||||||
</component>
|
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?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_leaks"
|
||||||
|
android:title="@string/leak_canary_display_activity_label"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<bool name="leak_canary_add_launcher_icon">false</bool>
|
||||||
|
</resources>
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.base.ui.list
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
class AdapterUpdater<T>(oldList: List<T>, newList: List<T>, getId: (T) -> Long) {
|
|
||||||
|
|
||||||
private val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
getId(oldList[oldItemPosition]) == getId(newList[newItemPosition])
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
Objects.equals(oldList[oldItemPosition], newList[newItemPosition])
|
|
||||||
|
|
||||||
override fun getOldListSize() = oldList.size
|
|
||||||
|
|
||||||
override fun getNewListSize() = newList.size
|
|
||||||
})
|
|
||||||
|
|
||||||
operator fun invoke(adapter: RecyclerView.Adapter<*>) {
|
|
||||||
diff.dispatchUpdatesTo(adapter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.base.ui.list
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
abstract class BaseViewHolder<T, E, B : ViewBinding> protected constructor(val binding: B) :
|
|
||||||
RecyclerView.ViewHolder(binding.root), KoinComponent {
|
|
||||||
|
|
||||||
var boundData: T? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
val context get() = itemView.context!!
|
|
||||||
|
|
||||||
fun bind(data: T, extra: E) {
|
|
||||||
boundData = data
|
|
||||||
onBind(data, extra)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requireData(): T {
|
|
||||||
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun onRecycled() = Unit
|
|
||||||
|
|
||||||
abstract fun onBind(data: T, extra: E)
|
|
||||||
}
|
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list.decor
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.content.res.getColorOrThrow
|
||||||
|
import androidx.core.view.children
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@SuppressLint("PrivateResource")
|
||||||
|
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||||
|
|
||||||
|
private val bounds = Rect()
|
||||||
|
private val thickness: Int
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
init {
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
val ta = context.obtainStyledAttributes(
|
||||||
|
null,
|
||||||
|
materialR.styleable.MaterialDivider,
|
||||||
|
materialR.attr.materialDividerStyle,
|
||||||
|
materialR.style.Widget_Material3_MaterialDivider,
|
||||||
|
)
|
||||||
|
paint.color = ta.getColorOrThrow(materialR.styleable.MaterialDivider_dividerColor)
|
||||||
|
thickness = ta.getDimensionPixelSize(
|
||||||
|
materialR.styleable.MaterialDivider_dividerThickness,
|
||||||
|
context.resources.getDimensionPixelSize(materialR.dimen.material_divider_thickness),
|
||||||
|
)
|
||||||
|
ta.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun getItemOffsets(
|
||||||
|
outRect: Rect,
|
||||||
|
view: View,
|
||||||
|
parent: RecyclerView,
|
||||||
|
state: RecyclerView.State,
|
||||||
|
) {
|
||||||
|
outRect.set(0, thickness, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO implement for horizontal lists on demand
|
||||||
|
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
||||||
|
if (parent.layoutManager == null || thickness == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canvas.save()
|
||||||
|
val left: Float
|
||||||
|
val right: Float
|
||||||
|
if (parent.clipToPadding) {
|
||||||
|
left = parent.paddingLeft.toFloat()
|
||||||
|
right = (parent.width - parent.paddingRight).toFloat()
|
||||||
|
canvas.clipRect(
|
||||||
|
left,
|
||||||
|
parent.paddingTop.toFloat(),
|
||||||
|
right,
|
||||||
|
(parent.height - parent.paddingBottom).toFloat()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
left = 0f
|
||||||
|
right = parent.width.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous: RecyclerView.ViewHolder? = null
|
||||||
|
for (child in parent.children) {
|
||||||
|
val holder = parent.getChildViewHolder(child)
|
||||||
|
if (previous != null && shouldDrawDivider(previous, holder)) {
|
||||||
|
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||||
|
val top: Float = bounds.top + child.translationY
|
||||||
|
val bottom: Float = top + thickness
|
||||||
|
canvas.drawRect(left, top, right, bottom, paint)
|
||||||
|
}
|
||||||
|
previous = holder
|
||||||
|
}
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun shouldDrawDivider(
|
||||||
|
above: RecyclerView.ViewHolder,
|
||||||
|
below: RecyclerView.ViewHolder,
|
||||||
|
): Boolean
|
||||||
|
}
|
||||||
@ -1,58 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.base.ui.list.decor
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.view.View
|
|
||||||
import androidx.core.view.children
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.koitharu.kotatsu.utils.ext.getThemeDrawable
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
class ItemTypeDividerDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
|
||||||
|
|
||||||
private val divider = context.getThemeDrawable(android.R.attr.listDivider)
|
|
||||||
private val bounds = Rect()
|
|
||||||
|
|
||||||
override fun getItemOffsets(
|
|
||||||
outRect: Rect, view: View,
|
|
||||||
parent: RecyclerView, state: RecyclerView.State
|
|
||||||
) {
|
|
||||||
outRect.set(0, divider?.intrinsicHeight ?: 0, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
|
||||||
if (parent.layoutManager == null || divider == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val adapter = parent.adapter ?: return
|
|
||||||
canvas.save()
|
|
||||||
val left: Int
|
|
||||||
val right: Int
|
|
||||||
if (parent.clipToPadding) {
|
|
||||||
left = parent.paddingLeft
|
|
||||||
right = parent.width - parent.paddingRight
|
|
||||||
canvas.clipRect(
|
|
||||||
left, parent.paddingTop, right,
|
|
||||||
parent.height - parent.paddingBottom
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
left = 0
|
|
||||||
right = parent.width
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastItemType = -1
|
|
||||||
for (child in parent.children) {
|
|
||||||
val itemType = adapter.getItemViewType(parent.getChildAdapterPosition(child))
|
|
||||||
if (lastItemType != -1 && itemType != lastItemType) {
|
|
||||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
|
||||||
val top: Int = bounds.top + child.translationY.roundToInt()
|
|
||||||
val bottom: Int = top + divider.intrinsicHeight
|
|
||||||
divider.setBounds(left, top, right, bottom)
|
|
||||||
divider.draw(canvas)
|
|
||||||
}
|
|
||||||
lastItemType = itemType
|
|
||||||
}
|
|
||||||
canvas.restore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.base.ui.list.decor
|
|
||||||
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.core.view.children
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.utils.ext.inflate
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
/**
|
|
||||||
* https://github.com/paetztm/recycler_view_headers
|
|
||||||
*/
|
|
||||||
class SectionItemDecoration(
|
|
||||||
private val isSticky: Boolean,
|
|
||||||
private val callback: Callback
|
|
||||||
) : RecyclerView.ItemDecoration() {
|
|
||||||
|
|
||||||
private var headerView: TextView? = null
|
|
||||||
private var headerOffset: Int = 0
|
|
||||||
|
|
||||||
override fun getItemOffsets(
|
|
||||||
outRect: Rect,
|
|
||||||
view: View,
|
|
||||||
parent: RecyclerView,
|
|
||||||
state: RecyclerView.State
|
|
||||||
) {
|
|
||||||
if (headerOffset == 0) {
|
|
||||||
headerOffset = parent.resources.getDimensionPixelSize(R.dimen.header_height)
|
|
||||||
}
|
|
||||||
val pos = parent.getChildAdapterPosition(view)
|
|
||||||
outRect.set(0, if (callback.isSection(pos)) headerOffset else 0, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
|
||||||
super.onDrawOver(c, parent, state)
|
|
||||||
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_filter_header).also {
|
|
||||||
headerView = it
|
|
||||||
}
|
|
||||||
fixLayoutSize(textView, parent)
|
|
||||||
|
|
||||||
for (child in parent.children) {
|
|
||||||
val pos = parent.getChildAdapterPosition(child)
|
|
||||||
if (callback.isSection(pos)) {
|
|
||||||
textView.text = callback.getSectionTitle(pos) ?: continue
|
|
||||||
c.save()
|
|
||||||
if (isSticky) {
|
|
||||||
c.translate(
|
|
||||||
0f,
|
|
||||||
max(0f, (child.top - textView.height).toFloat())
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
c.translate(
|
|
||||||
0f,
|
|
||||||
(child.top - textView.height).toFloat()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
textView.draw(c)
|
|
||||||
c.restore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measures the header view to make sure its size is greater than 0 and will be drawn
|
|
||||||
* https://yoda.entelect.co.za/view/9627/how-to-android-recyclerview-item-decorations
|
|
||||||
*/
|
|
||||||
private fun fixLayoutSize(view: View, parent: ViewGroup) {
|
|
||||||
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
|
|
||||||
val heightSpec =
|
|
||||||
View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
|
|
||||||
|
|
||||||
val childWidth = ViewGroup.getChildMeasureSpec(
|
|
||||||
widthSpec,
|
|
||||||
parent.paddingLeft + parent.paddingRight,
|
|
||||||
view.layoutParams.width
|
|
||||||
)
|
|
||||||
val childHeight = ViewGroup.getChildMeasureSpec(
|
|
||||||
heightSpec,
|
|
||||||
parent.paddingTop + parent.paddingBottom,
|
|
||||||
view.layoutParams.height
|
|
||||||
)
|
|
||||||
view.measure(childWidth, childHeight)
|
|
||||||
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Callback {
|
|
||||||
|
|
||||||
fun isSection(position: Int): Boolean
|
|
||||||
|
|
||||||
fun getSectionTitle(position: Int): CharSequence?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.IdRes
|
||||||
|
import androidx.core.view.children
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
|
||||||
|
class CheckableButtonGroup @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = 0,
|
||||||
|
) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener {
|
||||||
|
|
||||||
|
var onCheckedChangeListener: OnCheckedChangeListener? = null
|
||||||
|
|
||||||
|
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
|
||||||
|
if (child is MaterialButton) {
|
||||||
|
child.setOnClickListener(this)
|
||||||
|
}
|
||||||
|
super.addView(child, index, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
setCheckedId(v.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCheckedId(@IdRes viewRes: Int) {
|
||||||
|
children.forEach {
|
||||||
|
(it as? MaterialButton)?.isChecked = it.id == viewRes
|
||||||
|
}
|
||||||
|
onCheckedChangeListener?.onCheckedChanged(this, viewRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface OnCheckedChangeListener {
|
||||||
|
fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2018 Google LLC
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.koitharu.kotatsu.base.ui.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.view.postDelayed
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom snackbar implementation allowing more control over placement and entry/exit animations.
|
||||||
|
*
|
||||||
|
* Xtimms: Well, my sufferings over the Snackbar in [DetailsActivity] will go away forever... Thanks, Google.
|
||||||
|
*
|
||||||
|
* https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/FadingSnackbar.kt
|
||||||
|
*/
|
||||||
|
class FadingSnackbar @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private val message: TextView
|
||||||
|
private val action: Button
|
||||||
|
|
||||||
|
init {
|
||||||
|
val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true)
|
||||||
|
message = view.findViewById(R.id.snackbar_text)
|
||||||
|
action = view.findViewById(R.id.snackbar_action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismiss() {
|
||||||
|
if (visibility == VISIBLE && alpha == 1f) {
|
||||||
|
animate()
|
||||||
|
.alpha(0f)
|
||||||
|
.withEndAction { visibility = GONE }
|
||||||
|
.duration = EXIT_DURATION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun show(
|
||||||
|
messageText: CharSequence? = null,
|
||||||
|
@StringRes actionId: Int? = null,
|
||||||
|
longDuration: Boolean = true,
|
||||||
|
actionClick: () -> Unit = { dismiss() },
|
||||||
|
dismissListener: () -> Unit = { }
|
||||||
|
) {
|
||||||
|
message.text = messageText
|
||||||
|
if (actionId != null) {
|
||||||
|
action.run {
|
||||||
|
visibility = VISIBLE
|
||||||
|
text = context.getString(actionId)
|
||||||
|
setOnClickListener {
|
||||||
|
actionClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
action.visibility = GONE
|
||||||
|
}
|
||||||
|
alpha = 0f
|
||||||
|
visibility = VISIBLE
|
||||||
|
animate()
|
||||||
|
.alpha(1f)
|
||||||
|
.duration = ENTER_DURATION
|
||||||
|
val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION
|
||||||
|
postDelayed(showDuration) {
|
||||||
|
dismiss()
|
||||||
|
dismissListener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ENTER_DURATION = 300L
|
||||||
|
private const val EXIT_DURATION = 200L
|
||||||
|
private const val SHORT_DURATION = 1_500L
|
||||||
|
private const val LONG_DURATION = 2_750L
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.koitharu.kotatsu.utils.ext.map
|
||||||
|
|
||||||
|
class GraphQLException(private val errors: JSONArray) : RuntimeException() {
|
||||||
|
|
||||||
|
val messages = errors.map {
|
||||||
|
it.getString("message")
|
||||||
|
}
|
||||||
|
|
||||||
|
override val message: String
|
||||||
|
get() = messages.joinToString("\n")
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import coil.map.Mapper
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
|
||||||
|
class FaviconMapper() : Mapper<Uri, HttpUrl> {
|
||||||
|
|
||||||
|
override fun map(data: Uri): HttpUrl {
|
||||||
|
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
|
||||||
|
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
|
||||||
|
return repo.getFaviconUrl().toHttpUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handles(data: Uri) = data.scheme == "favicon"
|
||||||
|
}
|
||||||
@ -0,0 +1,215 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.core.model.*
|
||||||
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 20
|
||||||
|
private const val CONTENT_RATING =
|
||||||
|
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
||||||
|
private const val LOCALE_FALLBACK = "en"
|
||||||
|
|
||||||
|
class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||||
|
|
||||||
|
override val source = MangaSource.MANGADEX
|
||||||
|
override val defaultDomain = "mangadex.org"
|
||||||
|
|
||||||
|
override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
SortOrder.ALPHABETICAL,
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList2(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder?,
|
||||||
|
): List<Manga> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val url = buildString {
|
||||||
|
append("https://api.")
|
||||||
|
append(domain)
|
||||||
|
append("/manga?limit=")
|
||||||
|
append(PAGE_SIZE)
|
||||||
|
append("&offset=")
|
||||||
|
append(offset)
|
||||||
|
append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
|
||||||
|
tags?.forEach { tag ->
|
||||||
|
append("includedTags[]=")
|
||||||
|
append(tag.key)
|
||||||
|
append('&')
|
||||||
|
}
|
||||||
|
if (!query.isNullOrEmpty()) {
|
||||||
|
append("title=")
|
||||||
|
append(query.urlEncoded())
|
||||||
|
append('&')
|
||||||
|
}
|
||||||
|
append(CONTENT_RATING)
|
||||||
|
append("&order")
|
||||||
|
append(when (sortOrder) {
|
||||||
|
null,
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
-> "[latestUploadedChapter]=desc"
|
||||||
|
SortOrder.ALPHABETICAL -> "[title]=asc"
|
||||||
|
SortOrder.NEWEST -> "[createdAt]=desc"
|
||||||
|
SortOrder.POPULARITY -> "[followedCount]=desc"
|
||||||
|
else -> "[followedCount]=desc"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val json = loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||||
|
return json.map { jo ->
|
||||||
|
val id = jo.getString("id")
|
||||||
|
val attrs = jo.getJSONObject("attributes")
|
||||||
|
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||||
|
val cover = relations["cover_art"]
|
||||||
|
?.getJSONObject("attributes")
|
||||||
|
?.getString("fileName")
|
||||||
|
?.let {
|
||||||
|
"https://uploads.$domain/covers/$id/$it"
|
||||||
|
}
|
||||||
|
Manga(
|
||||||
|
id = generateUid(id),
|
||||||
|
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
|
||||||
|
"Title should not be null"
|
||||||
|
},
|
||||||
|
altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
|
||||||
|
url = id,
|
||||||
|
publicUrl = "https://$domain/title/$id",
|
||||||
|
rating = Manga.NO_RATING,
|
||||||
|
isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
|
||||||
|
coverUrl = cover?.plus(".256.jpg").orEmpty(),
|
||||||
|
largeCoverUrl = cover,
|
||||||
|
description = attrs.optJSONObject("description")?.selectByLocale(),
|
||||||
|
tags = attrs.getJSONArray("tags").mapToSet { tag ->
|
||||||
|
MangaTag(
|
||||||
|
title = tag.getJSONObject("attributes")
|
||||||
|
.getJSONObject("name")
|
||||||
|
.firstStringValue(),
|
||||||
|
key = tag.getString("id"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
state = when (jo.getStringOrNull("status")) {
|
||||||
|
"ongoing" -> MangaState.ONGOING
|
||||||
|
"completed" -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
author = (relations["author"] ?: relations["artist"])
|
||||||
|
?.getJSONObject("attributes")
|
||||||
|
?.getStringOrNull("name"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga = coroutineScope<Manga> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val attrsDeferred = async {
|
||||||
|
loaderContext.httpGet(
|
||||||
|
"https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art"
|
||||||
|
).parseJson().getJSONObject("data").getJSONObject("attributes")
|
||||||
|
}
|
||||||
|
val feedDeferred = async {
|
||||||
|
val url = buildString {
|
||||||
|
append("https://api.")
|
||||||
|
append(domain)
|
||||||
|
append("/manga/")
|
||||||
|
append(manga.url)
|
||||||
|
append("/feed")
|
||||||
|
append("?limit=96&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=0&")
|
||||||
|
append(CONTENT_RATING)
|
||||||
|
}
|
||||||
|
loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||||
|
}
|
||||||
|
val mangaAttrs = attrsDeferred.await()
|
||||||
|
val feed = feedDeferred.await()
|
||||||
|
//2022-01-02T00:27:11+00:00
|
||||||
|
val dateFormat = SimpleDateFormat(
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssX"
|
||||||
|
} else {
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss'+00:00'"
|
||||||
|
},
|
||||||
|
Locale.ROOT
|
||||||
|
)
|
||||||
|
manga.copy(
|
||||||
|
description = mangaAttrs.getJSONObject("description").selectByLocale()
|
||||||
|
?: manga.description,
|
||||||
|
chapters = feed.mapNotNull { jo ->
|
||||||
|
val id = jo.getString("id")
|
||||||
|
val attrs = jo.getJSONObject("attributes")
|
||||||
|
if (!attrs.isNull("externalUrl")) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage"))
|
||||||
|
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||||
|
val number = attrs.optInt("chapter", 0)
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(id),
|
||||||
|
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
|
||||||
|
?: "Chapter #$number",
|
||||||
|
number = number,
|
||||||
|
url = id,
|
||||||
|
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
|
||||||
|
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
|
||||||
|
branch = locale.getDisplayName(locale).toTitleCase(locale),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val chapter = loaderContext.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
|
||||||
|
.parseJson()
|
||||||
|
.getJSONObject("chapter")
|
||||||
|
val pages = chapter.getJSONArray("data")
|
||||||
|
val prefix = "https://uploads.$domain/data/${chapter.getString("hash")}/"
|
||||||
|
val referer = "https://$domain/"
|
||||||
|
return List(pages.length()) { i ->
|
||||||
|
val url = prefix + pages.getString(i)
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
referer = referer,
|
||||||
|
preview = null, // TODO prefix + dataSaver.getString(i),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val tags = loaderContext.httpGet("https://api.${getDomain()}/manga/tag").parseJson()
|
||||||
|
.getJSONArray("data")
|
||||||
|
return tags.mapToSet { jo ->
|
||||||
|
MangaTag(
|
||||||
|
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(),
|
||||||
|
key = jo.getString("id"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.firstStringValue() = values().next() as String
|
||||||
|
|
||||||
|
private fun JSONObject.selectByLocale(): String? {
|
||||||
|
val preferredLocales = LocaleListCompat.getAdjustedDefault()
|
||||||
|
repeat(preferredLocales.size()) { i ->
|
||||||
|
val locale = preferredLocales.get(i)
|
||||||
|
getStringOrNull(locale.language)?.let { return it }
|
||||||
|
getStringOrNull(locale.toLanguageTag())?.let { return it }
|
||||||
|
}
|
||||||
|
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||||
|
import org.koitharu.kotatsu.core.model.*
|
||||||
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||||
|
|
||||||
|
override val source = MangaSource.MANGAOWL
|
||||||
|
|
||||||
|
override val defaultDomain = "mangaowls.com"
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.UPDATED
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList2(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder?,
|
||||||
|
): List<Manga> {
|
||||||
|
val page = (offset / 36f).toIntUp().inc()
|
||||||
|
val link = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(getDomain())
|
||||||
|
when {
|
||||||
|
!query.isNullOrEmpty() -> {
|
||||||
|
append("/search/${page}?search=")
|
||||||
|
append(query.urlEncoded())
|
||||||
|
}
|
||||||
|
!tags.isNullOrEmpty() -> {
|
||||||
|
for (tag in tags) {
|
||||||
|
append(tag.key)
|
||||||
|
}
|
||||||
|
append("/${page}?type=${getAlternativeSortKey(sortOrder)}")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
append("/${getSortKey(sortOrder)}/${page}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val doc = loaderContext.httpGet(link).parseHtml()
|
||||||
|
val slides = doc.body().select("ul.slides") ?: parseFailed("An error occurred while parsing")
|
||||||
|
val items = slides.select("div.col-md-2")
|
||||||
|
return items.mapNotNull { item ->
|
||||||
|
val href = item.selectFirst("h6 a")?.relUrl("href") ?: return@mapNotNull null
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
title = item.selectFirst("h6 a")?.text() ?: return@mapNotNull null,
|
||||||
|
coverUrl = item.select("div.img-responsive").attr("abs:data-background-image"),
|
||||||
|
altTitle = null,
|
||||||
|
author = null,
|
||||||
|
rating = runCatching {
|
||||||
|
item.selectFirst("div.block-stars")
|
||||||
|
?.text()
|
||||||
|
?.toFloatOrNull()
|
||||||
|
?.div(10f)
|
||||||
|
}.getOrNull() ?: Manga.NO_RATING,
|
||||||
|
url = href,
|
||||||
|
publicUrl = href.withDomain(),
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
|
||||||
|
val info = doc.body().selectFirst("div.single_detail") ?: parseFailed("An error occurred while parsing")
|
||||||
|
val table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing")
|
||||||
|
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
|
||||||
|
val trRegex = "window\\['tr'] = '([^']*)';".toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
val trElement = doc.getElementsByTag("script").find { trRegex.find(it.data()) != null } ?: parseFailed("Oops, tr not found")
|
||||||
|
val tr = trRegex.find(trElement.data())!!.groups[1]!!.value
|
||||||
|
val s = Base64.encodeToString(defaultDomain.toByteArray(), Base64.NO_PADDING)
|
||||||
|
return manga.copy(
|
||||||
|
description = info.selectFirst(".description")?.html(),
|
||||||
|
largeCoverUrl = info.select("img").first()?.let { img ->
|
||||||
|
if (img.hasAttr("data-src")) img.attr("abs:data-src") else img.attr("abs:src")
|
||||||
|
},
|
||||||
|
author = info.selectFirst("p.fexi_header_para a.author_link")?.text(),
|
||||||
|
state = parseStatus(info.select("p.fexi_header_para:contains(status)").first()?.ownText()),
|
||||||
|
tags = manga.tags + info.select("div.col-xs-12.col-md-8.single-right-grid-right > p > a[href*=genres]")
|
||||||
|
.mapNotNull {
|
||||||
|
val a = it.selectFirst("a") ?: return@mapNotNull null
|
||||||
|
MangaTag(
|
||||||
|
title = a.text(),
|
||||||
|
key = a.attr("href"),
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
},
|
||||||
|
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
|
||||||
|
val a = li.select("a")
|
||||||
|
val href = a.attr("data-href").ifEmpty {
|
||||||
|
parseFailed("Link is missing")
|
||||||
|
}
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
name = a.select("label").text(),
|
||||||
|
number = i + 1,
|
||||||
|
url = "$href?tr=$tr&s=$s",
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
|
||||||
|
source = MangaSource.MANGAOWL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val fullUrl = chapter.url.withDomain()
|
||||||
|
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
||||||
|
val root = doc.body().select("div.item img.owl-lazy") ?: throw ParseException("Root not found")
|
||||||
|
return root.map { div ->
|
||||||
|
val url = div?.relUrl("data-src") ?: parseFailed("Page image not found")
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
preview = null,
|
||||||
|
referer = url,
|
||||||
|
source = MangaSource.MANGAOWL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStatus(status: String?) = when {
|
||||||
|
status == null -> null
|
||||||
|
status.contains("Ongoing") -> MangaState.ONGOING
|
||||||
|
status.contains("Completed") -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = loaderContext.httpGet("https://${getDomain()}/").parseHtml()
|
||||||
|
val root = doc.body().select("ul.dropdown-menu.multi-column.columns-3").select("li")
|
||||||
|
return root.mapToSet { p ->
|
||||||
|
val a = p.selectFirst("a") ?: parseFailed("a is null")
|
||||||
|
MangaTag(
|
||||||
|
title = a.text().toCamelCase(),
|
||||||
|
key = a.attr("href"),
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSortKey(sortOrder: SortOrder?) =
|
||||||
|
when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
|
||||||
|
SortOrder.POPULARITY -> "popular"
|
||||||
|
SortOrder.NEWEST -> "new_release"
|
||||||
|
SortOrder.UPDATED -> "lastest"
|
||||||
|
else -> "lastest"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAlternativeSortKey(sortOrder: SortOrder?) =
|
||||||
|
when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
|
||||||
|
SortOrder.POPULARITY -> "0"
|
||||||
|
SortOrder.NEWEST -> "2"
|
||||||
|
SortOrder.UPDATED -> "3"
|
||||||
|
else -> "3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,57 @@
|
|||||||
package org.koitharu.kotatsu.details.ui.model
|
package org.koitharu.kotatsu.details.ui.model
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
|
||||||
|
|
||||||
data class ChapterListItem(
|
class ChapterListItem(
|
||||||
val chapter: MangaChapter,
|
val chapter: MangaChapter,
|
||||||
val extra: ChapterExtra,
|
val flags: Int,
|
||||||
val isMissing: Boolean,
|
val uploadDate: String?,
|
||||||
)
|
) {
|
||||||
|
|
||||||
|
val status: Int
|
||||||
|
get() = flags and MASK_STATUS
|
||||||
|
|
||||||
|
fun hasFlag(flag: Int): Boolean {
|
||||||
|
return (flags and flag) == flag
|
||||||
|
}
|
||||||
|
|
||||||
|
fun description(): CharSequence? {
|
||||||
|
val scanlator = chapter.scanlator?.takeUnless { it.isBlank() }
|
||||||
|
return when {
|
||||||
|
uploadDate != null && scanlator != null -> "$uploadDate • $scanlator"
|
||||||
|
scanlator != null -> scanlator
|
||||||
|
else -> uploadDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as ChapterListItem
|
||||||
|
|
||||||
|
if (chapter != other.chapter) return false
|
||||||
|
if (flags != other.flags) return false
|
||||||
|
if (uploadDate != other.uploadDate) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = chapter.hashCode()
|
||||||
|
result = 31 * result + flags
|
||||||
|
result = 31 * result + uploadDate.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val FLAG_UNREAD = 2
|
||||||
|
const val FLAG_CURRENT = 4
|
||||||
|
const val FLAG_NEW = 8
|
||||||
|
const val FLAG_MISSING = 16
|
||||||
|
const val FLAG_DOWNLOADED = 32
|
||||||
|
const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,30 @@
|
|||||||
package org.koitharu.kotatsu.details.ui.model
|
package org.koitharu.kotatsu.details.ui.model
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
|
||||||
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
|
||||||
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
|
||||||
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
|
||||||
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
|
||||||
|
import java.text.DateFormat
|
||||||
|
|
||||||
fun MangaChapter.toListItem(
|
fun MangaChapter.toListItem(
|
||||||
extra: ChapterExtra,
|
isCurrent: Boolean,
|
||||||
|
isUnread: Boolean,
|
||||||
|
isNew: Boolean,
|
||||||
isMissing: Boolean,
|
isMissing: Boolean,
|
||||||
) = ChapterListItem(
|
isDownloaded: Boolean,
|
||||||
|
dateFormat: DateFormat,
|
||||||
|
): ChapterListItem {
|
||||||
|
var flags = 0
|
||||||
|
if (isCurrent) flags = flags or FLAG_CURRENT
|
||||||
|
if (isUnread) flags = flags or FLAG_UNREAD
|
||||||
|
if (isNew) flags = flags or FLAG_NEW
|
||||||
|
if (isMissing) flags = flags or FLAG_MISSING
|
||||||
|
if (isDownloaded) flags = flags or FLAG_DOWNLOADED
|
||||||
|
return ChapterListItem(
|
||||||
chapter = this,
|
chapter = this,
|
||||||
extra = extra,
|
flags = flags,
|
||||||
isMissing = isMissing,
|
uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null
|
||||||
)
|
)
|
||||||
|
}
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.history.domain
|
|
||||||
|
|
||||||
enum class ChapterExtra {
|
|
||||||
|
|
||||||
READ, CURRENT, UNREAD, NEW
|
|
||||||
}
|
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
package org.koitharu.kotatsu.image.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.CachePolicy
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.target.PoolableViewTarget
|
||||||
|
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||||
|
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
||||||
|
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.utils.ext.indicator
|
||||||
|
|
||||||
|
class ImageActivity : BaseActivity<ActivityImageBinding>() {
|
||||||
|
|
||||||
|
private val coil: ImageLoader by inject()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
||||||
|
supportActionBar?.run {
|
||||||
|
setDisplayHomeAsUpEnabled(true)
|
||||||
|
setDisplayShowTitleEnabled(false)
|
||||||
|
}
|
||||||
|
loadImage(intent.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
with(binding.toolbar) {
|
||||||
|
updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right
|
||||||
|
)
|
||||||
|
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
|
topMargin = insets.top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadImage(url: Uri?) {
|
||||||
|
ImageRequest.Builder(this)
|
||||||
|
.data(url)
|
||||||
|
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||||
|
.lifecycle(this)
|
||||||
|
.target(SsivTarget(binding.ssiv))
|
||||||
|
.indicator(binding.progressBar)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SsivTarget(
|
||||||
|
override val view: SubsamplingScaleImageView,
|
||||||
|
) : PoolableViewTarget<SubsamplingScaleImageView> {
|
||||||
|
|
||||||
|
override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
|
||||||
|
|
||||||
|
override fun onError(error: Drawable?) = setDrawable(error)
|
||||||
|
|
||||||
|
override fun onSuccess(result: Drawable) = setDrawable(result)
|
||||||
|
|
||||||
|
override fun onClear() = setDrawable(null)
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
return (this === other) || (other is SsivTarget && view == other.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode() = view.hashCode()
|
||||||
|
|
||||||
|
override fun toString() = "SsivTarget(view=$view)"
|
||||||
|
|
||||||
|
private fun setDrawable(drawable: Drawable?) {
|
||||||
|
if (drawable != null) {
|
||||||
|
view.setImage(ImageSource.bitmap(drawable.toBitmap()))
|
||||||
|
} else {
|
||||||
|
view.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newIntent(context: Context, url: String): Intent {
|
||||||
|
return Intent(context, ImageActivity::class.java)
|
||||||
|
.setData(Uri.parse(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue