Merge branch 'devel' into feature/suggestions
This commit is contained in:
@@ -23,7 +23,9 @@
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="UnusedAttribute">
|
||||
<activity android:name="org.koitharu.kotatsu.main.ui.MainActivity">
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -32,12 +34,16 @@
|
||||
android:name="android.app.default_searchable"
|
||||
android:value=".ui.search.SearchActivity" />
|
||||
</activity>
|
||||
<activity android:name="org.koitharu.kotatsu.details.ui.DetailsActivity">
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.action.VIEW_MANGA" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity">
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.action.READ_MANGA" />
|
||||
</intent-filter>
|
||||
@@ -50,13 +56,19 @@
|
||||
android:label="@string/settings" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/settings">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="org.koitharu.kotatsu.browser.BrowserActivity" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.core.ui.CrashActivity"
|
||||
android:label="@string/error_occurred"
|
||||
@@ -68,6 +80,7 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/manga_shelf">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
@@ -83,9 +96,12 @@
|
||||
<activity
|
||||
android:name=".settings.protect.ProtectSetupActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||
android:label="@string/downloads" />
|
||||
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.download.DownloadService"
|
||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||
|
||||
@@ -9,13 +9,16 @@ import org.koitharu.kotatsu.utils.ext.await
|
||||
|
||||
open class MangaLoaderContext(
|
||||
private val okHttp: OkHttpClient,
|
||||
private val cookieJar: CookieJar
|
||||
val cookieJar: CookieJar
|
||||
) : KoinComponent {
|
||||
|
||||
suspend fun httpGet(url: String): Response {
|
||||
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url(url)
|
||||
if (headers != null) {
|
||||
request.headers(headers)
|
||||
}
|
||||
return okHttp.newCall(request.build()).await()
|
||||
}
|
||||
|
||||
@@ -54,16 +57,6 @@ open class MangaLoaderContext(
|
||||
|
||||
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
|
||||
|
||||
fun insertCookies(domain: String, vararg cookies: String) {
|
||||
val url = HttpUrl.Builder()
|
||||
.scheme(SCHEME_HTTP)
|
||||
.host(domain)
|
||||
.build()
|
||||
cookieJar.saveFromResponse(url, cookies.mapNotNull {
|
||||
Cookie.parse(url, it)
|
||||
})
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val SCHEME_HTTP = "http"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.base.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
@@ -11,16 +12,16 @@ import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.ActionBarContextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.*
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindowInsetsListener {
|
||||
|
||||
@@ -35,7 +36,7 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (get<AppSettings>().isAmoledTheme) {
|
||||
setTheme(R.style.AppTheme_Amoled)
|
||||
setTheme(R.style.AppTheme_AMOLED)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -56,8 +57,19 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
|
||||
protected fun setContentView(binding: B) {
|
||||
this.binding = binding
|
||||
super.setContentView(binding.root)
|
||||
(binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
|
||||
val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
|
||||
toolbar?.let(this::setSupportActionBar)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
|
||||
|
||||
val toolbarParams = (binding.root.findViewById<View>(R.id.toolbar_card) ?: toolbar)
|
||||
?.layoutParams as? AppBarLayout.LayoutParams
|
||||
if (toolbarParams != null) {
|
||||
if (get<AppSettings>().isToolbarHideWhenScrolling) {
|
||||
toolbarParams.scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP
|
||||
} else {
|
||||
toolbarParams.scrollFlags = SCROLL_FLAG_NO_SCROLL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
@@ -90,6 +102,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
|
||||
(findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
|
||||
}
|
||||
|
||||
protected fun isDarkAmoledTheme(): Boolean {
|
||||
val uiMode = resources.configuration.uiMode
|
||||
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
return isNight && get<AppSettings>().isAmoledTheme
|
||||
}
|
||||
|
||||
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
super.onSupportActionModeStarted(mode)
|
||||
val insets = ViewCompat.getRootWindowInsets(binding.root)
|
||||
@@ -98,12 +116,6 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
|
||||
view?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top
|
||||
}
|
||||
window?.statusBarColor = ContextCompat.getColor(this, R.color.grey_dark)
|
||||
}
|
||||
|
||||
override fun onSupportActionModeFinished(mode: ActionMode) {
|
||||
super.onSupportActionModeFinished(mode)
|
||||
window?.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
|
||||
@@ -38,6 +38,7 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(), OnApplyWindowInsetsLi
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
lastInsets = Insets.NONE
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, this)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class SectionItemDecoration(
|
||||
|
||||
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.onDrawOver(c, parent, state)
|
||||
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_header).also {
|
||||
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_filter_header).also {
|
||||
headerView = it
|
||||
}
|
||||
fixLayoutSize(textView, parent)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.isGone
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import java.lang.reflect.Field
|
||||
|
||||
class AnimatedToolbar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.toolbarStyle,
|
||||
) : MaterialToolbar(context, attrs, defStyleAttr) {
|
||||
|
||||
private var navButtonView: View? = null
|
||||
get() {
|
||||
if (field == null) {
|
||||
runCatching {
|
||||
field = navButtonViewField?.get(this) as? View
|
||||
}
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
override fun setNavigationIcon(icon: Drawable?) {
|
||||
super.setNavigationIcon(icon)
|
||||
navButtonView?.isGone = (icon == null)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val navButtonViewField: Field? = runCatching {
|
||||
Toolbar::class.java.getDeclaredField("mNavButtonView")
|
||||
.also { it.isAccessible = true }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,17 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View.OnClickListener
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class ChipsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.chipGroupStyle
|
||||
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle
|
||||
) : ChipGroup(context, attrs, defStyleAttr) {
|
||||
|
||||
private var isLayoutSuppressedCompat = false
|
||||
@@ -21,12 +22,21 @@ class ChipsView @JvmOverloads constructor(
|
||||
private var chipOnClickListener = OnClickListener {
|
||||
onChipClickListener?.onChipClick(it as Chip, it.tag)
|
||||
}
|
||||
private var chipOnCloseListener = OnClickListener {
|
||||
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
|
||||
}
|
||||
var onChipClickListener: OnChipClickListener? = null
|
||||
set(value) {
|
||||
field = value
|
||||
val isChipClickable = value != null
|
||||
children.forEach { it.isClickable = isChipClickable }
|
||||
}
|
||||
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
||||
set(value) {
|
||||
field = value
|
||||
val isCloseIconVisible = value != null
|
||||
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
|
||||
}
|
||||
|
||||
override fun requestLayout() {
|
||||
if (isLayoutSuppressedCompat) {
|
||||
@@ -36,15 +46,15 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setChips(items: List<ChipModel>) {
|
||||
fun setChips(items: Collection<ChipModel>) {
|
||||
suppressLayoutCompat(true)
|
||||
try {
|
||||
for ((i, model) in items.withIndex()) {
|
||||
val chip = getChildAt(i) as Chip? ?: addChip()
|
||||
bindChip(chip, model)
|
||||
}
|
||||
for (i in items.size until childCount) {
|
||||
removeViewAt(i)
|
||||
if (childCount > items.size) {
|
||||
removeViews(items.size, childCount - items.size)
|
||||
}
|
||||
} finally {
|
||||
suppressLayoutCompat(false)
|
||||
@@ -59,16 +69,19 @@ class ChipsView @JvmOverloads constructor(
|
||||
chip.isCheckedIconVisible = true
|
||||
chip.setChipIconResource(model.icon)
|
||||
}
|
||||
chip.isClickable = onChipClickListener != null
|
||||
chip.tag = model.data
|
||||
}
|
||||
|
||||
private fun addChip(): Chip {
|
||||
val chip = Chip(context)
|
||||
chip.setTextColor(context.getThemeColor(android.R.attr.textColorPrimary))
|
||||
chip.isCloseIconVisible = false
|
||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
|
||||
chip.setChipDrawable(drawable)
|
||||
chip.setTextColor(ContextCompat.getColor(context, R.color.color_primary))
|
||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||
chip.setEnsureMinTouchTargetSize(false)
|
||||
chip.setOnClickListener(chipOnClickListener)
|
||||
chip.isClickable = onChipClickListener != null
|
||||
addView(chip)
|
||||
return chip
|
||||
}
|
||||
@@ -93,4 +106,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
|
||||
fun onChipClick(chip: Chip, data: Any?)
|
||||
}
|
||||
|
||||
fun interface OnChipCloseClickListener {
|
||||
|
||||
fun onChipCloseClick(chip: Chip, data: Any?)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_browser, menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
||||
@@ -27,19 +25,4 @@ class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat
|
||||
super.onPageCommitVisible(view, url)
|
||||
callback.onTitleChanged(view.title.orEmpty(), url)
|
||||
}
|
||||
|
||||
override fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
|
||||
return runCatching {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
val response = okHttp.newCall(request).execute()
|
||||
val ct = response.body?.contentType()
|
||||
WebResourceResponse(
|
||||
"${ct?.type}/${ct?.subtype}",
|
||||
ct?.charset()?.name() ?: "utf-8",
|
||||
response.body?.byteStream()
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ package org.koitharu.kotatsu.browser.cloudflare
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.koitharu.kotatsu.core.network.CookieJar
|
||||
import org.koitharu.kotatsu.core.network.AndroidCookieJar
|
||||
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
||||
|
||||
class CloudFlareClient(
|
||||
private val cookieJar: CookieJar,
|
||||
private val cookieJar: AndroidCookieJar,
|
||||
private val callback: CloudFlareCallback,
|
||||
private val targetUrl: String
|
||||
) : WebViewClientCompat() {
|
||||
|
||||
@@ -118,6 +118,7 @@ class BackupRepository(private val db: MangaDatabase) {
|
||||
jo.put("created_at", createdAt)
|
||||
jo.put("sort_key", sortKey)
|
||||
jo.put("title", title)
|
||||
jo.put("order", order)
|
||||
return jo
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
@@ -101,7 +102,8 @@ class RestoreRepository(private val db: MangaDatabase) {
|
||||
categoryId = json.getInt("category_id"),
|
||||
createdAt = json.getLong("created_at"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
title = json.getString("title")
|
||||
title = json.getString("title"),
|
||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
||||
)
|
||||
|
||||
private fun parseFavourite(json: JSONObject) = FavouriteEntity(
|
||||
|
||||
@@ -20,6 +20,7 @@ val databaseModule
|
||||
Migration5To6(),
|
||||
Migration6To7(),
|
||||
Migration7To8(),
|
||||
Migration8To9(),
|
||||
).addCallback(
|
||||
DatabasePrePopulateCallback(androidContext().resources)
|
||||
).build()
|
||||
|
||||
@@ -4,13 +4,14 @@ import android.content.res.Resources
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
|
||||
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
|
||||
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"INSERT INTO favourite_categories (created_at, sort_key, title) VALUES (?,?,?)",
|
||||
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later))
|
||||
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)",
|
||||
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class
|
||||
], version = 8
|
||||
], version = 9
|
||||
)
|
||||
abstract class MangaDatabase : RoomDatabase() {
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ abstract class MangaDao {
|
||||
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
||||
abstract suspend fun find(id: Long): MangaWithTags?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE title LIKE :query OR alt_title LIKE :query LIMIT :limit")
|
||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source LIMIT :limit")
|
||||
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insert(manga: MangaEntity): Long
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
|
||||
class Migration8To9 : Migration(8, 9) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@ data class FavouriteCategory(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val sortKey: Int,
|
||||
val createdAt: Date
|
||||
val order: SortOrder,
|
||||
val createdAt: Date,
|
||||
) : Parcelable
|
||||
@@ -5,6 +5,6 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class MangaFilter(
|
||||
val sortOrder: SortOrder,
|
||||
val tag: MangaTag?
|
||||
val sortOrder: SortOrder?,
|
||||
val tags: Set<MangaTag>,
|
||||
) : Parcelable
|
||||
@@ -30,7 +30,17 @@ enum class MangaSource(
|
||||
// NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java),
|
||||
MANGAREAD("MangaRead", "en", MangareadRepository::class.java),
|
||||
REMANGA("Remanga", "ru", RemangaRepository::class.java),
|
||||
HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java);
|
||||
HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java),
|
||||
ANIBEL("Anibel", "be", AnibelRepository::class.java),
|
||||
NINEMANGA_EN("NineManga English", "en", NineMangaRepository.English::class.java),
|
||||
NINEMANGA_ES("NineManga Español", "es", NineMangaRepository.Spanish::class.java),
|
||||
NINEMANGA_RU("NineManga Русский", "ru", NineMangaRepository.Russian::class.java),
|
||||
NINEMANGA_DE("NineManga Deutsch", "de", NineMangaRepository.Deutsch::class.java),
|
||||
NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java),
|
||||
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
|
||||
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
|
||||
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java)
|
||||
;
|
||||
|
||||
@get:Throws(NoBeanDefFoundException::class)
|
||||
@Deprecated("")
|
||||
|
||||
@@ -7,7 +7,7 @@ import okhttp3.HttpUrl
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class CookieJar : CookieJar {
|
||||
class AndroidCookieJar : CookieJar {
|
||||
|
||||
private val cookieManager = CookieManager.getInstance()
|
||||
|
||||
@@ -28,10 +28,6 @@ class CookieJar : CookieJar {
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAsync() {
|
||||
cookieManager.removeAllCookies(null)
|
||||
}
|
||||
|
||||
suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
|
||||
cookieManager.removeAllCookies(continuation::resume)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class CurlLoggingInterceptor(
|
||||
private val extraCurlOptions: String? = null,
|
||||
) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request: Request = chain.request()
|
||||
var compressed = false
|
||||
val curlCmd = StringBuilder("curl")
|
||||
if (extraCurlOptions != null) {
|
||||
curlCmd.append(" ").append(extraCurlOptions)
|
||||
}
|
||||
curlCmd.append(" -X ").append(request.method)
|
||||
val headers = request.headers
|
||||
var i = 0
|
||||
val count = headers.size
|
||||
while (i < count) {
|
||||
val name = headers.name(i)
|
||||
val value = headers.value(i)
|
||||
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
|
||||
ignoreCase = true)
|
||||
) {
|
||||
compressed = true
|
||||
}
|
||||
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
|
||||
i++
|
||||
}
|
||||
val requestBody = request.body
|
||||
if (requestBody != null) {
|
||||
val buffer = Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
val contentType = requestBody.contentType()
|
||||
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||
curlCmd.append(" --data $'")
|
||||
.append(buffer.readString(charset).replace("\n", "\\n"))
|
||||
.append("'")
|
||||
}
|
||||
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
|
||||
Log.d(TAG, "╭--- cURL (" + request.url + ")")
|
||||
Log.d(TAG, curlCmd.toString())
|
||||
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val TAG = "CURL"
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
val networkModule
|
||||
get() = module {
|
||||
single { CookieJar() } bind CookieJar::class
|
||||
single { AndroidCookieJar() } bind CookieJar::class
|
||||
single(named(CacheUtils.QUALIFIER_HTTP)) { CacheUtils.createHttpCache(androidContext()) }
|
||||
single {
|
||||
OkHttpClient.Builder().apply {
|
||||
@@ -22,6 +23,9 @@ val networkModule
|
||||
cache(get(named(CacheUtils.QUALIFIER_HTTP)))
|
||||
addInterceptor(UserAgentInterceptor())
|
||||
addInterceptor(CloudFlareInterceptor())
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(CurlLoggingInterceptor())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class ShortcutsRepository(
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return
|
||||
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
|
||||
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
|
||||
.filter { x -> x.title.isNotEmpty() }
|
||||
.map { buildShortcutInfo(it).build().toShortcutInfo() }
|
||||
manager.dynamicShortcuts = shortcuts
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
|
||||
interface MangaRepository {
|
||||
|
||||
val sortOrders: Set<SortOrder>
|
||||
|
||||
suspend fun getList(
|
||||
suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String? = null,
|
||||
tags: Set<MangaTag>? = null,
|
||||
sortOrder: SortOrder? = null,
|
||||
tag: MangaTag? = null
|
||||
): List<Manga>
|
||||
|
||||
suspend fun getDetails(manga: Manga): Manga
|
||||
@@ -20,4 +23,11 @@ interface MangaRepository {
|
||||
suspend fun getPageUrl(page: MangaPage): String
|
||||
|
||||
suspend fun getTags(): Set<MangaTag>
|
||||
|
||||
companion object : KoinComponent {
|
||||
|
||||
operator fun invoke(source: MangaSource): MangaRepository {
|
||||
return get(named(source))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
interface MangaRepositoryAuthProvider {
|
||||
|
||||
val authUrl: String
|
||||
|
||||
fun isAuthorized(): Boolean
|
||||
}
|
||||
@@ -24,4 +24,13 @@ val parserModule
|
||||
factory<MangaRepository>(named(MangaSource.MANGAREAD)) { MangareadRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.REMANGA)) { RemangaRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.HENTAILIB)) { HentaiLibRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.ANIBEL)) { AnibelRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_EN)) { NineMangaRepository.English(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_BR)) { NineMangaRepository.Brazil(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_DE)) { NineMangaRepository.Deutsch(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_ES)) { NineMangaRepository.Spanish(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_RU)) { NineMangaRepository.Russian(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_IT)) { NineMangaRepository.Italiano(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
import org.koitharu.kotatsu.core.model.MangaPage
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
@@ -19,6 +20,9 @@ abstract class RemoteMangaRepository(
|
||||
loaderContext.getSettings(source)
|
||||
}
|
||||
|
||||
val title: String
|
||||
get() = source.title
|
||||
|
||||
override val sortOrders: Set<SortOrder> get() = emptySet()
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain()
|
||||
@@ -75,4 +79,8 @@ abstract class RemoteMangaRepository(
|
||||
h = 31 * h + id
|
||||
return h
|
||||
}
|
||||
|
||||
protected fun parseFailed(message: String? = null): Nothing {
|
||||
throw ParseException(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
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.util.*
|
||||
|
||||
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
|
||||
override val source = MangaSource.ANIBEL
|
||||
|
||||
override val defaultDomain = "anibel.net"
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||
SortOrder.NEWEST
|
||||
)
|
||||
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
return if (offset == 0) search(query) else emptyList()
|
||||
}
|
||||
val page = (offset / 12f).toIntUp().inc()
|
||||
val link = when {
|
||||
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
|
||||
else -> tags.joinToString(
|
||||
prefix = "/manga?",
|
||||
postfix = "&page=$page",
|
||||
separator = "&",
|
||||
) { tag -> "genre[]=${tag.key}" }.withDomain()
|
||||
}
|
||||
val doc = loaderContext.httpGet(link).parseHtml()
|
||||
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
|
||||
val items = root.select("div.anime-card")
|
||||
return items.mapNotNull { card ->
|
||||
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
|
||||
val status = card.select("tr")[2].text()
|
||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
||||
val titleParts = fullTitle.splitTwoParts('/')
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
title = titleParts?.first?.trim() ?: fullTitle,
|
||||
coverUrl = card.selectFirst("img")?.attr("data-src")
|
||||
?.withDomain().orEmpty(),
|
||||
altTitle = titleParts?.second?.trim(),
|
||||
author = null,
|
||||
rating = Manga.NO_RATING,
|
||||
url = href,
|
||||
publicUrl = href.withDomain(),
|
||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
||||
MangaTag(
|
||||
title = x.text(),
|
||||
key = x.attr("href").ifEmpty {
|
||||
return@mapNotNull null
|
||||
}.substringAfterLast("="),
|
||||
source = source
|
||||
)
|
||||
},
|
||||
state = when (status) {
|
||||
"выпускаецца" -> MangaState.ONGOING
|
||||
"завершанае" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
source = source
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
|
||||
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
|
||||
return manga.copy(
|
||||
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
|
||||
chapters = root.select("ul.series").flatMap { table ->
|
||||
table.select("li")
|
||||
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
|
||||
val href = a?.select("a")?.first()?.attr("href")
|
||||
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.selectFirst("a")?.text().orEmpty(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val fullUrl = chapter.url.withDomain()
|
||||
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
||||
val scripts = doc.select("script")
|
||||
for (script in scripts) {
|
||||
val data = script.html()
|
||||
val pos = data.indexOf("dataSource")
|
||||
if (pos == -1) {
|
||||
continue
|
||||
}
|
||||
val json = data.substring(pos).substringAfter('[').substringBefore(']')
|
||||
val domain = getDomain()
|
||||
return json.split(",").mapNotNull {
|
||||
it.trim()
|
||||
.removeSurrounding('"', '\'')
|
||||
.toRelativeUrl(domain)
|
||||
.takeUnless(String::isBlank)
|
||||
}.map { url ->
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
referer = fullUrl,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
}
|
||||
parseFailed("Pages list not found at ${chapter.url.withDomain()}")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
|
||||
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
|
||||
return root.select("p.menu-tags.tupe").mapToSet { p ->
|
||||
val a = p.selectFirst("a") ?: parseFailed("a is null")
|
||||
MangaTag(
|
||||
title = a.text().toCamelCase(),
|
||||
key = a.attr("data-name"),
|
||||
source = source
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun search(query: String): List<Manga> {
|
||||
val domain = getDomain()
|
||||
val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml()
|
||||
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: parseFailed("Cannot find root")
|
||||
val items = root.select("div.anime-card")
|
||||
return items.mapNotNull { card ->
|
||||
val href = card.select("a").attr("href")
|
||||
val status = card.select("tr")[2].text()
|
||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
||||
val titleParts = fullTitle.splitTwoParts('/')
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
title = titleParts?.first?.trim() ?: fullTitle,
|
||||
coverUrl = card.selectFirst("img")?.attr("src")
|
||||
?.withDomain().orEmpty(),
|
||||
altTitle = titleParts?.second?.trim(),
|
||||
author = null,
|
||||
rating = Manga.NO_RATING,
|
||||
url = href,
|
||||
publicUrl = href.withDomain(),
|
||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
||||
MangaTag(
|
||||
title = x.text(),
|
||||
key = x.attr("href").ifEmpty {
|
||||
return@mapNotNull null
|
||||
}.substringAfterLast("="),
|
||||
source = source
|
||||
)
|
||||
},
|
||||
state = when (status) {
|
||||
"выпускаецца" -> MangaState.ONGOING
|
||||
"завершанае" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
source = source
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,11 +17,11 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
||||
SortOrder.ALPHABETICAL
|
||||
)
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
val domain = getDomain()
|
||||
val url = when {
|
||||
@@ -31,11 +31,15 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
||||
}
|
||||
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
|
||||
}
|
||||
tag != null -> "https://$domain/tags/${tag.key}&n=${getSortKey2(sortOrder)}?offset=$offset"
|
||||
!tags.isNullOrEmpty() -> tags.joinToString(
|
||||
prefix = "https://$domain/tags/",
|
||||
postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset",
|
||||
separator = "+",
|
||||
) { tag -> tag.key }
|
||||
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
|
||||
}
|
||||
val doc = loaderContext.httpGet(url).parseHtml()
|
||||
val root = doc.body().selectFirst("div.main_fon").getElementById("content")
|
||||
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
|
||||
?: throw ParseException("Cannot find root")
|
||||
return root.select("div.content_row").mapNotNull { row ->
|
||||
val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a")
|
||||
@@ -78,7 +82,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
||||
chapters = root.select("table.table_cha").flatMap { table ->
|
||||
table.select("div.manga2")
|
||||
}.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
|
||||
val href = a.relUrl("href")
|
||||
val href = a?.relUrl("href") ?: return@mapIndexedNotNull null
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.text().trim(),
|
||||
@@ -123,12 +127,12 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val domain = getDomain()
|
||||
val doc = loaderContext.httpGet("https://$domain/catalog").parseHtml()
|
||||
val root = doc.body().selectFirst("div.main_fon").getElementById("side")
|
||||
.select("ul").last()
|
||||
val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
|
||||
?.select("ul")?.last() ?: throw ParseException("Cannot find root")
|
||||
return root.select("li.sidetag").mapToSet { li ->
|
||||
val a = li.children().last()
|
||||
val a = li.children().last() ?: throw ParseException("a is null")
|
||||
MangaTag(
|
||||
title = a.text().capitalize(),
|
||||
title = a.text().toCamelCase(),
|
||||
key = a.attr("href").substringAfterLast('/'),
|
||||
source = source
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
|
||||
@@ -21,11 +20,11 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
SortOrder.ALPHABETICAL
|
||||
)
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
if (query != null && offset != 0) {
|
||||
return emptyList()
|
||||
@@ -38,9 +37,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
append(getSortKey(sortOrder))
|
||||
append("&page=")
|
||||
append((offset / 20) + 1)
|
||||
if (tag != null) {
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
append("&genres=")
|
||||
append(tag.key)
|
||||
appendAll(tags, ",") { it.key }
|
||||
}
|
||||
if (query != null) {
|
||||
append("&search=")
|
||||
@@ -122,12 +121,13 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml()
|
||||
val root = doc.body().getElementById("animeFilter").selectFirst(".catalog-genres")
|
||||
val root = doc.body().getElementById("animeFilter")
|
||||
?.selectFirst(".catalog-genres") ?: throw ParseException("Root not found")
|
||||
return root.select("li").mapToSet {
|
||||
MangaTag(
|
||||
source = source,
|
||||
key = it.selectFirst("input").attr("data-genre"),
|
||||
title = it.selectFirst("label").text()
|
||||
key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(),
|
||||
title = it.selectFirst("label")?.text() ?: parseFailed()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import org.jsoup.nodes.Element
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import kotlin.math.pow
|
||||
|
||||
private const val DOMAIN_UNAUTHORIZED = "e-hentai.org"
|
||||
private const val DOMAIN_AUTHORIZED = "exhentai.org"
|
||||
|
||||
class ExHentaiRepository(
|
||||
loaderContext: MangaLoaderContext,
|
||||
) : RemoteMangaRepository(loaderContext), MangaRepositoryAuthProvider {
|
||||
|
||||
override val source = MangaSource.EXHENTAI
|
||||
|
||||
override val defaultDomain: String
|
||||
get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED
|
||||
|
||||
override val authUrl: String
|
||||
get() = "https://${getDomain()}/bounce_login.php"
|
||||
|
||||
private val ratingPattern = Regex("-?[0-9]+px")
|
||||
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
|
||||
private var updateDm = false
|
||||
|
||||
init {
|
||||
loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
|
||||
loaderContext.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
|
||||
}
|
||||
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> {
|
||||
val page = (offset / 25f).toIntUp()
|
||||
var search = query?.urlEncoded().orEmpty()
|
||||
val url = buildString {
|
||||
append("https://")
|
||||
append(getDomain())
|
||||
append("/?page=")
|
||||
append(page)
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
var fCats = 0
|
||||
for (tag in tags) {
|
||||
tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run {
|
||||
search += tag.key + " "
|
||||
}
|
||||
}
|
||||
if (fCats != 0) {
|
||||
append("&f_cats=")
|
||||
append(1023 - fCats)
|
||||
}
|
||||
}
|
||||
if (search.isNotEmpty()) {
|
||||
append("&f_search=")
|
||||
append(search.trim().replace(' ', '+'))
|
||||
}
|
||||
// by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again
|
||||
if (updateDm) {
|
||||
append("&inline_set=dm_e")
|
||||
}
|
||||
}
|
||||
val body = loaderContext.httpGet(url).parseHtml().body()
|
||||
val root = body.selectFirst("table.itg")
|
||||
?.selectFirst("tbody")
|
||||
?: if (updateDm) {
|
||||
parseFailed("Cannot find root")
|
||||
} else {
|
||||
updateDm = true
|
||||
return getList2(offset, query, tags, sortOrder)
|
||||
}
|
||||
updateDm = false
|
||||
return root.children().mapNotNull { tr ->
|
||||
if (tr.childrenSize() != 2) return@mapNotNull null
|
||||
val (td1, td2) = tr.children()
|
||||
val glink = td2.selectFirst("div.glink") ?: parseFailed("glink not found")
|
||||
val a = glink.parents().select("a").first() ?: parseFailed("link not found")
|
||||
val href = a.relUrl("href")
|
||||
val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
|
||||
val mainTag = td2.selectFirst("div.cn")?.let { div ->
|
||||
MangaTag(
|
||||
title = div.text(),
|
||||
key = tagIdByClass(div.classNames()) ?: return@let null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
title = glink.text().cleanupTitle(),
|
||||
altTitle = null,
|
||||
url = href,
|
||||
publicUrl = a.absUrl("href"),
|
||||
rating = td2.selectFirst("div.ir")?.parseRating() ?: Manga.NO_RATING,
|
||||
isNsfw = true,
|
||||
coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(),
|
||||
tags = setOfNotNull(mainTag),
|
||||
state = null,
|
||||
author = tagsDiv.getElementsContainingOwnText("artist:").first()
|
||||
?.nextElementSibling()?.text(),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
|
||||
val root = doc.body().selectFirst("div.gm") ?: parseFailed("Cannot find root")
|
||||
val cover = root.getElementById("gd1")?.children()?.first()
|
||||
val title = root.getElementById("gd2")
|
||||
val taglist = root.getElementById("taglist")
|
||||
val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr")
|
||||
return manga.copy(
|
||||
title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title,
|
||||
altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle,
|
||||
publicUrl = doc.baseUri().ifEmpty { manga.publicUrl },
|
||||
rating = root.getElementById("rating_label")?.text()
|
||||
?.substringAfterLast(' ')
|
||||
?.toFloatOrNull()
|
||||
?.div(5f) ?: manga.rating,
|
||||
largeCoverUrl = cover?.css("background")?.cssUrl(),
|
||||
description = taglist?.select("tr")?.joinToString("<br>") { tr ->
|
||||
val (tc, td) = tr.children()
|
||||
val subtags = td.select("a").joinToString { it.html() }
|
||||
"<b>${tc.html()}</b> $subtags"
|
||||
},
|
||||
chapters = tabs?.select("a")?.findLast { a ->
|
||||
a.text().toIntOrNull() != null
|
||||
}?.let { a ->
|
||||
val count = a.text().toInt()
|
||||
val chapters = ArrayList<MangaChapter>(count)
|
||||
for (i in 1..count) {
|
||||
val url = "${manga.url}?p=$i"
|
||||
chapters += MangaChapter(
|
||||
id = generateUid(url),
|
||||
name = "${manga.title} #$i",
|
||||
number = i,
|
||||
url = url,
|
||||
branch = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
chapters
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val doc = loaderContext.httpGet(chapter.url.withDomain()).parseHtml()
|
||||
val root = doc.body().getElementById("gdt") ?: parseFailed("Root not found")
|
||||
return root.select("a").mapNotNull { a ->
|
||||
val url = a.relUrl("href")
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
referer = a.absUrl("href"),
|
||||
preview = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String {
|
||||
val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml()
|
||||
return doc.body().getElementById("img")?.absUrl("src")
|
||||
?: parseFailed("Image not found")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}").parseHtml()
|
||||
val root = doc.body().getElementById("searchbox")?.selectFirst("table")
|
||||
?: parseFailed("Root not found")
|
||||
return root.select("div.cs").mapNotNullToSet { div ->
|
||||
val id = div.id().substringAfterLast('_').toIntOrNull()
|
||||
?: return@mapNotNullToSet null
|
||||
MangaTag(
|
||||
title = div.text(),
|
||||
key = id.toString(),
|
||||
source = source
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isAuthorized(): Boolean {
|
||||
val authorized = isAuthorized(DOMAIN_UNAUTHORIZED)
|
||||
if (authorized) {
|
||||
if (!isAuthorized(DOMAIN_AUTHORIZED)) {
|
||||
loaderContext.cookieJar.copyCookies(
|
||||
DOMAIN_UNAUTHORIZED,
|
||||
DOMAIN_AUTHORIZED,
|
||||
authCookies,
|
||||
)
|
||||
loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder")
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isAuthorized(domain: String): Boolean {
|
||||
val cookies = loaderContext.cookieJar.getCookies(domain).mapToSet { x -> x.name }
|
||||
return authCookies.all { it in cookies }
|
||||
}
|
||||
|
||||
private fun Element.parseRating(): Float {
|
||||
return runCatching {
|
||||
val style = requireNotNull(attr("style"))
|
||||
val (v1, v2) = ratingPattern.find(style)!!.destructured
|
||||
var p1 = v1.dropLast(2).toInt()
|
||||
val p2 = v2.dropLast(2).toInt()
|
||||
if (p2 != -1) {
|
||||
p1 += 8
|
||||
}
|
||||
(80 - p1) / 80f
|
||||
}.getOrDefault(Manga.NO_RATING)
|
||||
}
|
||||
|
||||
private fun String.cleanupTitle(): String {
|
||||
val result = StringBuilder(length)
|
||||
var skip = false
|
||||
for (c in this) {
|
||||
when {
|
||||
c == '[' -> skip = true
|
||||
c == ']' -> skip = false
|
||||
c.isWhitespace() && result.isEmpty() -> continue
|
||||
!skip -> result.append(c)
|
||||
}
|
||||
}
|
||||
while (result.lastOrNull()?.isWhitespace() == true) {
|
||||
result.deleteCharAt(result.lastIndex)
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private fun String.cssUrl(): String? {
|
||||
val fromIndex = indexOf("url(")
|
||||
if (fromIndex == -1) {
|
||||
return null
|
||||
}
|
||||
val toIndex = indexOf(')', startIndex = fromIndex)
|
||||
return if (toIndex == -1) {
|
||||
null
|
||||
} else {
|
||||
substring(fromIndex + 4, toIndex).trim()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tagIdByClass(classNames: Collection<String>): String? {
|
||||
val className = classNames.find { x -> x.startsWith("ct") } ?: return null
|
||||
val num = className.drop(2).toIntOrNull(16) ?: return null
|
||||
return 2.0.pow(num).toInt().toString()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
@@ -18,11 +19,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
SortOrder.RATING
|
||||
)
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
val domain = getDomain()
|
||||
val doc = when {
|
||||
@@ -33,22 +34,24 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString()
|
||||
)
|
||||
)
|
||||
tag == null -> loaderContext.httpGet(
|
||||
tags.isNullOrEmpty() -> loaderContext.httpGet(
|
||||
"https://$domain/list?sortType=${
|
||||
getSortKey(
|
||||
sortOrder
|
||||
)
|
||||
}&offset=${offset upBy PAGE_SIZE}"
|
||||
)
|
||||
else -> loaderContext.httpGet(
|
||||
"https://$domain/list/genre/${tag.key}?sortType=${
|
||||
tags.size == 1 -> loaderContext.httpGet(
|
||||
"https://$domain/list/genre/${tags.first().key}?sortType=${
|
||||
getSortKey(
|
||||
sortOrder
|
||||
)
|
||||
}&offset=${offset upBy PAGE_SIZE}"
|
||||
)
|
||||
}.parseHtml()
|
||||
val root = doc.body().getElementById("mangaBox")
|
||||
offset > 0 -> return emptyList()
|
||||
else -> advancedSearch(domain, tags)
|
||||
}.parseHtml().body()
|
||||
val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults"))
|
||||
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
|
||||
val baseHost = root.baseUri().toHttpUrl().host
|
||||
return root.select("div.tile").mapNotNull { node ->
|
||||
@@ -57,7 +60,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
if (descDiv.selectFirst("i.fa-user") != null) {
|
||||
return@mapNotNull null //skip author
|
||||
}
|
||||
val href = imgDiv.selectFirst("a").attr("href")?.inContextOf(node)
|
||||
val href = imgDiv.selectFirst("a")?.attr("href")?.inContextOf(node)
|
||||
if (href == null || href.toHttpUrl().host != baseHost) {
|
||||
return@mapNotNull null // skip external links
|
||||
}
|
||||
@@ -161,11 +164,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name").parseHtml()
|
||||
val root = doc.body().getElementById("mangaBox").selectFirst("div.leftContent")
|
||||
.selectFirst("table.table")
|
||||
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||
?.selectFirst("table.table") ?: parseFailed("Cannot find root")
|
||||
return root.select("a.element-link").mapToSet { a ->
|
||||
MangaTag(
|
||||
title = a.text().capitalize(),
|
||||
title = a.text().toCamelCase(),
|
||||
key = a.attr("href").substringAfterLast('/'),
|
||||
source = source
|
||||
)
|
||||
@@ -182,6 +185,43 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
null -> "updated"
|
||||
}
|
||||
|
||||
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
|
||||
val url = "https://$domain/search/advanced"
|
||||
// Step 1: map catalog genres names to advanced-search genres ids
|
||||
val tagsIndex = loaderContext.httpGet(url).parseHtml()
|
||||
.body().selectFirst("form.search-form")
|
||||
?.select("div.form-group")
|
||||
?.get(1) ?: parseFailed("Genres filter element not found")
|
||||
val tagNames = tags.map { it.title.lowercase() }
|
||||
val payload = HashMap<String, String>()
|
||||
var foundGenres = 0
|
||||
tagsIndex.select("li.property").forEach { li ->
|
||||
val name = li.text().trim().lowercase()
|
||||
val id = li.selectFirst("input")?.id()
|
||||
?: parseFailed("Id for tag $name not found")
|
||||
payload[id] = if (name in tagNames) {
|
||||
foundGenres++
|
||||
"in"
|
||||
} else ""
|
||||
}
|
||||
if (foundGenres != tags.size) {
|
||||
parseFailed("Some genres are not found")
|
||||
}
|
||||
// Step 2: advanced search
|
||||
payload["q"] = ""
|
||||
payload["s_high_rate"] = ""
|
||||
payload["s_single"] = ""
|
||||
payload["s_mature"] = ""
|
||||
payload["s_completed"] = ""
|
||||
payload["s_translated"] = ""
|
||||
payload["s_many_chapters"] = ""
|
||||
payload["s_wait_upload"] = ""
|
||||
payload["s_sale"] = ""
|
||||
payload["years"] = "1900,2099"
|
||||
payload["+"] = "Искать".urlEncoded()
|
||||
return loaderContext.httpPost(url, payload)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val PAGE_SIZE = 70
|
||||
|
||||
@@ -8,16 +8,16 @@ import org.koitharu.kotatsu.utils.ext.parseHtml
|
||||
|
||||
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
|
||||
|
||||
override val defaultDomain = "hentaichan.pro"
|
||||
override val defaultDomain = "hentaichan.live"
|
||||
override val source = MangaSource.HENCHAN
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
return super.getList(offset, query, sortOrder, tag).map {
|
||||
return super.getList2(offset, query, tags, sortOrder).map {
|
||||
val cover = it.coverUrl
|
||||
if (cover.contains("_blur")) {
|
||||
it.copy(coverUrl = cover.replace("_blur", ""))
|
||||
@@ -36,7 +36,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
|
||||
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
||||
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
||||
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
|
||||
val a = it.children().last()
|
||||
val a = it.children().last() ?: parseFailed("Invalid tag")
|
||||
MangaTag(
|
||||
title = a.text(),
|
||||
key = a.attr("href").substringAfterLast('/'),
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
RemoteMangaRepository(loaderContext) {
|
||||
@@ -27,11 +26,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
SortOrder.NEWEST
|
||||
)
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
return if (offset == 0) search(query) else emptyList()
|
||||
@@ -44,20 +43,21 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
append(getSortKey(sortOrder))
|
||||
append("&page=")
|
||||
append(page)
|
||||
if (tag != null) {
|
||||
append("&includeGenres[]=")
|
||||
tags?.forEach { tag ->
|
||||
append("&genres[include][]=")
|
||||
append(tag.key)
|
||||
}
|
||||
}
|
||||
val doc = loaderContext.httpGet(url).parseHtml()
|
||||
val root = doc.body().getElementById("manga-list") ?: throw ParseException("Root not found")
|
||||
val items = root.selectFirst("div.media-cards-grid").select("div.media-card-wrap")
|
||||
val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap")
|
||||
?: return emptyList()
|
||||
return items.mapNotNull { card ->
|
||||
val a = card.selectFirst("a.media-card") ?: return@mapNotNull null
|
||||
val href = a.relUrl("href")
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
title = card.selectFirst("h3").text(),
|
||||
title = card.selectFirst("h3")?.text().orEmpty(),
|
||||
coverUrl = a.absUrl("data-src"),
|
||||
altTitle = null,
|
||||
author = null,
|
||||
@@ -98,10 +98,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
append(item.getInt("chapter_volume"))
|
||||
append("/c")
|
||||
append(item.getString("chapter_number"))
|
||||
@Suppress("BlockingMethodInNonBlockingContext") // lint issue
|
||||
append('/')
|
||||
append(item.optString("chapter_string"))
|
||||
}
|
||||
var name = item.getString("chapter_name")
|
||||
var name = item.getStringOrNull("chapter_name")
|
||||
if (name.isNullOrBlank() || name == "null") {
|
||||
name = "Том " + item.getInt("chapter_volume") +
|
||||
" Глава " + item.getString("chapter_number")
|
||||
@@ -128,17 +129,17 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
rating = root.selectFirst("div.media-stats-item__score")
|
||||
?.selectFirst("span")
|
||||
?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating,
|
||||
author = info.getElementsMatchingOwnText("Автор").firstOrNull()
|
||||
author = info?.getElementsMatchingOwnText("Автор")?.firstOrNull()
|
||||
?.nextElementSibling()?.text() ?: manga.author,
|
||||
tags = info.selectFirst("div.media-tags")
|
||||
tags = info?.selectFirst("div.media-tags")
|
||||
?.select("a.media-tag-item")?.mapToSet { a ->
|
||||
MangaTag(
|
||||
title = a.text().capitalize(),
|
||||
title = a.text().toCamelCase(),
|
||||
key = a.attr("href").substringAfterLast('='),
|
||||
source = source
|
||||
)
|
||||
} ?: manga.tags,
|
||||
description = info.selectFirst("div.media-description__text")?.html(),
|
||||
description = info?.selectFirst("div.media-description__text")?.html(),
|
||||
chapters = chapters
|
||||
)
|
||||
}
|
||||
@@ -146,11 +147,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val fullUrl = chapter.url.withDomain()
|
||||
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
||||
if (doc.location()?.endsWith("/register") == true) {
|
||||
if (doc.location().endsWith("/register")) {
|
||||
throw AuthRequiredException("/login".inContextOf(doc))
|
||||
}
|
||||
val scripts = doc.head().select("script")
|
||||
val pg = doc.body().getElementById("pg").html()
|
||||
val pg = (doc.body().getElementById("pg")?.html() ?: parseFailed("Element #pg not found"))
|
||||
.substringAfter('=')
|
||||
.substringBeforeLast(';')
|
||||
val pages = JSONArray(pg)
|
||||
@@ -196,7 +197,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
result += MangaTag(
|
||||
source = source,
|
||||
key = x.getInt("id").toString(),
|
||||
title = x.getString("name").capitalize()
|
||||
title = x.getString("name").toCamelCase()
|
||||
)
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -23,11 +23,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
SortOrder.UPDATED
|
||||
)
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
val sortKey = when (sortOrder) {
|
||||
SortOrder.ALPHABETICAL -> "?name.az"
|
||||
@@ -43,22 +43,28 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
}
|
||||
"/search?name=${query.urlEncoded()}".withDomain()
|
||||
}
|
||||
tag != null -> "/directory/${tag.key}/$page.htm$sortKey".withDomain()
|
||||
else -> "/directory/$page.htm$sortKey".withDomain()
|
||||
tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".withDomain()
|
||||
tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".withDomain()
|
||||
else -> tags.joinToString(
|
||||
prefix = "/search?page=$page".withDomain()
|
||||
) { tag ->
|
||||
"&genres[${tag.key}]=1"
|
||||
}
|
||||
}
|
||||
val doc = loaderContext.httpGet(url).parseHtml()
|
||||
val root = doc.body().selectFirst("ul.manga_pic_list")
|
||||
?: throw ParseException("Root not found")
|
||||
return root.select("li").mapNotNull { li ->
|
||||
val a = li.selectFirst("a.manga_cover")
|
||||
val href = a.relUrl("href")
|
||||
val href = a?.relUrl("href")
|
||||
?: return@mapNotNull null
|
||||
val views = li.select("p.view")
|
||||
val status = views.findOwnText { x -> x.startsWith("Status:") }
|
||||
?.substringAfter(':')?.trim()?.toLowerCase(Locale.ROOT)
|
||||
?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT)
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
title = a.attr("title"),
|
||||
coverUrl = a.selectFirst("img").absUrl("src"),
|
||||
coverUrl = a.selectFirst("img")?.absUrl("src").orEmpty(),
|
||||
source = MangaSource.MANGATOWN,
|
||||
altTitle = null,
|
||||
rating = li.selectFirst("p.score")?.selectFirst("b")
|
||||
@@ -87,11 +93,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
|
||||
val root = doc.body().selectFirst("section.main")
|
||||
?.selectFirst("div.article_content") ?: throw ParseException("Cannot find root")
|
||||
val info = root.selectFirst("div.detail_info").selectFirst("ul")
|
||||
val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
|
||||
val chaptersList = root.selectFirst("div.chapter_content")
|
||||
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
|
||||
return manga.copy(
|
||||
tags = manga.tags + info.select("li").find { x ->
|
||||
tags = manga.tags + info?.select("li")?.find { x ->
|
||||
x.selectFirst("b")?.ownText() == "Genre(s):"
|
||||
}?.select("a")?.mapNotNull { a ->
|
||||
MangaTag(
|
||||
@@ -100,9 +106,10 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
source = MangaSource.MANGATOWN
|
||||
)
|
||||
}.orEmpty(),
|
||||
description = info.getElementById("show")?.ownText(),
|
||||
description = info?.getElementById("show")?.ownText(),
|
||||
chapters = chaptersList?.mapIndexedNotNull { i, li ->
|
||||
val href = li.selectFirst("a").relUrl("href")
|
||||
val href = li.selectFirst("a")?.relUrl("href")
|
||||
?: return@mapIndexedNotNull null
|
||||
val name = li.select("span").filter { it.className().isEmpty() }
|
||||
.joinToString(" - ") { it.text() }.trim()
|
||||
MangaChapter(
|
||||
@@ -110,7 +117,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
url = href,
|
||||
source = MangaSource.MANGATOWN,
|
||||
number = i + 1,
|
||||
name = if (name.isEmpty()) "${manga.title} - ${i + 1}" else name
|
||||
name = name.ifEmpty { "${manga.title} - ${i + 1}" }
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -121,7 +128,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
||||
val root = doc.body().selectFirst("div.page_select")
|
||||
?: throw ParseException("Cannot find root")
|
||||
return root.selectFirst("select").select("option").mapNotNull {
|
||||
return root.selectFirst("select")?.select("option")?.mapNotNull {
|
||||
val href = it.relUrl("value")
|
||||
if (href.endsWith("featured.html")) {
|
||||
return@mapNotNull null
|
||||
@@ -132,20 +139,20 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
referer = fullUrl,
|
||||
source = MangaSource.MANGATOWN
|
||||
)
|
||||
}
|
||||
} ?: parseFailed("Pages list not found")
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String {
|
||||
val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml()
|
||||
return doc.getElementById("image").absUrl("src")
|
||||
return doc.getElementById("image")?.absUrl("src") ?: parseFailed("Image not found")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("/directory/".withDomain()).parseHtml()
|
||||
val root = doc.body().selectFirst("aside.right")
|
||||
.getElementsContainingOwnText("Genres")
|
||||
.first()
|
||||
.nextElementSibling()
|
||||
?.getElementsContainingOwnText("Genres")
|
||||
?.first()
|
||||
?.nextElementSibling() ?: parseFailed("Root not found")
|
||||
return root.select("li").mapNotNullToSet { li ->
|
||||
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
||||
val key = a.attr("href").parseTagKey()
|
||||
|
||||
@@ -20,17 +20,19 @@ class MangareadRepository(
|
||||
SortOrder.POPULARITY
|
||||
)
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
if (offset % PAGE_SIZE != 0) {
|
||||
return emptyList()
|
||||
val tag = when {
|
||||
tags.isNullOrEmpty() -> null
|
||||
tags.size == 1 -> tags.first()
|
||||
else -> throw NotImplementedError("Multiple genres are not supported by this source")
|
||||
}
|
||||
val payload = createRequestTemplate()
|
||||
payload["page"] = (offset / PAGE_SIZE).toString()
|
||||
payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString()
|
||||
payload["vars[meta_key]"] = when (sortOrder) {
|
||||
SortOrder.POPULARITY -> "_wp_manga_views"
|
||||
SortOrder.UPDATED -> "_latest_update"
|
||||
@@ -43,26 +45,26 @@ class MangareadRepository(
|
||||
payload
|
||||
).parseHtml()
|
||||
return doc.select("div.row.c-tabs-item__content").map { div ->
|
||||
val href = div.selectFirst("a").relUrl("href")
|
||||
val href = div.selectFirst("a")?.relUrl("href")
|
||||
?: parseFailed("Link not found")
|
||||
val summary = div.selectFirst(".tab-summary")
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
url = href,
|
||||
publicUrl = href.inContextOf(div),
|
||||
coverUrl = div.selectFirst("img").attr("data-srcset")
|
||||
.split(',').firstOrNull()?.substringBeforeLast(' ').orEmpty(),
|
||||
title = summary.selectFirst("h3").text(),
|
||||
coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(),
|
||||
title = summary?.selectFirst("h3")?.text().orEmpty(),
|
||||
rating = div.selectFirst("span.total_votes")?.ownText()
|
||||
?.toFloatOrNull()?.div(5f) ?: -1f,
|
||||
tags = summary.selectFirst(".mg_genres").select("a").mapToSet { a ->
|
||||
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
|
||||
MangaTag(
|
||||
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
|
||||
title = a.text(),
|
||||
source = MangaSource.MANGAREAD
|
||||
)
|
||||
},
|
||||
author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
|
||||
state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content")
|
||||
}.orEmpty(),
|
||||
author = summary?.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
|
||||
state = when (summary?.selectFirst(".mg_status")?.selectFirst(".summary-content")
|
||||
?.ownText()?.trim()) {
|
||||
"OnGoing" -> MangaState.ONGOING
|
||||
"Completed" -> MangaState.FINISHED
|
||||
@@ -76,9 +78,9 @@ class MangareadRepository(
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml()
|
||||
val root = doc.body().selectFirst("header")
|
||||
.selectFirst("ul.second-menu")
|
||||
?.selectFirst("ul.second-menu") ?: parseFailed("Root not found")
|
||||
return root.select("li").mapNotNullToSet { li ->
|
||||
val a = li.selectFirst("a")
|
||||
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
||||
val href = a.attr("href").removeSuffix("/")
|
||||
.substringAfterLast("genres/", "")
|
||||
if (href.isEmpty()) {
|
||||
@@ -102,8 +104,8 @@ class MangareadRepository(
|
||||
val root2 = doc.body().selectFirst("div.content-area")
|
||||
?.selectFirst("div.c-page")
|
||||
?: throw ParseException("Root2 not found")
|
||||
val mangaId = doc.getElementsByAttribute("data-postid").firstOrNull()
|
||||
?.attr("data-postid")?.toLongOrNull()
|
||||
val mangaId = doc.getElementsByAttribute("data-post").firstOrNull()
|
||||
?.attr("data-post")?.toLongOrNull()
|
||||
?: throw ParseException("Cannot obtain manga id")
|
||||
val doc2 = loaderContext.httpPost(
|
||||
"https://${getDomain()}/wp-admin/admin-ajax.php",
|
||||
@@ -128,10 +130,12 @@ class MangareadRepository(
|
||||
?.joinToString { it.html() },
|
||||
chapters = doc2.select("li").asReversed().mapIndexed { i, li ->
|
||||
val a = li.selectFirst("a")
|
||||
val href = a.relUrl("href")
|
||||
val href = a?.relUrl("href").orEmpty().ifEmpty {
|
||||
parseFailed("Link is missing")
|
||||
}
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.ownText(),
|
||||
name = a!!.ownText(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
source = MangaSource.MANGAREAD
|
||||
@@ -148,7 +152,7 @@ class MangareadRepository(
|
||||
?: throw ParseException("Root not found")
|
||||
return root.select("div.page-break").map { div ->
|
||||
val img = div.selectFirst("img")
|
||||
val url = img.relUrl("data-src")
|
||||
val url = img?.relUrl("src") ?: parseFailed("Page image not found")
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
@@ -170,4 +174,4 @@ class MangareadRepository(
|
||||
it.substring(0, pos) to it.substring(pos + 1)
|
||||
}.toMutableMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
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.util.*
|
||||
|
||||
abstract class NineMangaRepository(
|
||||
loaderContext: MangaLoaderContext,
|
||||
override val source: MangaSource,
|
||||
override val defaultDomain: String,
|
||||
) : RemoteMangaRepository(loaderContext) {
|
||||
|
||||
init {
|
||||
loaderContext.cookieJar.insertCookies(getDomain(), "ninemanga_template_desk=yes")
|
||||
}
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||
SortOrder.POPULARITY,
|
||||
)
|
||||
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
|
||||
val url = buildString {
|
||||
append("https://")
|
||||
append(getDomain())
|
||||
when {
|
||||
!query.isNullOrEmpty() -> {
|
||||
append("/search/?name_sel=&wd=")
|
||||
append(query.urlEncoded())
|
||||
append("&page=")
|
||||
}
|
||||
!tags.isNullOrEmpty() -> {
|
||||
append("/search/&category_id=")
|
||||
for (tag in tags) {
|
||||
append(tag.key)
|
||||
append(',')
|
||||
}
|
||||
append("&page=")
|
||||
}
|
||||
else -> {
|
||||
append("/category/index_")
|
||||
}
|
||||
}
|
||||
append(page)
|
||||
append(".html")
|
||||
}
|
||||
val doc = loaderContext.httpGet(url, PREDEFINED_HEADERS).parseHtml()
|
||||
val root = doc.body().selectFirst("ul.direlist")
|
||||
?: throw ParseException("Cannot find root")
|
||||
val baseHost = root.baseUri().toHttpUrl().host
|
||||
return root.select("li").map { node ->
|
||||
val href = node.selectFirst("a")?.absUrl("href")
|
||||
?: parseFailed("Link not found")
|
||||
val relUrl = href.toRelativeUrl(baseHost)
|
||||
val dd = node.selectFirst("dd")
|
||||
Manga(
|
||||
id = generateUid(relUrl),
|
||||
url = relUrl,
|
||||
publicUrl = href,
|
||||
title = dd?.selectFirst("a.bookname")?.text()?.toCamelCase().orEmpty(),
|
||||
altTitle = null,
|
||||
coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(),
|
||||
rating = Manga.NO_RATING,
|
||||
author = null,
|
||||
tags = emptySet(),
|
||||
state = null,
|
||||
source = source,
|
||||
description = dd?.selectFirst("p")?.html(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val doc = loaderContext.httpGet(
|
||||
manga.url.withDomain() + "?waring=1",
|
||||
PREDEFINED_HEADERS
|
||||
).parseHtml()
|
||||
val root = doc.body().selectFirst("div.manga")
|
||||
?: throw ParseException("Cannot find root")
|
||||
val infoRoot = root.selectFirst("div.bookintro")
|
||||
?: throw ParseException("Cannot find info")
|
||||
return manga.copy(
|
||||
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
|
||||
?.select("a")?.mapToSet { a ->
|
||||
MangaTag(
|
||||
title = a.text(),
|
||||
key = a.attr("href").substringBetween("/", "."),
|
||||
source = source,
|
||||
)
|
||||
}.orEmpty(),
|
||||
author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(),
|
||||
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
|
||||
?.html()?.substringAfter("</b>"),
|
||||
chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul")
|
||||
?.select("li")?.asReversed()?.mapIndexed { i, li ->
|
||||
val a = li.selectFirst("a")
|
||||
val href = a?.relUrl("href") ?: parseFailed("Link not found")
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.text(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
branch = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val doc = loaderContext.httpGet(chapter.url.withDomain(), PREDEFINED_HEADERS).parseHtml()
|
||||
return doc.body().getElementById("page")?.select("option")?.map { option ->
|
||||
val url = option.attr("value")
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
referer = chapter.url.withDomain(),
|
||||
preview = null,
|
||||
source = source,
|
||||
)
|
||||
} ?: throw ParseException("Pages list not found at ${chapter.url}")
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String {
|
||||
val doc = loaderContext.httpGet(page.url.withDomain(), PREDEFINED_HEADERS).parseHtml()
|
||||
val root = doc.body()
|
||||
return root.selectFirst("a.pic_download")?.absUrl("href")
|
||||
?: throw ParseException("Page image not found")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/search/?type=high", PREDEFINED_HEADERS)
|
||||
.parseHtml()
|
||||
val root = doc.body().getElementById("search_form")
|
||||
return root?.select("li.cate_list")?.mapNotNullToSet { li ->
|
||||
val cateId = li.attr("cate_id") ?: return@mapNotNullToSet null
|
||||
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
|
||||
MangaTag(
|
||||
title = a.text().toTitleCase(),
|
||||
key = cateId,
|
||||
source = source
|
||||
)
|
||||
} ?: parseFailed("Root not found")
|
||||
}
|
||||
|
||||
class English(loaderContext: MangaLoaderContext) : NineMangaRepository(
|
||||
loaderContext,
|
||||
MangaSource.NINEMANGA_EN,
|
||||
"www.ninemanga.com",
|
||||
)
|
||||
|
||||
class Spanish(loaderContext: MangaLoaderContext) : NineMangaRepository(
|
||||
loaderContext,
|
||||
MangaSource.NINEMANGA_ES,
|
||||
"es.ninemanga.com",
|
||||
)
|
||||
|
||||
class Russian(loaderContext: MangaLoaderContext) : NineMangaRepository(
|
||||
loaderContext,
|
||||
MangaSource.NINEMANGA_RU,
|
||||
"ru.ninemanga.com",
|
||||
)
|
||||
|
||||
class Deutsch(loaderContext: MangaLoaderContext) : NineMangaRepository(
|
||||
loaderContext,
|
||||
MangaSource.NINEMANGA_DE,
|
||||
"de.ninemanga.com",
|
||||
)
|
||||
|
||||
class Brazil(loaderContext: MangaLoaderContext) : NineMangaRepository(
|
||||
loaderContext,
|
||||
MangaSource.NINEMANGA_BR,
|
||||
"br.ninemanga.com",
|
||||
)
|
||||
|
||||
class Italiano(loaderContext: MangaLoaderContext) : NineMangaRepository(
|
||||
loaderContext,
|
||||
MangaSource.NINEMANGA_IT,
|
||||
"it.ninemanga.com",
|
||||
)
|
||||
|
||||
class Francais(loaderContext: MangaLoaderContext) : NineMangaRepository(
|
||||
loaderContext,
|
||||
MangaSource.NINEMANGA_FR,
|
||||
"fr.ninemanga.com",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
const val PAGE_SIZE = 26
|
||||
|
||||
val PREDEFINED_HEADERS = Headers.Builder()
|
||||
.add("Accept-Language", "en-US;q=0.7,en;q=0.3")
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
|
||||
@@ -18,18 +17,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
override val defaultDomain = "remanga.org"
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.POPULARITY,
|
||||
SortOrder.RATING,
|
||||
SortOrder.ALPHABETICAL,
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.NEWEST
|
||||
)
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
val domain = getDomain()
|
||||
val urlBuilder = StringBuilder()
|
||||
@@ -41,8 +39,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
} else {
|
||||
urlBuilder.append("/api/search/catalog/?ordering=")
|
||||
.append(getSortKey(sortOrder))
|
||||
if (tag != null) {
|
||||
urlBuilder.append("&genres=" + tag.key)
|
||||
tags?.forEach { tag ->
|
||||
urlBuilder.append("&genres=")
|
||||
urlBuilder.append(tag.key)
|
||||
}
|
||||
}
|
||||
urlBuilder
|
||||
@@ -162,7 +161,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
SortOrder.POPULARITY -> "-rating"
|
||||
SortOrder.RATING -> "-votes"
|
||||
SortOrder.NEWEST -> "-id"
|
||||
else -> "-rating"
|
||||
else -> "-chapter_date"
|
||||
}
|
||||
|
||||
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
|
||||
|
||||
@@ -41,6 +41,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
|
||||
val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false)
|
||||
|
||||
val isToolbarHideWhenScrolling by BoolPreferenceDelegate(KEY_HIDE_TOOLBAR, defaultValue = true)
|
||||
|
||||
var gridSize by IntPreferenceDelegate(KEY_GRID_SIZE, defaultValue = 100)
|
||||
|
||||
val readerPageSwitch by StringSetPreferenceDelegate(
|
||||
@@ -99,6 +101,9 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
|
||||
var hiddenSources by StringSetPreferenceDelegate(KEY_SOURCES_HIDDEN)
|
||||
|
||||
val isSourcesSelected: Boolean
|
||||
get() = KEY_SOURCES_HIDDEN in prefs
|
||||
|
||||
fun getStorageDir(context: Context): File? {
|
||||
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
File(it)
|
||||
@@ -147,6 +152,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_APP_SECTION = "app_section"
|
||||
const val KEY_THEME = "theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
const val KEY_HIDE_TOOLBAR = "hide_toolbar"
|
||||
const val KEY_SOURCES_ORDER = "sources_order"
|
||||
const val KEY_SOURCES_HIDDEN = "sources_hidden"
|
||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||
@@ -160,8 +166,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||
const val KEY_READER_SWITCHERS = "reader_switchers"
|
||||
const val KEY_TRACK_SOURCES = "track_sources"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
|
||||
const val KEY_TRACK_WARNING = "track_warning"
|
||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||
@@ -177,5 +182,14 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_RESTORE = "restore"
|
||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val KEY_APP_GRATITUDES = "about_gratitudes"
|
||||
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
|
||||
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
|
||||
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
|
||||
}
|
||||
}
|
||||
@@ -27,5 +27,6 @@ interface SourceSettings {
|
||||
|
||||
const val KEY_DOMAIN = "domain"
|
||||
const val KEY_USE_SSL = "ssl"
|
||||
const val KEY_AUTH = "auth"
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,12 @@ package org.koitharu.kotatsu.details
|
||||
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.base.domain.MangaIntent
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
|
||||
val detailsModule
|
||||
get() = module {
|
||||
|
||||
viewModel { (intent: MangaIntent) ->
|
||||
DetailsViewModel(intent, get(), get(), get(), get(), get(), get())
|
||||
viewModel { intent ->
|
||||
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get())
|
||||
}
|
||||
}
|
||||
@@ -15,19 +15,20 @@ import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.download.DownloadService
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
|
||||
class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
|
||||
OnListItemClickListener<MangaChapter>, ActionMode.Callback, AdapterView.OnItemSelectedListener {
|
||||
OnListItemClickListener<ChapterListItem>,
|
||||
ActionMode.Callback,
|
||||
AdapterView.OnItemSelectedListener {
|
||||
|
||||
private val viewModel by sharedViewModel<DetailsViewModel>()
|
||||
|
||||
@@ -105,9 +106,9 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaChapter, view: View) {
|
||||
override fun onItemClick(item: ChapterListItem, view: View) {
|
||||
if (selectionDecoration?.checkedItemsCount != 0) {
|
||||
selectionDecoration?.toggleItemChecked(item.id)
|
||||
selectionDecoration?.toggleItemChecked(item.chapter.id)
|
||||
if (selectionDecoration?.checkedItemsCount == 0) {
|
||||
actionMode?.finish()
|
||||
} else {
|
||||
@@ -116,6 +117,10 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
|
||||
}
|
||||
return
|
||||
}
|
||||
if (item.isMissing) {
|
||||
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
|
||||
return
|
||||
}
|
||||
val options = ActivityOptions.makeScaleUpAnimation(
|
||||
view,
|
||||
0,
|
||||
@@ -127,17 +132,17 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
|
||||
ReaderActivity.newIntent(
|
||||
view.context,
|
||||
viewModel.manga.value ?: return,
|
||||
ReaderState(item.id, 0, 0)
|
||||
ReaderState(item.chapter.id, 0, 0)
|
||||
), options.toBundle()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: MangaChapter, view: View): Boolean {
|
||||
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
|
||||
if (actionMode == null) {
|
||||
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
|
||||
}
|
||||
return actionMode?.also {
|
||||
selectionDecoration?.setItemIsChecked(item.id, true)
|
||||
selectionDecoration?.setItemIsChecked(item.chapter.id, true)
|
||||
binding.recyclerViewChapters.invalidateItemDecorations()
|
||||
it.invalidate()
|
||||
} != null
|
||||
@@ -148,7 +153,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
|
||||
R.id.action_save -> {
|
||||
DownloadService.start(
|
||||
context ?: return false,
|
||||
viewModel.manga.value ?: return false,
|
||||
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
|
||||
selectionDecoration?.checkedItemsIds
|
||||
)
|
||||
mode.finish()
|
||||
@@ -174,17 +179,20 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
val manga = viewModel.manga.value
|
||||
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||
menu.findItem(R.id.action_save).isVisible = manga?.source != MangaSource.LOCAL
|
||||
mode.title = manga?.title
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
val count = selectionDecoration?.checkedItemsCount ?: return false
|
||||
val selectedIds = selectionDecoration?.checkedItemsIds ?: return false
|
||||
val items = chaptersAdapter?.items?.filter { x -> x.chapter.id in selectedIds }.orEmpty()
|
||||
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
||||
x.chapter.source == MangaSource.LOCAL
|
||||
}
|
||||
mode.subtitle = resources.getQuantityString(
|
||||
R.plurals.chapters_from_x,
|
||||
count,
|
||||
count,
|
||||
items.size,
|
||||
items.size,
|
||||
chaptersAdapter?.itemCount ?: 0
|
||||
)
|
||||
return true
|
||||
|
||||
@@ -33,9 +33,12 @@ import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
||||
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
|
||||
import org.koitharu.kotatsu.download.DownloadService
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.ext.buildAlertDialog
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
@@ -113,7 +116,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_details, menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
@@ -228,6 +231,33 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
binding.pager.isUserInputEnabled = true
|
||||
}
|
||||
|
||||
fun showChapterMissingDialog(chapterId: Long) {
|
||||
val remoteManga = viewModel.getRemoteManga()
|
||||
if (remoteManga == null) {
|
||||
Snackbar.make(binding.pager, R.string.chapter_is_missing, Snackbar.LENGTH_LONG)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(this) {
|
||||
setMessage(R.string.chapter_is_missing_text)
|
||||
setTitle(R.string.chapter_is_missing)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.read) { _, _ ->
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
this@DetailsActivity,
|
||||
remoteManga,
|
||||
ReaderState(chapterId, 0, 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
setNeutralButton(R.string.download) { _, _ ->
|
||||
DownloadService.start(this@DetailsActivity, remoteManga, setOf(chapterId))
|
||||
}
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA"
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import coil.util.CoilUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
@@ -23,20 +22,20 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
|
||||
View.OnLongClickListener {
|
||||
|
||||
private val viewModel by sharedViewModel<DetailsViewModel>()
|
||||
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
|
||||
private var tagsJob: Job? = null
|
||||
|
||||
override fun onInflateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -61,16 +60,43 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
.enqueueWith(coil)
|
||||
textViewTitle.text = manga.title
|
||||
textViewSubtitle.textAndVisible = manga.altTitle
|
||||
textViewAuthor.textAndVisible = manga.author
|
||||
sourceContainer.isVisible = manga.source != MangaSource.LOCAL
|
||||
textViewSource.text = manga.source.title
|
||||
textViewDescription.text =
|
||||
manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
|
||||
?: getString(R.string.no_description)
|
||||
if (manga.rating == Manga.NO_RATING) {
|
||||
ratingBar.isVisible = false
|
||||
if (manga.chapters?.isNotEmpty() == true) {
|
||||
chaptersContainer.isVisible = true
|
||||
textViewChapters.text = manga.chapters.let {
|
||||
resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
it.size,
|
||||
manga.chapters.size
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ratingBar.progress = (ratingBar.max * manga.rating).roundToInt()
|
||||
ratingBar.isVisible = true
|
||||
chaptersContainer.isVisible = false
|
||||
}
|
||||
imageViewFavourite.setOnClickListener(this@DetailsFragment)
|
||||
if (manga.rating == Manga.NO_RATING) {
|
||||
ratingContainer.isVisible = false
|
||||
} else {
|
||||
textViewRating.text = String.format("%.1f", manga.rating * 5)
|
||||
ratingContainer.isVisible = true
|
||||
}
|
||||
val file = manga.url.toUri().toFileOrNull()
|
||||
if (file != null) {
|
||||
viewLifecycleScope.launch {
|
||||
val size = withContext(Dispatchers.IO) {
|
||||
file.length()
|
||||
}
|
||||
textViewSize.text = FileSizeUtils.formatBytes(requireContext(), size)
|
||||
}
|
||||
sizeContainer.isVisible = true
|
||||
} else {
|
||||
sizeContainer.isVisible = false
|
||||
}
|
||||
buttonFavorite.setOnClickListener(this@DetailsFragment)
|
||||
buttonRead.setOnClickListener(this@DetailsFragment)
|
||||
buttonRead.setOnLongClickListener(this@DetailsFragment)
|
||||
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
|
||||
@@ -91,33 +117,42 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
}
|
||||
|
||||
private fun onFavouriteChanged(isFavourite: Boolean) {
|
||||
binding.imageViewFavourite.setImageResource(
|
||||
with(binding.buttonFavorite) {
|
||||
if (isFavourite) {
|
||||
R.drawable.ic_heart
|
||||
this.setIconResource(R.drawable.ic_heart)
|
||||
} else {
|
||||
R.drawable.ic_heart_outline
|
||||
this.setIconResource(R.drawable.ic_heart_outline)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
binding.progressBar.isVisible = isLoading
|
||||
if (isLoading) {
|
||||
binding.progressBar.show()
|
||||
} else {
|
||||
binding.progressBar.hide()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val manga = viewModel.manga.value
|
||||
val manga = viewModel.manga.value ?: return
|
||||
when (v.id) {
|
||||
R.id.imageView_favourite -> {
|
||||
FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return)
|
||||
R.id.button_favorite -> {
|
||||
FavouriteCategoriesDialog.show(childFragmentManager, manga)
|
||||
}
|
||||
R.id.button_read -> {
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
context ?: return,
|
||||
manga ?: return,
|
||||
null
|
||||
val chapterId = viewModel.readingHistory.value?.chapterId
|
||||
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
|
||||
(activity as? DetailsActivity)?.showChapterMissingDialog(chapterId)
|
||||
} else {
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
context ?: return,
|
||||
manga,
|
||||
null
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,37 +195,13 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
}
|
||||
|
||||
private fun bindTags(manga: Manga) {
|
||||
tagsJob?.cancel()
|
||||
tagsJob = viewLifecycleScope.launch {
|
||||
val tags = ArrayList<ChipsView.ChipModel>(manga.tags.size + 2)
|
||||
if (manga.author != null) {
|
||||
tags += ChipsView.ChipModel(
|
||||
title = manga.author,
|
||||
icon = R.drawable.ic_chip_user
|
||||
)
|
||||
}
|
||||
for (tag in manga.tags) {
|
||||
tags += ChipsView.ChipModel(
|
||||
binding.chipsTags.setChips(
|
||||
manga.tags.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
icon = R.drawable.ic_chip_tag
|
||||
icon = 0
|
||||
)
|
||||
}
|
||||
val file = manga.url.toUri().toFileOrNull()
|
||||
if (file != null) {
|
||||
val size = withContext(Dispatchers.IO) {
|
||||
file.length()
|
||||
}
|
||||
tags += ChipsView.ChipModel(
|
||||
title = FileSizeUtils.formatBytes(requireContext(), size),
|
||||
icon = R.drawable.ic_chip_storage
|
||||
)
|
||||
} else {
|
||||
tags += ChipsView.ChipModel(
|
||||
title = manga.source.title,
|
||||
icon = R.drawable.ic_chip_web
|
||||
)
|
||||
}
|
||||
binding.chipsTags.setChips(tags)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,11 @@ import org.koitharu.kotatsu.base.domain.MangaIntent
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
@@ -29,7 +33,7 @@ class DetailsViewModel(
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val settings: AppSettings
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val mangaData = MutableStateFlow<Manga?>(intent.manga)
|
||||
@@ -53,6 +57,18 @@ class DetailsViewModel(
|
||||
trackingRepository.getNewChaptersCount(mangaId)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||
|
||||
private val remoteManga = MutableStateFlow<Manga?>(null)
|
||||
/*private val remoteManga = mangaData.mapLatest {
|
||||
if (it?.source == MangaSource.LOCAL) {
|
||||
runCatching {
|
||||
val m = localMangaRepository.getRemoteManga(it) ?: return@mapLatest null
|
||||
MangaRepository(m.source).getDetails(m)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)*/
|
||||
|
||||
private val chaptersReversed = settings.observe()
|
||||
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
|
||||
.map { settings.chaptersReverse }
|
||||
@@ -85,24 +101,19 @@ class DetailsViewModel(
|
||||
|
||||
val chapters = combine(
|
||||
mangaData.map { it?.chapters.orEmpty() },
|
||||
remoteManga,
|
||||
history.map { it?.chapterId },
|
||||
newChapters,
|
||||
chaptersReversed,
|
||||
selectedBranch
|
||||
) { chapters, currentId, newCount, reversed, branch ->
|
||||
val currentIndex = chapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = chapters.size - newCount
|
||||
val res = chapters.mapIndexed { index, chapter ->
|
||||
chapter.toListItem(
|
||||
when {
|
||||
index >= firstNewIndex -> ChapterExtra.NEW
|
||||
index == currentIndex -> ChapterExtra.CURRENT
|
||||
index < currentIndex -> ChapterExtra.READ
|
||||
else -> ChapterExtra.UNREAD
|
||||
}
|
||||
)
|
||||
}.filter { it.chapter.branch == branch }
|
||||
if (reversed) res.asReversed() else res
|
||||
) { chapters, sourceManga, currentId, newCount, branch ->
|
||||
val sourceChapters = sourceManga?.chapters
|
||||
if (sourceChapters.isNullOrEmpty()) {
|
||||
mapChapters(chapters, currentId, newCount, branch)
|
||||
} else {
|
||||
mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch)
|
||||
}
|
||||
}.combine(chaptersReversed) { list, reversed ->
|
||||
if (reversed) list.asReversed() else list
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
init {
|
||||
@@ -121,6 +132,12 @@ class DetailsViewModel(
|
||||
?.maxByOrNull { it.value.size }?.key
|
||||
}
|
||||
mangaData.value = manga
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
remoteManga.value = runCatching {
|
||||
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
|
||||
MangaRepository(m.source).getDetails(m)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,4 +159,80 @@ class DetailsViewModel(
|
||||
fun setSelectedBranch(branch: String?) {
|
||||
selectedBranch.value = branch
|
||||
}
|
||||
|
||||
fun getRemoteManga(): Manga? {
|
||||
return remoteManga.value
|
||||
}
|
||||
|
||||
private fun mapChapters(
|
||||
chapters: List<MangaChapter>,
|
||||
currentId: Long?,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val result = ArrayList<ChapterListItem>(chapters.size)
|
||||
val currentIndex = chapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = chapters.size - newCount
|
||||
for (i in chapters.indices) {
|
||||
val chapter = chapters[i]
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
result += chapter.toListItem(
|
||||
extra = when {
|
||||
i >= firstNewIndex -> ChapterExtra.NEW
|
||||
i == currentIndex -> ChapterExtra.CURRENT
|
||||
i < currentIndex -> ChapterExtra.READ
|
||||
else -> ChapterExtra.UNREAD
|
||||
},
|
||||
isMissing = false
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun mapChaptersWithSource(
|
||||
chapters: List<MangaChapter>,
|
||||
sourceChapters: List<MangaChapter>,
|
||||
currentId: Long?,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
|
||||
val result = ArrayList<ChapterListItem>(sourceChapters.size)
|
||||
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = sourceChapters.size - newCount
|
||||
for (i in sourceChapters.indices) {
|
||||
val chapter = sourceChapters[i]
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
val localChapter = chaptersMap.remove(chapter.id)
|
||||
result += localChapter?.toListItem(
|
||||
extra = when {
|
||||
i >= firstNewIndex -> ChapterExtra.NEW
|
||||
i == currentIndex -> ChapterExtra.CURRENT
|
||||
i < currentIndex -> ChapterExtra.READ
|
||||
else -> ChapterExtra.UNREAD
|
||||
},
|
||||
isMissing = false
|
||||
) ?: chapter.toListItem(
|
||||
extra = when {
|
||||
i >= firstNewIndex -> ChapterExtra.NEW
|
||||
i == currentIndex -> ChapterExtra.CURRENT
|
||||
i < currentIndex -> ChapterExtra.READ
|
||||
else -> ChapterExtra.UNREAD
|
||||
},
|
||||
isMissing = true
|
||||
)
|
||||
}
|
||||
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
||||
result.ensureCapacity(result.size + chaptersMap.size)
|
||||
chaptersMap.values.mapTo(result) {
|
||||
it.toListItem(ChapterExtra.UNREAD, false)
|
||||
}
|
||||
result.sortBy { it.chapter.number }
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,22 @@ package org.koitharu.kotatsu.details.ui.adapter
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.databinding.ItemChapterBinding
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
|
||||
fun chapterListItemAD(
|
||||
clickListener: OnListItemClickListener<MangaChapter>
|
||||
clickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) = adapterDelegateViewBinding<ChapterListItem, ChapterListItem, ItemChapterBinding>(
|
||||
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
clickListener.onItemClick(item.chapter, it)
|
||||
clickListener.onItemClick(item, it)
|
||||
}
|
||||
itemView.setOnLongClickListener {
|
||||
clickListener.onItemLongClick(item.chapter, it)
|
||||
clickListener.onItemLongClick(item, it)
|
||||
}
|
||||
|
||||
bind { payload ->
|
||||
@@ -43,5 +42,7 @@ fun chapterListItemAD(
|
||||
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
|
||||
}
|
||||
}
|
||||
binding.textViewTitle.alpha = if (item.isMissing) 0.3f else 1f
|
||||
binding.textViewNumber.alpha = if (item.isMissing) 0.3f else 1f
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.details.ui.adapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import kotlin.jvm.internal.Intrinsics
|
||||
|
||||
class ChaptersAdapter(
|
||||
onItemClickListener: OnListItemClickListener<MangaChapter>
|
||||
onItemClickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) : AsyncListDifferDelegationAdapter<ChapterListItem>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
@@ -38,7 +37,7 @@ class ChaptersAdapter(
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? {
|
||||
if (oldItem.extra != newItem.extra) {
|
||||
if (oldItem.extra != newItem.extra && oldItem.chapter == newItem.chapter) {
|
||||
return newItem.extra
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -5,5 +5,6 @@ import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
|
||||
data class ChapterListItem(
|
||||
val chapter: MangaChapter,
|
||||
val extra: ChapterExtra
|
||||
val extra: ChapterExtra,
|
||||
val isMissing: Boolean,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,11 @@ package org.koitharu.kotatsu.details.ui.model
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
|
||||
fun MangaChapter.toListItem(extra: ChapterExtra) = ChapterListItem(
|
||||
fun MangaChapter.toListItem(
|
||||
extra: ChapterExtra,
|
||||
isMissing: Boolean,
|
||||
) = ChapterListItem(
|
||||
chapter = this,
|
||||
extra = extra
|
||||
extra = extra,
|
||||
isMissing = isMissing,
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
package org.koitharu.kotatsu.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class DownloadNotification(private val context: Context) {
|
||||
|
||||
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
private val manager =
|
||||
context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
&& manager.getNotificationChannel(CHANNEL_ID) == null
|
||||
) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.downloads),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.enableVibration(false)
|
||||
channel.enableLights(false)
|
||||
channel.setSound(null, null)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
builder.setOnlyAlertOnce(true)
|
||||
builder.setDefaults(0)
|
||||
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
|
||||
}
|
||||
|
||||
fun fillFrom(manga: Manga) {
|
||||
builder.setContentTitle(manga.title)
|
||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
builder.setLargeIcon(null)
|
||||
builder.setContentIntent(null)
|
||||
builder.setStyle(null)
|
||||
}
|
||||
|
||||
fun setCancelId(startId: Int) {
|
||||
if (startId == 0) {
|
||||
builder.clearActions()
|
||||
} else {
|
||||
val intent = DownloadService.getCancelIntent(context, startId)
|
||||
builder.addAction(
|
||||
R.drawable.ic_cross,
|
||||
context.getString(android.R.string.cancel),
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
startId,
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setError(e: Throwable) {
|
||||
val message = e.getDisplayMessage(context.resources)
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
builder.setSubText(context.getString(R.string.error))
|
||||
builder.setContentText(message)
|
||||
builder.setAutoCancel(true)
|
||||
builder.setContentIntent(null)
|
||||
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
}
|
||||
|
||||
fun setLargeIcon(icon: Drawable?) {
|
||||
builder.setLargeIcon(icon?.toBitmap())
|
||||
}
|
||||
|
||||
fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) {
|
||||
val max = chaptersTotal * PROGRESS_STEP
|
||||
val progress =
|
||||
chapter * PROGRESS_STEP + (page / pagesTotal.toFloat() * PROGRESS_STEP).roundToInt()
|
||||
val percent = (progress / max.toFloat() * 100).roundToInt()
|
||||
builder.setProgress(max, progress, false)
|
||||
builder.setContentText("%d%%".format(percent))
|
||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
builder.setStyle(null)
|
||||
}
|
||||
|
||||
fun setWaitingForNetwork() {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.waiting_for_network))
|
||||
builder.setStyle(null)
|
||||
}
|
||||
|
||||
fun setPostProcessing() {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.processing_))
|
||||
builder.setStyle(null)
|
||||
}
|
||||
|
||||
fun setDone(manga: Manga) {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.download_complete))
|
||||
builder.setContentIntent(createIntent(context, manga))
|
||||
builder.setAutoCancel(true)
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
builder.setCategory(null)
|
||||
builder.setStyle(null)
|
||||
}
|
||||
|
||||
fun setCancelling() {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.cancelling_))
|
||||
builder.setContentIntent(null)
|
||||
builder.setStyle(null)
|
||||
}
|
||||
|
||||
fun update(id: Int = NOTIFICATION_ID) {
|
||||
manager.notify(id, builder.build())
|
||||
}
|
||||
|
||||
fun dismiss(id: Int = NOTIFICATION_ID) {
|
||||
manager.cancel(id)
|
||||
}
|
||||
|
||||
operator fun invoke(): Notification = builder.build()
|
||||
|
||||
companion object {
|
||||
|
||||
const val NOTIFICATION_ID = 201
|
||||
const val CHANNEL_ID = "download"
|
||||
|
||||
private const val PROGRESS_STEP = 20
|
||||
|
||||
private fun createIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
|
||||
context,
|
||||
manga.hashCode(),
|
||||
DetailsActivity.newIntent(context, manga),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package org.koitharu.kotatsu.download
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.PowerManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.IOException
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseService
|
||||
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class DownloadService : BaseService() {
|
||||
|
||||
private lateinit var notification: DownloadNotification
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
|
||||
private val okHttp by inject<OkHttpClient>()
|
||||
private val cache by inject<PagesCache>()
|
||||
private val settings by inject<AppSettings>()
|
||||
private val imageLoader by inject<ImageLoader>()
|
||||
private val jobs = HashMap<Int, Job>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notification = DownloadNotification(this)
|
||||
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
when (intent?.action) {
|
||||
ACTION_DOWNLOAD_START -> {
|
||||
val manga = intent.getParcelableExtra<Manga>(EXTRA_MANGA)
|
||||
val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
|
||||
if (manga != null) {
|
||||
jobs[startId] = downloadManga(manga, chapters, startId)
|
||||
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
ACTION_DOWNLOAD_CANCEL -> {
|
||||
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
|
||||
jobs.remove(cancelId)?.cancel()
|
||||
stopSelf(startId)
|
||||
}
|
||||
else -> stopSelf(startId)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
|
||||
return lifecycleScope.launch(Dispatchers.Default) {
|
||||
mutex.lock()
|
||||
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
|
||||
notification.fillFrom(manga)
|
||||
notification.setCancelId(startId)
|
||||
withContext(Dispatchers.Main) {
|
||||
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
|
||||
}
|
||||
val destination = settings.getStorageDir(this@DownloadService)
|
||||
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
|
||||
var output: MangaZip? = null
|
||||
try {
|
||||
val repo = mangaRepositoryOf(manga.source)
|
||||
val cover = runCatching {
|
||||
imageLoader.execute(
|
||||
ImageRequest.Builder(this@DownloadService)
|
||||
.data(manga.coverUrl)
|
||||
.build()
|
||||
).drawable
|
||||
}.getOrNull()
|
||||
notification.setLargeIcon(cover)
|
||||
notification.update()
|
||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
||||
output = MangaZip.findInDir(destination, data)
|
||||
output.prepare(data)
|
||||
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
||||
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||
}
|
||||
val chapters = if (chaptersIds == null) {
|
||||
data.chapters.orEmpty()
|
||||
} else {
|
||||
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
||||
}
|
||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||
if (chaptersIds == null || chapter.id in chaptersIds) {
|
||||
val pages = repo.getPages(chapter)
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
failsafe@ do {
|
||||
try {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file =
|
||||
cache[url] ?: downloadFile(url, page.referer, destination)
|
||||
output.addPage(
|
||||
chapter,
|
||||
file,
|
||||
pageIndex,
|
||||
MimeTypeMap.getFileExtensionFromUrl(url)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
notification.setWaitingForNetwork()
|
||||
notification.update()
|
||||
connectivityManager.waitForNetwork()
|
||||
continue@failsafe
|
||||
}
|
||||
} while (false)
|
||||
notification.setProgress(
|
||||
chapters.size,
|
||||
pages.size,
|
||||
chapterIndex,
|
||||
pageIndex
|
||||
)
|
||||
notification.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
notification.setCancelId(0)
|
||||
notification.setPostProcessing()
|
||||
notification.update()
|
||||
if (!output.compress()) {
|
||||
throw RuntimeException("Cannot create target file")
|
||||
}
|
||||
val result = get<LocalMangaRepository>().getFromFile(output.file)
|
||||
notification.setDone(result)
|
||||
notification.dismiss()
|
||||
notification.update(manga.id.toInt().absoluteValue)
|
||||
} catch (_: CancellationException) {
|
||||
withContext(NonCancellable) {
|
||||
notification.setCancelling()
|
||||
notification.setCancelId(0)
|
||||
notification.update()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
notification.setError(e)
|
||||
notification.setCancelId(0)
|
||||
notification.dismiss()
|
||||
notification.update(manga.id.toInt().absoluteValue)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
jobs.remove(startId)
|
||||
output?.cleanup()
|
||||
destination.sub(TEMP_PAGE_FILE).deleteAwait()
|
||||
withContext(Dispatchers.Main) {
|
||||
stopForeground(true)
|
||||
notification.dismiss()
|
||||
stopSelf(startId)
|
||||
}
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
mutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header(CommonHeaders.REFERER, referer)
|
||||
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
||||
.get()
|
||||
.build()
|
||||
val call = okHttp.newCall(request)
|
||||
var attempts = MAX_DOWNLOAD_ATTEMPTS
|
||||
val file = destination.sub(TEMP_PAGE_FILE)
|
||||
while (true) {
|
||||
try {
|
||||
val response = call.clone().await()
|
||||
withContext(Dispatchers.IO) {
|
||||
file.outputStream().use { out ->
|
||||
checkNotNull(response.body).byteStream().copyTo(out)
|
||||
}
|
||||
}
|
||||
return file
|
||||
} catch (e: IOException) {
|
||||
attempts--
|
||||
if (attempts <= 0) {
|
||||
throw e
|
||||
} else {
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ACTION_DOWNLOAD_START =
|
||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_START"
|
||||
private const val ACTION_DOWNLOAD_CANCEL =
|
||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||
|
||||
private const val EXTRA_MANGA = "manga"
|
||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||
private const val EXTRA_CANCEL_ID = "cancel_id"
|
||||
|
||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
||||
|
||||
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
|
||||
confirmDataTransfer(context) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.action = ACTION_DOWNLOAD_START
|
||||
intent.putExtra(EXTRA_MANGA, manga)
|
||||
if (chaptersIds != null) {
|
||||
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCancelIntent(context: Context, startId: Int) =
|
||||
Intent(context, DownloadService::class.java)
|
||||
.setAction(ACTION_DOWNLOAD_CANCEL)
|
||||
.putExtra(ACTION_DOWNLOAD_CANCEL, startId)
|
||||
|
||||
private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val settings = GlobalContext.get().get<AppSettings>()
|
||||
if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
|
||||
CheckBoxAlertDialog.Builder(context)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.network_consumption_warning)
|
||||
.setCheckBoxText(R.string.dont_ask_again)
|
||||
.setCheckBoxChecked(false)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string._continue) { _, doNotAsk ->
|
||||
settings.isTrafficWarningEnabled = !doNotAsk
|
||||
callback()
|
||||
}.create()
|
||||
.show()
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.ConnectivityManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||
import java.io.File
|
||||
|
||||
class DownloadManager(
|
||||
private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
) {
|
||||
|
||||
private val connectivityManager = context.getSystemService(
|
||||
Context.CONNECTIVITY_SERVICE
|
||||
) as ConnectivityManager
|
||||
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||
androidx.core.R.dimen.compat_notification_large_icon_max_width
|
||||
)
|
||||
private val coverHeight = context.resources.getDimensionPixelSize(
|
||||
androidx.core.R.dimen.compat_notification_large_icon_max_height
|
||||
)
|
||||
|
||||
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int) = flow<State> {
|
||||
emit(State.Preparing(startId, manga, null))
|
||||
var cover: Drawable? = null
|
||||
val destination = settings.getStorageDir(context)
|
||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||
var output: MangaZip? = null
|
||||
try {
|
||||
val repo = MangaRepository(manga.source)
|
||||
cover = runCatching {
|
||||
imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(manga.coverUrl)
|
||||
.size(coverWidth, coverHeight)
|
||||
.scale(Scale.FILL)
|
||||
.build()
|
||||
).drawable
|
||||
}.getOrNull()
|
||||
emit(State.Preparing(startId, manga, cover))
|
||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
||||
output = MangaZip.findInDir(destination, data)
|
||||
output.prepare(data)
|
||||
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
||||
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||
}
|
||||
val chapters = if (chaptersIds == null) {
|
||||
data.chapters.orEmpty()
|
||||
} else {
|
||||
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
||||
}
|
||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||
if (chaptersIds == null || chapter.id in chaptersIds) {
|
||||
val pages = repo.getPages(chapter)
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
failsafe@ do {
|
||||
try {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file =
|
||||
cache[url] ?: downloadFile(url, page.referer, destination)
|
||||
output.addPage(
|
||||
chapter,
|
||||
file,
|
||||
pageIndex,
|
||||
MimeTypeMap.getFileExtensionFromUrl(url)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
emit(State.WaitingForNetwork(startId, manga, cover))
|
||||
connectivityManager.waitForNetwork()
|
||||
continue@failsafe
|
||||
}
|
||||
} while (false)
|
||||
|
||||
emit(State.Progress(
|
||||
startId, manga, cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageIndex,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(State.PostProcessing(startId, manga, cover))
|
||||
if (!output.compress()) {
|
||||
throw RuntimeException("Cannot create target file")
|
||||
}
|
||||
val localManga = localMangaRepository.getFromFile(output.file)
|
||||
emit(State.Done(startId, manga, cover, localManga))
|
||||
} catch (_: CancellationException) {
|
||||
emit(State.Cancelling(startId, manga, cover))
|
||||
} catch (e: Throwable) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
emit(State.Error(startId, manga, cover, e))
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output?.cleanup()
|
||||
File(destination, TEMP_PAGE_FILE).deleteAwait()
|
||||
}
|
||||
}
|
||||
}.catch { e ->
|
||||
emit(State.Error(startId, manga, null, e))
|
||||
}
|
||||
|
||||
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header(CommonHeaders.REFERER, referer)
|
||||
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
||||
.get()
|
||||
.build()
|
||||
val call = okHttp.newCall(request)
|
||||
var attempts = MAX_DOWNLOAD_ATTEMPTS
|
||||
val file = File(destination, TEMP_PAGE_FILE)
|
||||
while (true) {
|
||||
try {
|
||||
val response = call.clone().await()
|
||||
withContext(Dispatchers.IO) {
|
||||
file.outputStream().use { out ->
|
||||
checkNotNull(response.body).byteStream().copyTo(out)
|
||||
}
|
||||
}
|
||||
return file
|
||||
} catch (e: IOException) {
|
||||
attempts--
|
||||
if (attempts <= 0) {
|
||||
throw e
|
||||
} else {
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface State {
|
||||
|
||||
val startId: Int
|
||||
val manga: Manga
|
||||
val cover: Drawable?
|
||||
|
||||
data class Queued(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : State
|
||||
|
||||
data class Preparing(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : State
|
||||
|
||||
data class Progress(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val totalChapters: Int,
|
||||
val currentChapter: Int,
|
||||
val totalPages: Int,
|
||||
val currentPage: Int,
|
||||
): State {
|
||||
|
||||
val max: Int = totalChapters * totalPages
|
||||
|
||||
val progress: Int = totalPages * currentChapter + currentPage + 1
|
||||
|
||||
val percent: Float = progress.toFloat() / max
|
||||
}
|
||||
|
||||
data class WaitingForNetwork(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
): State
|
||||
|
||||
data class Done(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val localManga: Manga,
|
||||
) : State
|
||||
|
||||
data class Error(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val error: Throwable,
|
||||
) : State
|
||||
|
||||
data class Cancelling(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
): State
|
||||
|
||||
data class PostProcessing(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : State
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.koitharu.kotatsu.download.ui
|
||||
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.utils.JobStateFlow
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
|
||||
|
||||
fun downloadItemAD(
|
||||
scope: CoroutineScope,
|
||||
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
|
||||
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
var job: Job? = null
|
||||
|
||||
bind {
|
||||
job?.cancel()
|
||||
job = item.onEach { state ->
|
||||
binding.textViewTitle.text = state.manga.title
|
||||
binding.imageViewCover.setImageDrawable(
|
||||
state.cover ?: getDrawable(R.drawable.ic_placeholder)
|
||||
)
|
||||
when (state) {
|
||||
is DownloadManager.State.Cancelling -> {
|
||||
binding.textViewStatus.setText(R.string.cancelling_)
|
||||
binding.progressBar.setIndeterminateCompat(true)
|
||||
binding.progressBar.isVisible = true
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Done -> {
|
||||
binding.textViewStatus.setText(R.string.download_complete)
|
||||
binding.progressBar.setIndeterminateCompat(false)
|
||||
binding.progressBar.isVisible = false
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Error -> {
|
||||
binding.textViewStatus.setText(R.string.error_occurred)
|
||||
binding.progressBar.setIndeterminateCompat(false)
|
||||
binding.progressBar.isVisible = false
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
|
||||
binding.textViewDetails.isVisible = true
|
||||
}
|
||||
is DownloadManager.State.PostProcessing -> {
|
||||
binding.textViewStatus.setText(R.string.processing_)
|
||||
binding.progressBar.setIndeterminateCompat(true)
|
||||
binding.progressBar.isVisible = true
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Preparing -> {
|
||||
binding.textViewStatus.setText(R.string.preparing_)
|
||||
binding.progressBar.setIndeterminateCompat(true)
|
||||
binding.progressBar.isVisible = true
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Progress -> {
|
||||
binding.textViewStatus.setText(R.string.manga_downloading_)
|
||||
binding.progressBar.setIndeterminateCompat(false)
|
||||
binding.progressBar.isVisible = true
|
||||
binding.progressBar.max = state.max
|
||||
binding.progressBar.setProgressCompat(state.progress, true)
|
||||
binding.textViewPercent.text = (state.percent * 100f).format(1) + "%"
|
||||
binding.textViewPercent.isVisible = true
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Queued -> {
|
||||
binding.textViewStatus.setText(R.string.queued)
|
||||
binding.progressBar.setIndeterminateCompat(false)
|
||||
binding.progressBar.isVisible = false
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.WaitingForNetwork -> {
|
||||
binding.textViewStatus.setText(R.string.waiting_for_network)
|
||||
binding.progressBar.setIndeterminateCompat(false)
|
||||
binding.progressBar.isVisible = false
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.koitharu.kotatsu.download.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection
|
||||
|
||||
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val adapter = DownloadsAdapter(lifecycleScope)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.adapter = adapter
|
||||
LifecycleAwareServiceConnection.bindService(
|
||||
this,
|
||||
this,
|
||||
Intent(this, DownloadService::class.java),
|
||||
0
|
||||
).service.flatMapLatest { binder ->
|
||||
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
|
||||
}.onEach {
|
||||
adapter.items = it?.toList().orEmpty()
|
||||
binding.textViewHolder.isVisible = it.isNullOrEmpty()
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.recyclerView.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom
|
||||
)
|
||||
binding.toolbar.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
top = insets.top
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.koitharu.kotatsu.download.ui
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.utils.JobStateFlow
|
||||
|
||||
class DownloadsAdapter(
|
||||
scope: CoroutineScope,
|
||||
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(downloadItemAD(scope))
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return items[position].value.startId.toLong()
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<JobStateFlow<DownloadManager.State>>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: JobStateFlow<DownloadManager.State>,
|
||||
newItem: JobStateFlow<DownloadManager.State>,
|
||||
): Boolean {
|
||||
return oldItem.value.startId == newItem.value.startId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: JobStateFlow<DownloadManager.State>,
|
||||
newItem: JobStateFlow<DownloadManager.State>,
|
||||
): Boolean {
|
||||
return oldItem.value == newItem.value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package org.koitharu.kotatsu.download.ui.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.download.ui.DownloadsActivity
|
||||
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class DownloadNotification(
|
||||
private val context: Context,
|
||||
startId: Int,
|
||||
) {
|
||||
|
||||
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
private val cancelAction = NotificationCompat.Action(
|
||||
R.drawable.ic_cross,
|
||||
context.getString(android.R.string.cancel),
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
startId,
|
||||
DownloadService.getCancelIntent(startId),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
private val listIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
REQUEST_LIST,
|
||||
DownloadsActivity.newIntent(context),
|
||||
PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
init {
|
||||
builder.setOnlyAlertOnce(true)
|
||||
builder.setDefaults(0)
|
||||
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
|
||||
}
|
||||
|
||||
fun create(state: DownloadManager.State): Notification {
|
||||
builder.setContentTitle(state.manga.title)
|
||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
builder.setContentIntent(listIntent)
|
||||
builder.setStyle(null)
|
||||
builder.setLargeIcon(state.cover?.toBitmap())
|
||||
builder.clearActions()
|
||||
when (state) {
|
||||
is DownloadManager.State.Cancelling -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.cancelling_))
|
||||
builder.setContentIntent(null)
|
||||
builder.setStyle(null)
|
||||
}
|
||||
is DownloadManager.State.Done -> {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.download_complete))
|
||||
builder.setContentIntent(createMangaIntent(context, state.localManga))
|
||||
builder.setAutoCancel(true)
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
builder.setCategory(null)
|
||||
builder.setStyle(null)
|
||||
}
|
||||
is DownloadManager.State.Error -> {
|
||||
val message = state.error.getDisplayMessage(context.resources)
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
builder.setSubText(context.getString(R.string.error))
|
||||
builder.setContentText(message)
|
||||
builder.setAutoCancel(true)
|
||||
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
}
|
||||
is DownloadManager.State.PostProcessing -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.processing_))
|
||||
builder.setStyle(null)
|
||||
}
|
||||
is DownloadManager.State.Queued,
|
||||
is DownloadManager.State.Preparing -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.preparing_))
|
||||
builder.setStyle(null)
|
||||
builder.addAction(cancelAction)
|
||||
}
|
||||
is DownloadManager.State.Progress -> {
|
||||
builder.setProgress(state.max, state.progress, false)
|
||||
builder.setContentText((state.percent * 100).format() + "%")
|
||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
builder.setStyle(null)
|
||||
builder.addAction(cancelAction)
|
||||
}
|
||||
is DownloadManager.State.WaitingForNetwork -> {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.waiting_for_network))
|
||||
builder.setStyle(null)
|
||||
builder.addAction(cancelAction)
|
||||
}
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
|
||||
context,
|
||||
manga.hashCode(),
|
||||
DetailsActivity.newIntent(context, manga),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
private const val CHANNEL_ID = "download"
|
||||
private const val REQUEST_LIST = 6
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.downloads),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.enableVibration(false)
|
||||
channel.enableLights(false)
|
||||
channel.setSound(null, null)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.koitharu.kotatsu.download.ui.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseService
|
||||
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.utils.JobStateFlow
|
||||
import org.koitharu.kotatsu.utils.ext.toArraySet
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.collections.set
|
||||
|
||||
class DownloadService : BaseService() {
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
private lateinit var downloadManager: DownloadManager
|
||||
|
||||
private val jobs = LinkedHashMap<Int, JobStateFlow<DownloadManager.State>>()
|
||||
private val jobCount = MutableStateFlow(0)
|
||||
private val mutex = Mutex()
|
||||
private val controlReceiver = ControlReceiver()
|
||||
private var binder: DownloadBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
downloadManager = DownloadManager(this, get(), get(), get(), get(), get())
|
||||
DownloadNotification.createChannel(this)
|
||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
val manga = intent?.getParcelableExtra<Manga>(EXTRA_MANGA)
|
||||
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
|
||||
return if (manga != null) {
|
||||
jobs[startId] = downloadManga(startId, manga, chapters)
|
||||
jobCount.value = jobs.size
|
||||
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
|
||||
START_REDELIVER_INTENT
|
||||
} else {
|
||||
stopSelf(startId)
|
||||
START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
super.onBind(intent)
|
||||
return binder ?: DownloadBinder(this).also { binder = it }
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterReceiver(controlReceiver)
|
||||
binder = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun downloadManga(
|
||||
startId: Int,
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
): JobStateFlow<DownloadManager.State> {
|
||||
val initialState = DownloadManager.State.Queued(startId, manga, null)
|
||||
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
|
||||
val job = lifecycleScope.launch {
|
||||
mutex.withLock {
|
||||
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
|
||||
val notification = DownloadNotification(this@DownloadService, startId)
|
||||
startForeground(startId, notification.create(initialState))
|
||||
try {
|
||||
withContext(Dispatchers.Default) {
|
||||
downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||
.collect { state ->
|
||||
stateFlow.value = state
|
||||
notificationManager.notify(startId, notification.create(state))
|
||||
}
|
||||
}
|
||||
if (stateFlow.value is DownloadManager.State.Done) {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_DOWNLOAD_COMPLETE)
|
||||
.putExtra(EXTRA_MANGA, manga)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(
|
||||
this@DownloadService,
|
||||
if (isActive) {
|
||||
ServiceCompat.STOP_FOREGROUND_DETACH
|
||||
} else {
|
||||
ServiceCompat.STOP_FOREGROUND_REMOVE
|
||||
}
|
||||
)
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return JobStateFlow(stateFlow, job)
|
||||
}
|
||||
|
||||
inner class ControlReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_DOWNLOAD_CANCEL -> {
|
||||
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
|
||||
jobs.remove(cancelId)?.cancel()
|
||||
jobCount.value = jobs.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadBinder(private val service: DownloadService) : Binder() {
|
||||
|
||||
val downloads: Flow<Collection<JobStateFlow<DownloadManager.State>>>
|
||||
get() = service.jobCount.mapLatest { service.jobs.values }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ACTION_DOWNLOAD_COMPLETE =
|
||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||
|
||||
private const val ACTION_DOWNLOAD_CANCEL =
|
||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||
|
||||
private const val EXTRA_MANGA = "manga"
|
||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||
private const val EXTRA_CANCEL_ID = "cancel_id"
|
||||
|
||||
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
|
||||
if (chaptersIds?.isEmpty() == true) {
|
||||
return
|
||||
}
|
||||
confirmDataTransfer(context) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.putExtra(EXTRA_MANGA, manga)
|
||||
if (chaptersIds != null) {
|
||||
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
|
||||
.putExtra(ACTION_DOWNLOAD_CANCEL, startId)
|
||||
|
||||
private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val settings = GlobalContext.get().get<AppSettings>()
|
||||
if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
|
||||
CheckBoxAlertDialog.Builder(context)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.network_consumption_warning)
|
||||
.setCheckBoxText(R.string.dont_ask_again)
|
||||
.setCheckBoxChecked(false)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string._continue) { _, doNotAsk ->
|
||||
settings.isTrafficWarningEnabled = !doNotAsk
|
||||
callback()
|
||||
}.create()
|
||||
.show()
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites
|
||||
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel
|
||||
@@ -13,11 +12,11 @@ val favouritesModule
|
||||
|
||||
single { FavouritesRepository(get()) }
|
||||
|
||||
viewModel { (categoryId: Long) ->
|
||||
FavouritesListViewModel(categoryId, get(), get())
|
||||
viewModel { categoryId ->
|
||||
FavouritesListViewModel(categoryId.get(), get(), get())
|
||||
}
|
||||
viewModel { FavouritesCategoriesViewModel(get()) }
|
||||
viewModel { (manga: Manga) ->
|
||||
MangaCategoriesViewModel(manga, get())
|
||||
viewModel { manga ->
|
||||
MangaCategoriesViewModel(manga.get(), get())
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites.data
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
|
||||
@Dao
|
||||
abstract class FavouriteCategoriesDao {
|
||||
@@ -13,6 +12,9 @@ abstract class FavouriteCategoriesDao {
|
||||
@Query("SELECT * FROM favourite_categories ORDER BY sort_key")
|
||||
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM favourite_categories WHERE category_id = :id")
|
||||
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
|
||||
|
||||
@@ -23,10 +25,13 @@ abstract class FavouriteCategoriesDao {
|
||||
abstract suspend fun delete(id: Long)
|
||||
|
||||
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
|
||||
abstract suspend fun update(id: Long, title: String)
|
||||
abstract suspend fun updateTitle(id: Long, title: String)
|
||||
|
||||
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
|
||||
abstract suspend fun updateOrder(id: Long, order: String)
|
||||
|
||||
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
|
||||
abstract suspend fun update(id: Long, sortKey: Int)
|
||||
abstract suspend fun updateSortKey(id: Long, sortKey: Int)
|
||||
|
||||
@Query("SELECT MAX(sort_key) FROM favourite_categories")
|
||||
protected abstract suspend fun getMaxSortKey(): Int?
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = "favourite_categories")
|
||||
@@ -12,13 +13,15 @@ data class FavouriteCategoryEntity(
|
||||
@ColumnInfo(name = "category_id") val categoryId: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "sort_key") val sortKey: Int,
|
||||
@ColumnInfo(name = "title") val title: String
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "order") val order: String,
|
||||
) {
|
||||
|
||||
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
|
||||
id = id ?: categoryId.toLong(),
|
||||
title = title,
|
||||
sortKey = sortKey,
|
||||
createdAt = Date(createdAt)
|
||||
order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
|
||||
createdAt = Date(createdAt),
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.koitharu.kotatsu.favourites.data
|
||||
|
||||
import androidx.room.*
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
|
||||
@Dao
|
||||
abstract class FavouritesDao {
|
||||
@@ -11,9 +14,13 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
|
||||
abstract suspend fun findAll(): List<FavouriteManga>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
|
||||
abstract fun observeAll(): Flow<List<FavouriteManga>>
|
||||
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllRaw(query)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
|
||||
@@ -23,9 +30,14 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
|
||||
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
|
||||
abstract fun observeAll(categoryId: Long): Flow<List<FavouriteManga>>
|
||||
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id WHERE category_id = ? GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
arrayOf<Any>(categoryId),
|
||||
)
|
||||
return observeAllRaw(query)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
|
||||
@@ -63,4 +75,16 @@ abstract class FavouritesDao {
|
||||
insert(entity)
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [FavouriteEntity::class])
|
||||
protected abstract fun observeAllRaw(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
|
||||
|
||||
private fun getOrderBy(sortOrder: SortOrder) = when(sortOrder) {
|
||||
SortOrder.RATING -> "rating DESC"
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.UPDATED -> "created_at DESC"
|
||||
SortOrder.ALPHABETICAL -> "title ASC"
|
||||
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ package org.koitharu.kotatsu.favourites.domain
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
@@ -21,26 +23,26 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
}
|
||||
|
||||
fun observeAll(): Flow<List<Manga>> {
|
||||
return db.favouritesDao.observeAll()
|
||||
fun observeAll(order: SortOrder): Flow<List<Manga>> {
|
||||
return db.favouritesDao.observeAll(order)
|
||||
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
}
|
||||
|
||||
suspend fun getAllManga(offset: Int): List<Manga> {
|
||||
val entities = db.favouritesDao.findAll(offset, 20)
|
||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
}
|
||||
|
||||
suspend fun getManga(categoryId: Long): List<Manga> {
|
||||
val entities = db.favouritesDao.findAll(categoryId)
|
||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
||||
return db.favouritesDao.observeAll(categoryId)
|
||||
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
|
||||
return db.favouritesDao.observeAll(categoryId, order)
|
||||
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
||||
return observeOrder(categoryId)
|
||||
.flatMapLatest { order -> observeAll(categoryId, order) }
|
||||
}
|
||||
|
||||
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
|
||||
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
|
||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
@@ -77,25 +79,30 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
title = title,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
|
||||
categoryId = 0
|
||||
categoryId = 0,
|
||||
order = SortOrder.UPDATED.name,
|
||||
)
|
||||
val id = db.favouriteCategoriesDao.insert(entity)
|
||||
return entity.toFavouriteCategory(id)
|
||||
}
|
||||
|
||||
suspend fun renameCategory(id: Long, title: String) {
|
||||
db.favouriteCategoriesDao.update(id, title)
|
||||
db.favouriteCategoriesDao.updateTitle(id, title)
|
||||
}
|
||||
|
||||
suspend fun removeCategory(id: Long) {
|
||||
db.favouriteCategoriesDao.delete(id)
|
||||
}
|
||||
|
||||
suspend fun setCategoryOrder(id: Long, order: SortOrder) {
|
||||
db.favouriteCategoriesDao.updateOrder(id, order.name)
|
||||
}
|
||||
|
||||
suspend fun reorderCategories(orderedIds: List<Long>) {
|
||||
val dao = db.favouriteCategoriesDao
|
||||
db.withTransaction {
|
||||
for ((i, id) in orderedIds.withIndex()) {
|
||||
dao.update(id, i)
|
||||
dao.updateSortKey(id, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,4 +124,10 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
suspend fun removeFromFavourites(manga: Manga) {
|
||||
db.favouritesDao.delete(manga.id)
|
||||
}
|
||||
|
||||
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
|
||||
return db.favouriteCategoriesDao.observe(categoryId)
|
||||
.map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.favourites.ui
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -11,15 +12,17 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
|
||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||
import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback,
|
||||
@@ -65,10 +68,22 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.tabs.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right
|
||||
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
||||
binding.root.updatePadding(
|
||||
top = headerHeight - insets.top
|
||||
)
|
||||
binding.pager.updatePadding(
|
||||
top = -headerHeight
|
||||
)
|
||||
binding.tabs.apply {
|
||||
updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right
|
||||
)
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
|
||||
@@ -100,11 +115,19 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
|
||||
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
|
||||
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
|
||||
tabView.showPopupMenu(menuRes) {
|
||||
tabView.showPopupMenu(menuRes, { menu ->
|
||||
createOrderSubmenu(menu, category)
|
||||
}) {
|
||||
when (it.itemId) {
|
||||
R.id.action_remove -> editDelegate.deleteCategory(category)
|
||||
R.id.action_rename -> editDelegate.renameCategory(category)
|
||||
R.id.action_create -> editDelegate.createCategory()
|
||||
R.id.action_order -> return@showPopupMenu false
|
||||
else -> {
|
||||
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
|
||||
?: return@showPopupMenu false
|
||||
viewModel.setCategoryOrder(category.id, order)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -125,11 +148,26 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
|
||||
private fun wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> {
|
||||
val data = ArrayList<FavouriteCategory>(categories.size + 1)
|
||||
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, Date())
|
||||
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date())
|
||||
data += categories
|
||||
return data
|
||||
}
|
||||
|
||||
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
|
||||
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
||||
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
|
||||
val menuItem = submenu.add(
|
||||
R.id.group_order,
|
||||
Menu.NONE,
|
||||
i,
|
||||
item.titleRes
|
||||
)
|
||||
menuItem.isCheckable = true
|
||||
menuItem.isChecked = item == category.order
|
||||
}
|
||||
submenu.setGroupCheckable(R.id.group_order, true, true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = FavouritesContainerFragment()
|
||||
|
||||
@@ -36,7 +36,7 @@ class FavouritesPagerAdapter(
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val item = differ.currentList[position]
|
||||
tab.text = item.title
|
||||
tab.view.tag = item
|
||||
tab.view.tag = item.id
|
||||
tab.view.setOnLongClickListener(this)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ class FavouritesPagerAdapter(
|
||||
}
|
||||
|
||||
override fun onLongClick(v: View): Boolean {
|
||||
val item = v.tag as? FavouriteCategory ?: return false
|
||||
val itemId = v.tag as? Long ?: return false
|
||||
val item = differ.currentList.find { x -> x.id == itemId } ?: return false
|
||||
return longClickListener.onTabLongClick(v, item)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
@@ -20,6 +21,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
||||
@@ -44,6 +46,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
adapter = CategoriesAdapter(this)
|
||||
editDelegate = CategoriesEditDelegate(this, this)
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.fabAdd.setOnClickListener(this)
|
||||
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
|
||||
@@ -60,10 +63,17 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
}
|
||||
|
||||
override fun onItemClick(item: FavouriteCategory, view: View) {
|
||||
view.showPopupMenu(R.menu.popup_category) {
|
||||
view.showPopupMenu(R.menu.popup_category, { menu ->
|
||||
createOrderSubmenu(menu, item)
|
||||
}) {
|
||||
when (it.itemId) {
|
||||
R.id.action_remove -> editDelegate.deleteCategory(item)
|
||||
R.id.action_rename -> editDelegate.renameCategory(item)
|
||||
R.id.action_order -> return@showPopupMenu false
|
||||
else -> {
|
||||
val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false
|
||||
viewModel.setCategoryOrder(item.id, order)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -116,6 +126,21 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
viewModel.createCategory(name)
|
||||
}
|
||||
|
||||
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
|
||||
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
||||
for ((i, item) in SORT_ORDERS.withIndex()) {
|
||||
val menuItem = submenu.add(
|
||||
R.id.group_order,
|
||||
Menu.NONE,
|
||||
i,
|
||||
item.titleRes
|
||||
)
|
||||
menuItem.isCheckable = true
|
||||
menuItem.isChecked = item == category.order
|
||||
}
|
||||
submenu.setGroupCheckable(R.id.group_order, true, true)
|
||||
}
|
||||
|
||||
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
|
||||
) {
|
||||
@@ -144,6 +169,12 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
|
||||
companion object {
|
||||
|
||||
val SORT_ORDERS = arrayOf(
|
||||
SortOrder.ALPHABETICAL,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.RATING,
|
||||
)
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
|
||||
class CategoriesAdapter(
|
||||
onItemClickListener: OnListItemClickListener<FavouriteCategory>
|
||||
onItemClickListener: OnListItemClickListener<FavouriteCategory>,
|
||||
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
@@ -20,12 +20,27 @@ class CategoriesAdapter(
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id && oldItem.title == newItem.title
|
||||
&& oldItem.order == newItem.order
|
||||
}
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
): Any? = when {
|
||||
oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order
|
||||
else -> super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class FavouritesCategoriesViewModel(
|
||||
private val repository: FavouritesRepository
|
||||
@@ -19,23 +19,29 @@ class FavouritesCategoriesViewModel(
|
||||
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
fun createCategory(name: String) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
launchJob {
|
||||
repository.addCategory(name)
|
||||
}
|
||||
}
|
||||
|
||||
fun renameCategory(id: Long, name: String) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
launchJob {
|
||||
repository.renameCategory(id, name)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCategory(id: Long) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
launchJob {
|
||||
repository.removeCategory(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCategoryOrder(id: Long, order: SortOrder) {
|
||||
launchJob {
|
||||
repository.setCategoryOrder(id, order)
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderCategories(oldPos: Int, newPos: Int) {
|
||||
val prevJob = reorderJob
|
||||
reorderJob = launchJob(Dispatchers.Default) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
@@ -22,12 +23,18 @@ class FavouritesListViewModel(
|
||||
) : MangaListViewModel(settings) {
|
||||
|
||||
override val content = combine(
|
||||
if (categoryId == 0L) repository.observeAll() else repository.observeAll(categoryId),
|
||||
if (categoryId == 0L) {
|
||||
repository.observeAll(SortOrder.NEWEST)
|
||||
} else {
|
||||
repository.observeAll(categoryId)
|
||||
},
|
||||
createListModeFlow()
|
||||
) { list, mode ->
|
||||
when {
|
||||
list.isEmpty() -> listOf(
|
||||
EmptyState(
|
||||
R.drawable.ic_heart_outline,
|
||||
R.string.text_empty_holder_primary,
|
||||
if (categoryId == 0L) {
|
||||
R.string.you_have_not_favourites_yet
|
||||
} else {
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||
import org.koitharu.kotatsu.utils.ext.onFirst
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class HistoryListViewModel(
|
||||
private val repository: HistoryRepository,
|
||||
@@ -44,7 +43,7 @@ class HistoryListViewModel(
|
||||
createListModeFlow()
|
||||
) { list, grouped, mode ->
|
||||
when {
|
||||
list.isEmpty() -> listOf(EmptyState(R.string.text_history_holder))
|
||||
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_history, R.string.text_history_holder_primary, R.string.text_history_holder_secondary))
|
||||
else -> mapList(list, grouped, mode)
|
||||
}
|
||||
}.onFirst {
|
||||
@@ -81,8 +80,11 @@ class HistoryListViewModel(
|
||||
grouped: Boolean,
|
||||
mode: ListMode
|
||||
): List<ListModel> {
|
||||
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size)
|
||||
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
|
||||
var prevDate: DateTimeAgo? = null
|
||||
if (!grouped) {
|
||||
result += ListHeader(null, R.string.history)
|
||||
}
|
||||
for ((manga, history) in list) {
|
||||
if (grouped) {
|
||||
val date = timeAgo(history.updatedAt)
|
||||
|
||||
@@ -4,16 +4,15 @@ import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -37,11 +36,10 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter
|
||||
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
|
||||
import org.koitharu.kotatsu.utils.ext.clearItemDecorations
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.toggleDrawer
|
||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
PaginationScrollListener.Callback, OnListItemClickListener<Manga>, OnFilterChangedListener,
|
||||
@@ -73,7 +71,13 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
drawer = binding.root as? DrawerLayout
|
||||
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||
listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this, ::resolveException)
|
||||
listAdapter = MangaListAdapter(
|
||||
coil = get(),
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
clickListener = this,
|
||||
onRetryClick = ::resolveException,
|
||||
onTagRemoveClick = viewModel::onRemoveFilterTag
|
||||
)
|
||||
paginationListener = PaginationScrollListener(4, this)
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
@@ -81,6 +85,10 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
addOnScrollListener(paginationListener!!)
|
||||
}
|
||||
with(binding.swipeRefreshLayout) {
|
||||
setColorSchemeColors(
|
||||
ContextCompat.getColor(context, R.color.color_primary),
|
||||
ContextCompat.getColor(context, R.color.color_primary_variant)
|
||||
)
|
||||
setOnRefreshListener(this@MangaListFragment)
|
||||
isEnabled = isSwipeRefreshEnabled
|
||||
}
|
||||
@@ -215,22 +223,29 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onFilterChanged(filter: MangaFilter) {
|
||||
drawer?.closeDrawers()
|
||||
}
|
||||
override fun onFilterChanged(filter: MangaFilter) = Unit
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom
|
||||
)
|
||||
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
||||
binding.recyclerViewFilter.updatePadding(
|
||||
top = headerHeight,
|
||||
bottom = insets.bottom
|
||||
)
|
||||
binding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right
|
||||
)
|
||||
if (activity is MainActivity) {
|
||||
binding.recyclerView.updatePadding(
|
||||
top = headerHeight,
|
||||
bottom = insets.bottom
|
||||
)
|
||||
binding.swipeRefreshLayout.setProgressViewOffset(
|
||||
true,
|
||||
headerHeight + resources.resolveDp(-72),
|
||||
headerHeight + resources.resolveDp(10)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGridScaleChanged(scale: Float) {
|
||||
@@ -246,13 +261,9 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
when (mode) {
|
||||
ListMode.LIST -> {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
context,
|
||||
RecyclerView.VERTICAL
|
||||
)
|
||||
)
|
||||
updatePadding(left = 0, right = 0)
|
||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
||||
addItemDecoration(SpacingItemDecoration(spacing))
|
||||
updatePadding(left = spacing, right = spacing)
|
||||
}
|
||||
ListMode.DETAILED_LIST -> {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
@@ -282,7 +293,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
final override fun getSectionTitle(position: Int): CharSequence? {
|
||||
return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) {
|
||||
FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order)
|
||||
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genre)
|
||||
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genres)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
@@ -36,6 +37,8 @@ abstract class MangaListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
open fun onRemoveFilterTag(tag: MangaTag) = Unit
|
||||
|
||||
abstract fun onRefresh()
|
||||
|
||||
abstract fun onRetry()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.list.ui.adapter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.databinding.ItemCurrentFilterBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.CurrentFilterModel
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun currentFilterAD(
|
||||
onTagRemoveClick: (MangaTag) -> Unit,
|
||||
) = adapterDelegateViewBinding<CurrentFilterModel, ListModel, ItemCurrentFilterBinding>(
|
||||
{ inflater, parent -> ItemCurrentFilterBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
binding.chipsTags.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { chip, data ->
|
||||
onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.chipsTags.setChips(item.chips)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
package org.koitharu.kotatsu.list.ui.adapter
|
||||
|
||||
import android.widget.TextView
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
||||
import org.koitharu.kotatsu.R
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun emptyStateListAD() = adapterDelegate<EmptyState, ListModel>(R.layout.item_empty_state) {
|
||||
fun emptyStateListAD() = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
|
||||
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
bind {
|
||||
(itemView as TextView).setText(item.text)
|
||||
with(binding.icon) {
|
||||
setImageResource(item.icon)
|
||||
}
|
||||
with(binding.textPrimary) {
|
||||
setText(item.textPrimary)
|
||||
}
|
||||
with(binding.textSecondary) {
|
||||
setText(item.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.list.ui.adapter
|
||||
|
||||
import android.widget.TextView
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(R.layout.item_header) {
|
||||
|
||||
bind {
|
||||
val textView = (itemView as TextView)
|
||||
if (item.text != null) {
|
||||
textView.text = item.text
|
||||
} else {
|
||||
textView.setText(item.textRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.ui.DateTimeAgo
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||
@@ -17,7 +18,8 @@ class MangaListAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Manga>,
|
||||
onRetryClick: (Throwable) -> Unit
|
||||
onRetryClick: (Throwable) -> Unit,
|
||||
onTagRemoveClick: (MangaTag) -> Unit,
|
||||
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
@@ -37,6 +39,8 @@ class MangaListAdapter(
|
||||
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(onRetryClick))
|
||||
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(onRetryClick))
|
||||
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD())
|
||||
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
|
||||
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
|
||||
}
|
||||
|
||||
fun setItems(list: List<ListModel>, commitCallback: Runnable) {
|
||||
@@ -77,5 +81,7 @@ class MangaListAdapter(
|
||||
const val ITEM_TYPE_ERROR_STATE = 6
|
||||
const val ITEM_TYPE_ERROR_FOOTER = 7
|
||||
const val ITEM_TYPE_EMPTY = 8
|
||||
const val ITEM_TYPE_HEADER = 9
|
||||
const val ITEM_TYPE_FILTER = 10
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,15 @@ import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class FilterAdapter(
|
||||
sortOrders: List<SortOrder> = emptyList(),
|
||||
tags: List<MangaTag> = emptyList(),
|
||||
private val sortOrders: List<SortOrder> = emptyList(),
|
||||
private val tags: List<MangaTag> = emptyList(),
|
||||
state: MangaFilter?,
|
||||
private val listener: OnFilterChangedListener
|
||||
) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() {
|
||||
|
||||
private val sortOrders = ArrayList<SortOrder>(sortOrders)
|
||||
private val tags = ArrayList(Collections.singletonList(null) + tags)
|
||||
|
||||
private var currentState = state ?: MangaFilter(sortOrders.first(), null)
|
||||
private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet())
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
|
||||
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
|
||||
@@ -29,7 +24,7 @@ class FilterAdapter(
|
||||
}
|
||||
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
|
||||
itemView.setOnClickListener {
|
||||
setCheckedTag(boundData)
|
||||
setCheckedTag(boundData ?: return@setOnClickListener, !isChecked)
|
||||
}
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown viewType $viewType")
|
||||
@@ -45,7 +40,7 @@ class FilterAdapter(
|
||||
}
|
||||
is FilterTagHolder -> {
|
||||
val item = tags[position - sortOrders.size]
|
||||
holder.bind(item, item == currentState.tag)
|
||||
holder.bind(item, item in currentState.tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,19 +50,25 @@ class FilterAdapter(
|
||||
else -> VIEW_TYPE_TAG
|
||||
}
|
||||
|
||||
fun setCheckedTag(tag: MangaTag?) {
|
||||
if (tag != currentState.tag) {
|
||||
val oldItemPos = tags.indexOf(currentState.tag)
|
||||
val newItemPos = tags.indexOf(tag)
|
||||
currentState = currentState.copy(tag = tag)
|
||||
if (oldItemPos in tags.indices) {
|
||||
notifyItemChanged(sortOrders.size + oldItemPos)
|
||||
fun setCheckedTag(tag: MangaTag, isChecked: Boolean) {
|
||||
currentState = if (tag in currentState.tags) {
|
||||
if (!isChecked) {
|
||||
currentState.copy(tags = currentState.tags - tag)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (newItemPos in tags.indices) {
|
||||
notifyItemChanged(sortOrders.size + newItemPos)
|
||||
} else {
|
||||
if (isChecked) {
|
||||
currentState.copy(tags = currentState.tags + tag)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
listener.onFilterChanged(currentState)
|
||||
}
|
||||
val index = tags.indexOf(tag)
|
||||
if (index in tags.indices) {
|
||||
notifyItemChanged(sortOrders.size + index)
|
||||
}
|
||||
listener.onFilterChanged(currentState)
|
||||
}
|
||||
|
||||
fun setCheckedSort(sort: SortOrder) {
|
||||
|
||||
@@ -12,7 +12,7 @@ class FilterSortHolder(parent: ViewGroup) :
|
||||
) {
|
||||
|
||||
override fun onBind(data: SortOrder, extra: Boolean) {
|
||||
binding.radio.setText(data.titleRes)
|
||||
binding.radio.isChecked = extra
|
||||
binding.root.setText(data.titleRes)
|
||||
binding.root.isChecked = extra
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,20 @@ package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
||||
|
||||
class FilterTagHolder(parent: ViewGroup) :
|
||||
BaseViewHolder<MangaTag?, Boolean, ItemCheckableSingleBinding>(
|
||||
ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
BaseViewHolder<MangaTag, Boolean, ItemCheckableMultipleBinding>(
|
||||
ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
) {
|
||||
|
||||
override fun onBind(data: MangaTag?, extra: Boolean) {
|
||||
binding.radio.text = data?.title ?: context.getString(R.string.all)
|
||||
binding.radio.isChecked = extra
|
||||
val isChecked: Boolean
|
||||
get() = binding.root.isChecked
|
||||
|
||||
override fun onBind(data: MangaTag, extra: Boolean) {
|
||||
binding.root.text = data.title
|
||||
binding.root.isChecked = extra
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
|
||||
data class CurrentFilterModel(
|
||||
val chips: Collection<ChipsView.ChipModel>,
|
||||
) : ListModel
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
data class EmptyState(
|
||||
@StringRes val text: Int
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val textPrimary: Int,
|
||||
@StringRes val textSecondary: Int
|
||||
) : ListModel
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
data class ListHeader(
|
||||
val text: CharSequence?,
|
||||
@StringRes val textRes: Int,
|
||||
) : ListModel
|
||||
@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun Manga.toListModel() = MangaListModel(
|
||||
id = id,
|
||||
@@ -20,7 +19,7 @@ fun Manga.toListDetailedModel() = MangaListDetailedModel(
|
||||
id = id,
|
||||
title = title,
|
||||
subtitle = altTitle,
|
||||
rating = if (rating == Manga.NO_RATING) null else "${(rating * 10).roundToInt()}/10",
|
||||
rating = if (rating == Manga.NO_RATING) null else String.format("%.1f", rating * 5),
|
||||
tags = tags.joinToString(", ") { it.title },
|
||||
coverUrl = coverUrl,
|
||||
manga = this
|
||||
|
||||
@@ -15,5 +15,5 @@ val localModule
|
||||
single { LocalMangaRepository(androidContext()) }
|
||||
factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() }
|
||||
|
||||
viewModel { LocalListViewModel(get(), get(), get(), get(), androidContext()) }
|
||||
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
||||
}
|
||||
@@ -20,19 +20,22 @@ class CbzFetcher : Fetcher<Uri> {
|
||||
pool: BitmapPool,
|
||||
data: Uri,
|
||||
size: Size,
|
||||
options: Options
|
||||
options: Options,
|
||||
): FetchResult {
|
||||
val zip = ZipFile(data.schemeSpecificPart)
|
||||
val entry = zip.getEntry(data.fragment)
|
||||
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
|
||||
return SourceResult(
|
||||
source = zip.getInputStream(entry).source().buffer(),
|
||||
source = ExtraCloseableBufferedSource(
|
||||
zip.getInputStream(entry).source().buffer(),
|
||||
zip,
|
||||
),
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext),
|
||||
dataSource = DataSource.DISK
|
||||
)
|
||||
}
|
||||
|
||||
override fun key(data: Uri): String? = data.toString()
|
||||
override fun key(data: Uri) = data.toString()
|
||||
|
||||
override fun handles(data: Uri) = data.scheme == "cbz"
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import java.util.*
|
||||
class CbzFilter : FilenameFilter {
|
||||
|
||||
override fun accept(dir: File, name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.', "").toLowerCase(Locale.ROOT)
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.BufferedSource
|
||||
import okio.Closeable
|
||||
|
||||
class ExtraCloseableBufferedSource(
|
||||
private val delegate: BufferedSource,
|
||||
vararg closeable: Closeable,
|
||||
) : BufferedSource by delegate {
|
||||
|
||||
private val extraCloseable = closeable
|
||||
|
||||
override fun close() {
|
||||
delegate.close()
|
||||
extraCloseable.forEach { x -> x.closeQuietly() }
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.content.Context
|
||||
import com.tomclaw.cache.DiskLruCache
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.sub
|
||||
import org.koitharu.kotatsu.utils.ext.subdir
|
||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
@@ -13,8 +13,10 @@ import java.io.OutputStream
|
||||
class PagesCache(context: Context) {
|
||||
|
||||
private val cacheDir = context.externalCacheDir ?: context.cacheDir
|
||||
private val lruCache =
|
||||
DiskLruCache.create(cacheDir.sub(Cache.PAGES.dir), FileSizeUtils.mbToBytes(200))
|
||||
private val lruCache = DiskLruCache.create(
|
||||
cacheDir.subdir(Cache.PAGES.dir),
|
||||
FileSizeUtils.mbToBytes(200)
|
||||
)
|
||||
|
||||
operator fun get(url: String): File? {
|
||||
return lruCache.get(url)?.takeIfReadable()
|
||||
@@ -22,7 +24,7 @@ class PagesCache(context: Context) {
|
||||
|
||||
@Deprecated("Useless lambda")
|
||||
fun put(url: String, writer: (OutputStream) -> Unit): File {
|
||||
val file = cacheDir.sub(url.longHashCode().toString())
|
||||
val file = File(cacheDir, url.longHashCode().toString())
|
||||
file.outputStream().use(writer)
|
||||
val res = lruCache.put(url, file)
|
||||
file.delete()
|
||||
@@ -30,7 +32,7 @@ class PagesCache(context: Context) {
|
||||
}
|
||||
|
||||
fun put(url: String, inputStream: InputStream): File {
|
||||
val file = cacheDir.sub(url.longHashCode().toString())
|
||||
val file = File(cacheDir, url.longHashCode().toString())
|
||||
file.outputStream().use { out ->
|
||||
inputStream.copyTo(out)
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.readText
|
||||
import org.koitharu.kotatsu.utils.ext.sub
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.zip.ZipEntry
|
||||
@@ -27,17 +25,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
|
||||
private val filenameFilter = CbzFilter()
|
||||
|
||||
override suspend fun getList(
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
sortOrder: SortOrder?,
|
||||
tag: MangaTag?
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
require(offset == 0) {
|
||||
"LocalMangaRepository does not support pagination"
|
||||
}
|
||||
val files = getAvailableStorageDirs(context)
|
||||
.flatMap { x -> x.listFiles(filenameFilter)?.toList().orEmpty() }
|
||||
val files = getAllFiles()
|
||||
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
|
||||
}
|
||||
|
||||
@@ -78,9 +75,9 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(manga: Manga): Boolean {
|
||||
suspend fun delete(manga: Manga): Boolean {
|
||||
val file = Uri.parse(manga.url).toFile()
|
||||
return file.delete()
|
||||
return file.deleteAwait()
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@@ -98,11 +95,14 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
entryName = index.getCoverEntry()
|
||||
?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()
|
||||
),
|
||||
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
|
||||
chapters = info.chapters?.map { c ->
|
||||
c.copy(url = fileUri,
|
||||
source = MangaSource.LOCAL)
|
||||
}
|
||||
)
|
||||
}
|
||||
// fallback
|
||||
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
|
||||
val title = file.nameWithoutExtension.replace("_", " ").toCamelCase()
|
||||
val chapters = ArraySet<String>()
|
||||
for (x in zip.entries()) {
|
||||
if (!x.isDirectory) {
|
||||
@@ -120,7 +120,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
|
||||
MangaChapter(
|
||||
id = "$i$s".longHashCode(),
|
||||
name = if (s.isEmpty()) title else s,
|
||||
name = s.ifEmpty { title },
|
||||
number = i + 1,
|
||||
source = MangaSource.LOCAL,
|
||||
url = uriBuilder.fragment(s).build().toString()
|
||||
@@ -134,13 +134,36 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
Uri.parse(localManga.url).toFile()
|
||||
}.getOrNull() ?: return null
|
||||
return withContext(Dispatchers.IO) {
|
||||
val zip = ZipFile(file)
|
||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
|
||||
index.getMangaInfo()
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
ZipFile(file).use { zip ->
|
||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
|
||||
index.getMangaInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) {
|
||||
val files = getAllFiles()
|
||||
for (file in files) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val index = ZipFile(file).use { zip ->
|
||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||
entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
} ?: continue
|
||||
val info = index.getMangaInfo() ?: continue
|
||||
if (info.id == remoteManga.id) {
|
||||
val fileUri = file.toUri().toString()
|
||||
return@withContext info.copy(
|
||||
source = MangaSource.LOCAL,
|
||||
url = fileUri,
|
||||
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun zipUri(file: File, entryName: String) =
|
||||
Uri.fromParts("cbz", file.path, entryName).toString()
|
||||
|
||||
@@ -165,20 +188,26 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
|
||||
override suspend fun getTags() = emptySet<MangaTag>()
|
||||
|
||||
private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir ->
|
||||
dir.listFiles(filenameFilter)?.toList().orEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
|
||||
fun isFileSupported(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
|
||||
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
}
|
||||
|
||||
fun getAvailableStorageDirs(context: Context): List<File> {
|
||||
val result = ArrayList<File>(5)
|
||||
result += context.filesDir.sub(DIR_NAME)
|
||||
val result = ArrayList<File?>(5)
|
||||
result += File(context.filesDir, DIR_NAME)
|
||||
result += context.getExternalFilesDirs(DIR_NAME)
|
||||
return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() }
|
||||
return result.filterNotNull()
|
||||
.distinctBy { it.canonicalPath }
|
||||
.filter { it.exists() || it.mkdir() }
|
||||
}
|
||||
|
||||
fun getFallbackStorageDir(context: Context): File? {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
@@ -15,6 +15,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.utils.ext.ellipsize
|
||||
|
||||
@@ -25,12 +26,32 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
this
|
||||
)
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) {
|
||||
viewModel.onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
context.registerReceiver(
|
||||
downloadReceiver,
|
||||
IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
requireContext().unregisterReceiver(downloadReceiver)
|
||||
super.onDetach()
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
@@ -65,7 +86,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
|
||||
|
||||
override fun onActivityResult(result: Uri?) {
|
||||
if (result != null) {
|
||||
viewModel.importFile(result)
|
||||
viewModel.importFile(context?.applicationContext ?: return, result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,15 +14,12 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.list.ui.model.*
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.sub
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class LocalListViewModel(
|
||||
@@ -30,12 +27,12 @@ class LocalListViewModel(
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
private val shortcutsRepository: ShortcutsRepository,
|
||||
private val context: Context
|
||||
) : MangaListViewModel(settings) {
|
||||
|
||||
val onMangaRemoved = SingleLiveEvent<Manga>()
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
private val headerModel = ListHeader(null, R.string.local_storage)
|
||||
|
||||
override val content = combine(
|
||||
mangaList,
|
||||
@@ -45,8 +42,11 @@ class LocalListViewModel(
|
||||
when {
|
||||
error != null -> listOf(error.toErrorState(canRetry = true))
|
||||
list == null -> listOf(LoadingState)
|
||||
list.isEmpty() -> listOf(EmptyState(R.string.text_local_holder))
|
||||
else -> list.toUi(mode)
|
||||
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_storage, R.string.text_local_holder_primary, R.string.text_local_holder_secondary))
|
||||
else -> ArrayList<ListModel>(list.size + 1).apply {
|
||||
add(headerModel)
|
||||
list.toUi(this, mode)
|
||||
}
|
||||
}
|
||||
}.asLiveDataDistinct(
|
||||
viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
@@ -61,7 +61,7 @@ class LocalListViewModel(
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
try {
|
||||
listError.value = null
|
||||
mangaList.value = repository.getList(0)
|
||||
mangaList.value = repository.getList2(0)
|
||||
} catch (e: Throwable) {
|
||||
listError.value = e
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class LocalListViewModel(
|
||||
|
||||
override fun onRetry() = onRefresh()
|
||||
|
||||
fun importFile(uri: Uri) {
|
||||
fun importFile(context: Context, uri: Uri) {
|
||||
launchLoadingJob {
|
||||
val contentResolver = context.contentResolver
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -79,8 +79,9 @@ class LocalListViewModel(
|
||||
if (!LocalMangaRepository.isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
}
|
||||
val dest = settings.getStorageDir(context)?.sub(name)
|
||||
val dest = settings.getStorageDir(context)?.let { File(it, name) }
|
||||
?: throw IOException("External files dir unavailable")
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
contentResolver.openInputStream(uri)?.use { source ->
|
||||
dest.outputStream().use { output ->
|
||||
source.copyTo(output)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.main.ui
|
||||
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
|
||||
interface AppBarOwner {
|
||||
|
||||
val appBar: AppBarLayout
|
||||
}
|
||||
@@ -6,79 +6,130 @@ import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.*
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSection
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.databinding.ActivityMainBinding
|
||||
import org.koitharu.kotatsu.databinding.NavigationHeaderBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment
|
||||
import org.koitharu.kotatsu.history.ui.HistoryListFragment
|
||||
import org.koitharu.kotatsu.local.ui.LocalListFragment
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
import org.koitharu.kotatsu.search.ui.SearchHelper
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
import org.koitharu.kotatsu.settings.AppUpdateChecker
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
||||
import org.koitharu.kotatsu.tracker.ui.FeedFragment
|
||||
import org.koitharu.kotatsu.tracker.work.TrackWorker
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||
import java.io.Closeable
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
NavigationView.OnNavigationItemSelectedListener,
|
||||
View.OnClickListener {
|
||||
NavigationView.OnNavigationItemSelectedListener, AppBarOwner,
|
||||
View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener {
|
||||
|
||||
private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
|
||||
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
|
||||
mode = LazyThreadSafetyMode.NONE
|
||||
)
|
||||
|
||||
private lateinit var navHeaderBinding: NavigationHeaderBinding
|
||||
private lateinit var drawerToggle: ActionBarDrawerToggle
|
||||
private var closeable: Closeable? = null
|
||||
private var searchViewElevation = 0f
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = binding.appbar
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMainBinding.inflate(layoutInflater))
|
||||
drawerToggle =
|
||||
ActionBarDrawerToggle(
|
||||
this,
|
||||
binding.drawer,
|
||||
binding.toolbar,
|
||||
R.string.open_menu,
|
||||
R.string.close_menu
|
||||
)
|
||||
searchViewElevation = binding.toolbarCard.cardElevation
|
||||
navHeaderBinding = NavigationHeaderBinding.inflate(layoutInflater)
|
||||
drawerToggle = ActionBarDrawerToggle(
|
||||
this,
|
||||
binding.drawer,
|
||||
binding.toolbar,
|
||||
R.string.open_menu,
|
||||
R.string.close_menu
|
||||
)
|
||||
drawerToggle.setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_arrow_back))
|
||||
drawerToggle.setToolbarNavigationClickListener {
|
||||
binding.searchView.hideKeyboard()
|
||||
onBackPressed()
|
||||
}
|
||||
binding.drawer.addDrawerListener(drawerToggle)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
binding.navigationView.setNavigationItemSelectedListener(this)
|
||||
if (get<AppSettings>().isAmoledTheme && get<AppSettings>().theme == AppCompatDelegate.MODE_NIGHT_YES) {
|
||||
binding.appbar.setBackgroundColor(Color.BLACK)
|
||||
binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background))
|
||||
} else {
|
||||
binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface))
|
||||
}
|
||||
|
||||
with(binding.searchView) {
|
||||
onFocusChangeListener = this@MainActivity
|
||||
searchSuggestionListener = this@MainActivity
|
||||
}
|
||||
|
||||
with(binding.navigationView) {
|
||||
val menuView =
|
||||
findViewById<RecyclerView>(com.google.android.material.R.id.design_navigation_view)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(navHeaderBinding.root) { v, insets ->
|
||||
val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.updatePadding(top = systemWindowInsets.top)
|
||||
// NavigationView doesn't dispatch insets to the menu view, so pad the bottom here.
|
||||
menuView.updatePadding(bottom = systemWindowInsets.bottom)
|
||||
insets
|
||||
}
|
||||
addHeaderView(navHeaderBinding.root)
|
||||
itemBackground = navigationItemBackground(context)
|
||||
setNavigationItemSelectedListener(this@MainActivity)
|
||||
}
|
||||
|
||||
with(binding.fab) {
|
||||
imageTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
setOnClickListener(this@MainActivity)
|
||||
}
|
||||
|
||||
supportFragmentManager.findFragmentById(R.id.container)?.let {
|
||||
supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
|
||||
binding.fab.isVisible = it is HistoryListFragment
|
||||
} ?: run {
|
||||
openDefaultSection()
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
TrackWorker.setup(applicationContext)
|
||||
SuggestionsWorker.setup(applicationContext)
|
||||
AppUpdateChecker(this).launchIfNeeded()
|
||||
onFirstStart()
|
||||
}
|
||||
|
||||
viewModel.onOpenReader.observe(this, this::onOpenReader)
|
||||
@@ -87,9 +138,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
viewModel.remoteSources.observe(this, this::updateSideMenu)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
closeable?.close()
|
||||
super.onDestroy()
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
drawerToggle.isDrawerIndicatorEnabled =
|
||||
binding.drawer.getDrawerLockMode(GravityCompat.START) == DrawerLayout.LOCK_MODE_UNLOCKED
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
@@ -103,21 +155,20 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (binding.drawer.isDrawerOpen(binding.navigationView)) {
|
||||
binding.drawer.closeDrawer(binding.navigationView)
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
|
||||
binding.searchView.clearFocus()
|
||||
when {
|
||||
binding.drawer.isDrawerOpen(binding.navigationView) -> binding.drawer.closeDrawer(
|
||||
binding.navigationView)
|
||||
fragment != null -> supportFragmentManager.commit {
|
||||
remove(fragment)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
runOnCommit { onSearchClosed() }
|
||||
}
|
||||
else -> super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_main, menu)
|
||||
menu.findItem(R.id.action_search)?.let { menuItem ->
|
||||
closeable = SearchHelper.setupSearchView(menuItem)
|
||||
}
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return drawerToggle.onOptionsItemSelected(item) || when (item.itemId) {
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
@@ -134,48 +185,99 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
if (item.groupId == R.id.group_remote_sources) {
|
||||
val source = MangaSource.values().getOrNull(item.itemId) ?: return false
|
||||
setPrimaryFragment(RemoteListFragment.newInstance(source))
|
||||
} else when (item.itemId) {
|
||||
R.id.nav_history -> {
|
||||
viewModel.defaultSection = AppSection.HISTORY
|
||||
setPrimaryFragment(HistoryListFragment.newInstance())
|
||||
searchSuggestionViewModel.onSourceChanged(source)
|
||||
} else {
|
||||
searchSuggestionViewModel.onSourceChanged(null)
|
||||
when (item.itemId) {
|
||||
R.id.nav_history -> {
|
||||
viewModel.defaultSection = AppSection.HISTORY
|
||||
setPrimaryFragment(HistoryListFragment.newInstance())
|
||||
}
|
||||
R.id.nav_favourites -> {
|
||||
viewModel.defaultSection = AppSection.FAVOURITES
|
||||
setPrimaryFragment(FavouritesContainerFragment.newInstance())
|
||||
}
|
||||
R.id.nav_local_storage -> {
|
||||
viewModel.defaultSection = AppSection.LOCAL
|
||||
setPrimaryFragment(LocalListFragment.newInstance())
|
||||
}
|
||||
R.id.nav_suggestions -> {
|
||||
viewModel.defaultSection = AppSection.SUGGESTIONS
|
||||
setPrimaryFragment(SuggestionsFragment.newInstance())
|
||||
}
|
||||
R.id.nav_feed -> {
|
||||
viewModel.defaultSection = AppSection.FEED
|
||||
setPrimaryFragment(FeedFragment.newInstance())
|
||||
}
|
||||
R.id.nav_action_settings -> {
|
||||
startActivity(SettingsActivity.newIntent(this))
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
R.id.nav_favourites -> {
|
||||
viewModel.defaultSection = AppSection.FAVOURITES
|
||||
setPrimaryFragment(FavouritesContainerFragment.newInstance())
|
||||
}
|
||||
R.id.nav_local_storage -> {
|
||||
viewModel.defaultSection = AppSection.LOCAL
|
||||
setPrimaryFragment(LocalListFragment.newInstance())
|
||||
}
|
||||
R.id.nav_suggestions -> {
|
||||
viewModel.defaultSection = AppSection.SUGGESTIONS
|
||||
setPrimaryFragment(SuggestionsFragment.newInstance())
|
||||
}
|
||||
R.id.nav_feed -> {
|
||||
viewModel.defaultSection = AppSection.FEED
|
||||
setPrimaryFragment(FeedFragment.newInstance())
|
||||
}
|
||||
R.id.nav_action_settings -> {
|
||||
startActivity(SettingsActivity.newIntent(this))
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
binding.drawer.closeDrawers()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.toolbar.updatePadding(
|
||||
top = insets.top,
|
||||
left = insets.left,
|
||||
right = insets.right
|
||||
)
|
||||
binding.fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
binding.toolbarCard.updateLayoutParams<MarginLayoutParams> {
|
||||
topMargin = insets.top + resources.resolveDp(8)
|
||||
}
|
||||
binding.fab.updateLayoutParams<MarginLayoutParams> {
|
||||
bottomMargin = insets.bottom + topMargin
|
||||
leftMargin = insets.left + topMargin
|
||||
rightMargin = insets.right + topMargin
|
||||
}
|
||||
binding.container.updateLayoutParams<MarginLayoutParams> {
|
||||
topMargin = -(binding.appbar.measureHeight())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFocusChange(v: View?, hasFocus: Boolean) {
|
||||
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
|
||||
if (v?.id == R.id.searchView && hasFocus) {
|
||||
if (fragment == null) {
|
||||
supportFragmentManager.commit {
|
||||
add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
runOnCommit { onSearchOpened() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMangaClick(manga: Manga) {
|
||||
startActivity(DetailsActivity.newIntent(this, manga))
|
||||
}
|
||||
|
||||
override fun onQueryClick(query: String, submit: Boolean) {
|
||||
binding.searchView.query = query
|
||||
if (submit) {
|
||||
if (query.isNotEmpty()) {
|
||||
val source = searchSuggestionViewModel.getLocalSearchSource()
|
||||
if (source != null) {
|
||||
startActivity(SearchActivity.newIntent(this, source, query))
|
||||
} else {
|
||||
startActivity(GlobalSearchActivity.newIntent(this, query))
|
||||
}
|
||||
searchSuggestionViewModel.saveQuery(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onQueryChanged(query: String) {
|
||||
searchSuggestionViewModel.onQueryChanged(query)
|
||||
}
|
||||
|
||||
override fun onClearSearchHistory() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.clear_search_history)
|
||||
.setMessage(R.string.text_clear_search_history_prompt)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.clear) { _, _ ->
|
||||
searchSuggestionViewModel.clearSearchHistory()
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun onOpenReader(manga: Manga) {
|
||||
@@ -245,8 +347,62 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
||||
|
||||
private fun setPrimaryFragment(fragment: Fragment) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.replace(R.id.container, fragment, TAG_PRIMARY)
|
||||
.commit()
|
||||
binding.fab.isVisible = fragment is HistoryListFragment
|
||||
}
|
||||
|
||||
private fun onSearchOpened() {
|
||||
binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||
drawerToggle.isDrawerIndicatorEnabled = false
|
||||
TransitionManager.beginDelayedTransition(binding.appbar)
|
||||
// Avoiding shadows on the sides if the color is transparent, so we make the AppBarLayout white/grey/black
|
||||
if (isDarkAmoledTheme()) {
|
||||
binding.toolbar.setBackgroundColor(Color.BLACK)
|
||||
} else {
|
||||
binding.appbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface))
|
||||
}
|
||||
binding.toolbarCard.apply {
|
||||
cardElevation = 0f
|
||||
// Remove margin
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
leftMargin = 0
|
||||
rightMargin = 0
|
||||
}
|
||||
|
||||
}
|
||||
binding.appbar.elevation = searchViewElevation
|
||||
}
|
||||
|
||||
private fun onSearchClosed() {
|
||||
binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||
drawerToggle.isDrawerIndicatorEnabled = true
|
||||
if (isDarkAmoledTheme()) {
|
||||
binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background))
|
||||
}
|
||||
TransitionManager.beginDelayedTransition(binding.appbar)
|
||||
// Returning transparent color
|
||||
binding.appbar.setBackgroundColor(Color.TRANSPARENT)
|
||||
binding.appbar.elevation = 0f
|
||||
binding.toolbarCard.apply {
|
||||
cardElevation = searchViewElevation
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
leftMargin = resources.resolveDp(16)
|
||||
rightMargin = resources.resolveDp(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFirstStart() {
|
||||
TrackWorker.setup(applicationContext)
|
||||
SuggestionsWorker.setup(applicationContext)
|
||||
AppUpdateChecker(this@MainActivity).launchIfNeeded()
|
||||
OnboardDialogFragment.showWelcome(get(), supportFragmentManager)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val TAG_PRIMARY = "primary"
|
||||
const val TAG_SEARCH = "search"
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package org.koitharu.kotatsu.reader
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||
import org.koitharu.kotatsu.base.domain.MangaIntent
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
|
||||
val readerModule
|
||||
@@ -14,7 +12,7 @@ val readerModule
|
||||
single { MangaDataRepository(get()) }
|
||||
single { PagesCache(get()) }
|
||||
|
||||
viewModel { (intent: MangaIntent, state: ReaderState?) ->
|
||||
ReaderViewModel(intent, state, get(), get(), get(), get())
|
||||
viewModel { params ->
|
||||
ReaderViewModel(params[0], params[1], get(), get(), get(), get())
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,17 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.databinding.DialogChaptersBinding
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
|
||||
class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
|
||||
OnListItemClickListener<MangaChapter> {
|
||||
OnListItemClickListener<ChapterListItem> {
|
||||
|
||||
override fun onInflateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?
|
||||
container: ViewGroup?,
|
||||
) = DialogChaptersBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onBuildDialog(builder: AlertDialog.Builder) {
|
||||
@@ -51,7 +52,8 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
|
||||
index < currentPosition -> ChapterExtra.READ
|
||||
index == currentPosition -> ChapterExtra.CURRENT
|
||||
else -> ChapterExtra.UNREAD
|
||||
}
|
||||
},
|
||||
isMissing = false
|
||||
)
|
||||
}) {
|
||||
if (currentPosition >= 0) {
|
||||
@@ -66,11 +68,11 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaChapter, view: View) {
|
||||
override fun onItemClick(item: ChapterListItem, view: View) {
|
||||
((parentFragment as? OnChapterChangeListener)
|
||||
?: (activity as? OnChapterChangeListener))?.let {
|
||||
dismiss()
|
||||
it.onChapterChanged(item)
|
||||
it.onChapterChanged(item.chapter)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReader
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.wetoon.WebtoonReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
|
||||
import org.koitharu.kotatsu.utils.GridTouchHelper
|
||||
@@ -124,7 +124,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_reader_top, menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
@@ -220,7 +220,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
}
|
||||
|
||||
override fun onGridTouch(area: Int) {
|
||||
controlDelegate.onGridTouch(area)
|
||||
controlDelegate.onGridTouch(area, binding.container)
|
||||
}
|
||||
|
||||
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.SoundEffectConstants
|
||||
import android.view.View
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -30,18 +32,27 @@ class ReaderControlDelegate(
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
fun onGridTouch(area: Int) {
|
||||
fun onGridTouch(area: Int, view: View) {
|
||||
when (area) {
|
||||
GridTouchHelper.AREA_CENTER -> {
|
||||
listener.toggleUiVisibility()
|
||||
view.playSoundEffect(SoundEffectConstants.CLICK)
|
||||
}
|
||||
GridTouchHelper.AREA_TOP -> if (isTapSwitchEnabled) {
|
||||
listener.switchPageBy(-1)
|
||||
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
|
||||
}
|
||||
GridTouchHelper.AREA_TOP,
|
||||
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
|
||||
listener.switchPageBy(-1)
|
||||
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
|
||||
}
|
||||
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
|
||||
listener.switchPageBy(1)
|
||||
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
|
||||
}
|
||||
GridTouchHelper.AREA_BOTTOM,
|
||||
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
|
||||
listener.switchPageBy(1)
|
||||
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@ package org.koitharu.kotatsu.reader.ui
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.commit
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding
|
||||
import org.koitharu.kotatsu.settings.MainSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.NetworkSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.ReaderSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.SourceSettingsFragment
|
||||
|
||||
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
|
||||
|
||||
@@ -25,6 +28,9 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
|
||||
R.id.container, when (intent?.action) {
|
||||
Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment()
|
||||
ACTION_READER -> ReaderSettingsFragment()
|
||||
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
|
||||
intent.getParcelableExtra(EXTRA_SOURCE) ?: MangaSource.LOCAL
|
||||
)
|
||||
else -> MainSettingsFragment()
|
||||
}
|
||||
)
|
||||
@@ -43,9 +49,17 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
|
||||
|
||||
private const val ACTION_READER =
|
||||
"${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
|
||||
private const val ACTION_SOURCE =
|
||||
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
|
||||
fun newReaderSettingsIntent(context: Context) =
|
||||
Intent(context, SimpleSettingsActivity::class.java)
|
||||
.setAction(ACTION_READER)
|
||||
|
||||
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
|
||||
Intent(context, SimpleSettingsActivity::class.java)
|
||||
.setAction(ACTION_SOURCE)
|
||||
.putExtra(EXTRA_SOURCE, source as Parcelable)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.wetoon
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.wetoon
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user