Move sources from java to kotlin dir
This commit is contained in:
144
app/src/main/kotlin/org/koitharu/kotatsu/KotatsuApp.kt
Normal file
144
app/src/main/kotlin/org/koitharu/kotatsu/KotatsuApp.kt
Normal file
@@ -0,0 +1,144 @@
|
||||
package org.koitharu.kotatsu
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.StrictMode
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.work.Configuration
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.httpSender
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class KotatsuApp : Application(), Configuration.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
|
||||
|
||||
@Inject
|
||||
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
||||
|
||||
@Inject
|
||||
lateinit var database: MangaDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
enableStrictMode()
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||
setupActivityLifecycleCallbacks()
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
setupDatabaseObservers()
|
||||
}
|
||||
WorkServiceStopHelper(applicationContext).setup()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
excludeMatchingSharedPreferencesKeys = listOf(
|
||||
"sources_\\w+",
|
||||
)
|
||||
httpSender {
|
||||
uri = getString(R.string.url_error_report)
|
||||
basicAuthLogin = getString(R.string.acra_login)
|
||||
basicAuthPassword = getString(R.string.acra_password)
|
||||
httpMethod = HttpSender.Method.POST
|
||||
}
|
||||
reportContent = listOf(
|
||||
ReportField.PACKAGE_NAME,
|
||||
ReportField.INSTALLATION_ID,
|
||||
ReportField.APP_VERSION_CODE,
|
||||
ReportField.APP_VERSION_NAME,
|
||||
ReportField.ANDROID_VERSION,
|
||||
ReportField.PHONE_MODEL,
|
||||
ReportField.STACK_TRACE,
|
||||
ReportField.CRASH_CONFIGURATION,
|
||||
ReportField.SHARED_PREFERENCES,
|
||||
)
|
||||
dialog {
|
||||
text = getString(R.string.crash_text)
|
||||
title = getString(R.string.error_occurred)
|
||||
positiveButtonText = getString(R.string.send)
|
||||
resIcon = R.drawable.ic_alert_outline
|
||||
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getWorkManagerConfiguration(): Configuration {
|
||||
return Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun setupDatabaseObservers() {
|
||||
val tracker = database.invalidationTracker
|
||||
databaseObservers.forEach {
|
||||
tracker.addObserver(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActivityLifecycleCallbacks() {
|
||||
activityLifecycleCallbacks.forEach {
|
||||
registerActivityLifecycleCallbacks(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableStrictMode() {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||
.penaltyDeath()
|
||||
.detectFragmentReuse()
|
||||
.detectWrongFragmentContainer()
|
||||
.detectRetainInstanceUsage()
|
||||
.detectSetUserVisibleHint()
|
||||
.detectFragmentTagUsage()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.bookmarks.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
|
||||
@Entity(
|
||||
tableName = "bookmarks",
|
||||
primaryKeys = ["manga_id", "page_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
]
|
||||
)
|
||||
data class BookmarkEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
|
||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||
@ColumnInfo(name = "page") val page: Int,
|
||||
@ColumnInfo(name = "scroll") val scroll: Int,
|
||||
@ColumnInfo(name = "image") val imageUrl: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "percent") val percent: Float,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.koitharu.kotatsu.bookmarks.data
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||
|
||||
@Dao
|
||||
abstract class BookmarksDao {
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
|
||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
|
||||
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at"
|
||||
)
|
||||
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
|
||||
|
||||
@Insert
|
||||
abstract suspend fun insert(entity: BookmarkEntity)
|
||||
|
||||
@Delete
|
||||
abstract suspend fun delete(entity: BookmarkEntity)
|
||||
|
||||
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||
abstract suspend fun delete(mangaId: Long, pageId: Long)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.koitharu.kotatsu.bookmarks.data
|
||||
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.*
|
||||
|
||||
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
||||
manga = manga,
|
||||
pageId = pageId,
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll,
|
||||
imageUrl = imageUrl,
|
||||
createdAt = Date(createdAt),
|
||||
percent = percent,
|
||||
)
|
||||
|
||||
fun Bookmark.toEntity() = BookmarkEntity(
|
||||
mangaId = manga.id,
|
||||
pageId = pageId,
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll,
|
||||
imageUrl = imageUrl,
|
||||
createdAt = createdAt.time,
|
||||
percent = percent,
|
||||
)
|
||||
|
||||
fun Collection<BookmarkEntity>.toBookmarks(manga: Manga) = map {
|
||||
it.toBookmark(manga)
|
||||
}
|
||||
|
||||
fun Collection<Bookmark>.ids() = map { it.pageId }
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.koitharu.kotatsu.bookmarks.domain
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.*
|
||||
|
||||
class Bookmark(
|
||||
val manga: Manga,
|
||||
val pageId: Long,
|
||||
val chapterId: Long,
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
val imageUrl: String,
|
||||
val createdAt: Date,
|
||||
val percent: Float,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Bookmark
|
||||
|
||||
if (manga != other.manga) return false
|
||||
if (pageId != other.pageId) return false
|
||||
if (chapterId != other.chapterId) return false
|
||||
if (page != other.page) return false
|
||||
if (scroll != other.scroll) return false
|
||||
if (imageUrl != other.imageUrl) return false
|
||||
if (createdAt != other.createdAt) return false
|
||||
if (percent != other.percent) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = manga.hashCode()
|
||||
result = 31 * result + pageId.hashCode()
|
||||
result = 31 * result + chapterId.hashCode()
|
||||
result = 31 * result + page
|
||||
result = 31 * result + scroll
|
||||
result = 31 * result + imageUrl.hashCode()
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
result = 31 * result + percent.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.koitharu.kotatsu.bookmarks.domain
|
||||
|
||||
import android.database.SQLException
|
||||
import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||
import org.koitharu.kotatsu.bookmarks.data.toBookmark
|
||||
import org.koitharu.kotatsu.bookmarks.data.toBookmarks
|
||||
import org.koitharu.kotatsu.bookmarks.data.toEntity
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntities
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class BookmarksRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
) {
|
||||
|
||||
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
|
||||
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
||||
}
|
||||
|
||||
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
|
||||
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
|
||||
}
|
||||
|
||||
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
|
||||
return db.bookmarksDao.observe().map { map ->
|
||||
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
|
||||
for ((k, v) in map) {
|
||||
val manga = k.toManga()
|
||||
res[manga] = v.toBookmarks(manga)
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addBookmark(bookmark: Bookmark) {
|
||||
db.withTransaction {
|
||||
val tags = bookmark.manga.tags.toEntities()
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
|
||||
db.bookmarksDao.insert(bookmark.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(mangaId: Long, pageId: Long) {
|
||||
db.bookmarksDao.delete(mangaId, pageId)
|
||||
}
|
||||
|
||||
suspend fun removeBookmarks(ids: Map<Manga, Set<Long>>): ReversibleHandle {
|
||||
val entities = ArrayList<BookmarkEntity>(ids.size)
|
||||
db.withTransaction {
|
||||
val dao = db.bookmarksDao
|
||||
for ((manga, idSet) in ids) {
|
||||
for (pageId in idSet) {
|
||||
val e = dao.find(manga.id, pageId)
|
||||
if (e != null) {
|
||||
entities.add(e)
|
||||
}
|
||||
dao.delete(manga.id, pageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return BookmarksRestorer(entities)
|
||||
}
|
||||
|
||||
private inner class BookmarksRestorer(
|
||||
private val entities: Collection<BookmarkEntity>,
|
||||
) : ReversibleHandle {
|
||||
|
||||
override suspend fun reverse() {
|
||||
db.withTransaction {
|
||||
for (e in entities) {
|
||||
try {
|
||||
db.bookmarksDao.insert(e)
|
||||
} catch (e: SQLException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BookmarksActivity :
|
||||
BaseActivity<ActivityContainerBinding>(),
|
||||
AppBarOwner,
|
||||
SnackbarOwner {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
override val snackbarHost: CoordinatorLayout
|
||||
get() = viewBinding.root
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val fm = supportFragmentManager
|
||||
if (fm.findFragmentById(R.id.container) == null) {
|
||||
fm.commit {
|
||||
val fragment = BookmarksFragment.newInstance()
|
||||
replace(R.id.container, fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, BookmarksActivity::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.viewModels
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.data.ids
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter
|
||||
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.ui.util.reverseAsync
|
||||
import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations
|
||||
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BookmarksFragment :
|
||||
BaseFragment<FragmentListSimpleBinding>(),
|
||||
ListStateHolderListener,
|
||||
OnListItemClickListener<Bookmark>,
|
||||
SectionedSelectionController.Callback<Manga>,
|
||||
FastScroller.FastScrollListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by viewModels<BookmarksViewModel>()
|
||||
private var adapter: BookmarksGroupAdapter? = null
|
||||
private var selectionController: SectionedSelectionController<Manga>? = null
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
|
||||
return FragmentListSimpleBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListSimpleBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
selectionController = SectionedSelectionController(
|
||||
activity = requireActivity(),
|
||||
owner = this,
|
||||
callback = this,
|
||||
)
|
||||
adapter = BookmarksGroupAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
coil = coil,
|
||||
listener = this,
|
||||
selectionController = checkNotNull(selectionController),
|
||||
bookmarkClickListener = this,
|
||||
groupClickListener = OnGroupClickListener(),
|
||||
)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
val spacingDecoration = SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
|
||||
binding.recyclerView.addItemDecoration(spacingDecoration)
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
|
||||
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
adapter = null
|
||||
selectionController = null
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
|
||||
val intent = ReaderActivity.newIntent(view.context, item)
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(item.manga, item.pageId) ?: false
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
|
||||
override fun onEmptyActionClick() = Unit
|
||||
|
||||
override fun onFastScrollStart(fastScroller: FastScroller) {
|
||||
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
|
||||
}
|
||||
|
||||
override fun onFastScrollStop(fastScroller: FastScroller) = Unit
|
||||
|
||||
override fun onSelectionChanged(controller: SectionedSelectionController<Manga>, count: Int) {
|
||||
requireViewBinding().recyclerView.invalidateNestedItemDecorations()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(
|
||||
controller: SectionedSelectionController<Manga>,
|
||||
mode: ActionMode,
|
||||
menu: Menu,
|
||||
): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(
|
||||
controller: SectionedSelectionController<Manga>,
|
||||
mode: ActionMode,
|
||||
item: MenuItem,
|
||||
): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
val ids = selectionController?.snapshot() ?: return false
|
||||
viewModel.removeBookmarks(ids)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateItemDecoration(
|
||||
controller: SectionedSelectionController<Manga>,
|
||||
section: Manga,
|
||||
): AbstractSelectionItemDecoration = BookmarksSelectionDecoration(requireContext())
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
requireViewBinding().recyclerView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
requireViewBinding().recyclerView.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = insets.bottom
|
||||
}
|
||||
}
|
||||
|
||||
private fun onListChanged(list: List<ListModel>) {
|
||||
adapter?.items = list
|
||||
}
|
||||
|
||||
private fun onActionDone(action: ReversibleAction) {
|
||||
val handle = action.handle
|
||||
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
|
||||
val snackbar = Snackbar.make((activity as SnackbarOwner).snackbarHost, action.stringResId, length)
|
||||
if (handle != null) {
|
||||
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
|
||||
private inner class OnGroupClickListener : OnListItemClickListener<BookmarksGroup> {
|
||||
|
||||
override fun onItemClick(item: BookmarksGroup, view: View) {
|
||||
val controller = selectionController
|
||||
if (controller != null && controller.count > 0) {
|
||||
if (controller.getSectionCount(item.manga) == item.bookmarks.size) {
|
||||
controller.clearSelection(item.manga)
|
||||
} else {
|
||||
controller.addToSelection(item.manga, item.bookmarks.ids())
|
||||
}
|
||||
return
|
||||
}
|
||||
val intent = DetailsActivity.newIntent(view.context, item.manga)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: BookmarksGroup, view: View): Boolean {
|
||||
return selectionController?.addToSelection(item.manga, item.bookmarks.ids()) ?: false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = BookmarksFragment()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
|
||||
|
||||
class BookmarksSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
|
||||
|
||||
override fun getItemId(parent: RecyclerView, child: View): Long {
|
||||
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
|
||||
val item = holder.getItem(Bookmark::class.java) ?: return RecyclerView.NO_ID
|
||||
return item.pageId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.core.util.asFlowLiveData
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BookmarksViewModel @Inject constructor(
|
||||
private val repository: BookmarksRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onActionDone = SingleLiveEvent<ReversibleAction>()
|
||||
|
||||
val content: LiveData<List<ListModel>> = repository.observeBookmarks()
|
||||
.map { list ->
|
||||
if (list.isEmpty()) {
|
||||
listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_favourites,
|
||||
textPrimary = R.string.no_bookmarks_yet,
|
||||
textSecondary = R.string.no_bookmarks_summary,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
)
|
||||
} else list.map { (manga, bookmarks) ->
|
||||
BookmarksGroup(manga, bookmarks)
|
||||
}
|
||||
}
|
||||
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
|
||||
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||
|
||||
fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val handle = repository.removeBookmarks(ids)
|
||||
onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
||||
|
||||
fun bookmarkListAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Bookmark>,
|
||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
binding.root.setOnClickListener(listener)
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
|
||||
size(CoverSizeResolver(binding.imageViewThumb))
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
allowRgb565(true)
|
||||
decodeRegion(item.scroll)
|
||||
source(item.manga.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
binding.imageViewThumb.disposeImageRequest()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
|
||||
class BookmarksAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Bookmark>,
|
||||
) : AsyncListDifferDelegationAdapter<Bookmark>(
|
||||
DiffCallback(),
|
||||
bookmarkListAD(coil, lifecycleOwner, clickListener),
|
||||
) {
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
|
||||
return oldItem.manga.id == newItem.manga.id && oldItem.pageId == newItem.pageId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
|
||||
return oldItem.imageUrl == newItem.imageUrl
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.clearItemDecorations
|
||||
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarksGroupBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
fun bookmarksGroupAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
sharedPool: RecyclerView.RecycledViewPool,
|
||||
selectionController: SectionedSelectionController<Manga>,
|
||||
bookmarkClickListener: OnListItemClickListener<Bookmark>,
|
||||
groupClickListener: OnListItemClickListener<BookmarksGroup>,
|
||||
) = adapterDelegateViewBinding<BookmarksGroup, ListModel, ItemBookmarksGroupBinding>(
|
||||
{ layoutInflater, parent -> ItemBookmarksGroupBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
val viewListenerAdapter = object : View.OnClickListener, View.OnLongClickListener {
|
||||
override fun onClick(v: View) = groupClickListener.onItemClick(item, v)
|
||||
override fun onLongClick(v: View) = groupClickListener.onItemLongClick(item, v)
|
||||
}
|
||||
|
||||
val adapter = BookmarksAdapter(coil, lifecycleOwner, bookmarkClickListener)
|
||||
binding.recyclerView.setRecycledViewPool(sharedPool)
|
||||
binding.recyclerView.adapter = adapter
|
||||
val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
|
||||
binding.recyclerView.addItemDecoration(spacingDecoration)
|
||||
binding.root.setOnClickListener(viewListenerAdapter)
|
||||
binding.root.setOnLongClickListener(viewListenerAdapter)
|
||||
|
||||
bind { payloads ->
|
||||
if (payloads.isEmpty()) {
|
||||
binding.recyclerView.clearItemDecorations()
|
||||
binding.recyclerView.addItemDecoration(spacingDecoration)
|
||||
selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
|
||||
}
|
||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
allowRgb565(true)
|
||||
size(CoverSizeResolver(binding.imageViewCover))
|
||||
source(item.manga.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
binding.textViewTitle.text = item.manga.title
|
||||
adapter.items = item.bookmarks
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
binding.imageViewCover.disposeImageRequest()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import kotlin.jvm.internal.Intrinsics
|
||||
|
||||
class BookmarksGroupAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
selectionController: SectionedSelectionController<Manga>,
|
||||
listener: ListStateHolderListener,
|
||||
bookmarkClickListener: OnListItemClickListener<Bookmark>,
|
||||
groupClickListener: OnListItemClickListener<BookmarksGroup>,
|
||||
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
val pool = RecyclerView.RecycledViewPool()
|
||||
delegatesManager
|
||||
.addDelegate(
|
||||
bookmarksGroupAD(
|
||||
coil = coil,
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
sharedPool = pool,
|
||||
selectionController = selectionController,
|
||||
bookmarkClickListener = bookmarkClickListener,
|
||||
groupClickListener = groupClickListener,
|
||||
),
|
||||
)
|
||||
.addDelegate(loadingStateAD())
|
||||
.addDelegate(loadingFooterAD())
|
||||
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(errorStateListAD(listener))
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return when {
|
||||
oldItem is BookmarksGroup && newItem is BookmarksGroup -> {
|
||||
oldItem.manga.id == newItem.manga.id
|
||||
}
|
||||
|
||||
oldItem is LoadingFooter && newItem is LoadingFooter -> {
|
||||
oldItem.key == newItem.key
|
||||
}
|
||||
|
||||
else -> oldItem.javaClass == newItem.javaClass
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return Intrinsics.areEqual(oldItem, newItem)
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
|
||||
return when {
|
||||
oldItem is BookmarksGroup && newItem is BookmarksGroup -> Unit
|
||||
else -> super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.areItemsEquals
|
||||
|
||||
class BookmarksGroup(
|
||||
val manga: Manga,
|
||||
val bookmarks: List<Bookmark>,
|
||||
) : ListModel {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as BookmarksGroup
|
||||
|
||||
if (manga != other.manga) return false
|
||||
|
||||
return bookmarks.areItemsEquals(other.bookmarks) { a, b ->
|
||||
a.imageUrl == b.imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = manga.hashCode()
|
||||
result = 31 * result + bookmarks.sumOf { it.imageUrl.hashCode() }
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
|
||||
return
|
||||
}
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
||||
}
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||
if (savedInstanceState != null) {
|
||||
return
|
||||
}
|
||||
val url = intent?.dataString
|
||||
if (url.isNullOrEmpty()) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
onTitleChanged(
|
||||
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
|
||||
url,
|
||||
)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewBinding.webView.saveState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
viewBinding.webView.restoreState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.opt_browser, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
viewBinding.webView.stopLoading()
|
||||
finishAfterTransition()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(viewBinding.webView.url)
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, item.title))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
this.title = title
|
||||
supportActionBar?.subtitle = subtitle
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback.onHistoryChanged()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.appbar.updatePadding(
|
||||
top = insets.top,
|
||||
)
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_TITLE = "title"
|
||||
|
||||
fun newIntent(context: Context, url: String, title: String?): Intent {
|
||||
return Intent(context, BrowserActivity::class.java)
|
||||
.setData(Uri.parse(url))
|
||||
.putExtra(EXTRA_TITLE, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
interface BrowserCallback : OnHistoryChangedListener {
|
||||
|
||||
fun onLoadingStateChanged(isLoading: Boolean)
|
||||
|
||||
fun onTitleChanged(title: CharSequence, subtitle: CharSequence?)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(webView: WebView, url: String) {
|
||||
super.onPageFinished(webView, url)
|
||||
callback.onLoadingStateChanged(isLoading = false)
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
callback.onLoadingStateChanged(isLoading = true)
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
callback.onTitleChanged(view.title.orEmpty(), url)
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload)
|
||||
callback.onHistoryChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
fun interface OnHistoryChangedListener {
|
||||
|
||||
fun onHistoryChanged()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
|
||||
private const val PROGRESS_MAX = 100
|
||||
|
||||
class ProgressChromeClient(
|
||||
private val progressIndicator: BaseProgressIndicator<*>,
|
||||
) : WebChromeClient() {
|
||||
|
||||
init {
|
||||
progressIndicator.max = PROGRESS_MAX
|
||||
}
|
||||
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
super.onProgressChanged(view, newProgress)
|
||||
if (!progressIndicator.isVisible) {
|
||||
return
|
||||
}
|
||||
if (newProgress in 1 until PROGRESS_MAX) {
|
||||
progressIndicator.isIndeterminate = false
|
||||
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
|
||||
} else {
|
||||
progressIndicator.isIndeterminate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
|
||||
class WebViewBackPressedCallback(
|
||||
private val webView: WebView,
|
||||
) : OnBackPressedCallback(false), OnHistoryChangedListener {
|
||||
|
||||
init {
|
||||
onHistoryChanged()
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
webView.goBack()
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
isEnabled = webView.canGoBack()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import org.koitharu.kotatsu.browser.BrowserCallback
|
||||
|
||||
interface CloudFlareCallback : BrowserCallback {
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
|
||||
|
||||
fun onPageLoaded()
|
||||
|
||||
fun onCheckPassed()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
|
||||
private const val CF_CLEARANCE = "cf_clearance"
|
||||
|
||||
class CloudFlareClient(
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val callback: CloudFlareCallback,
|
||||
private val targetUrl: String,
|
||||
) : BrowserClient(callback) {
|
||||
|
||||
private val oldClearance = getClearance()
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
checkClearance()
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
callback.onPageLoaded()
|
||||
}
|
||||
|
||||
override fun onPageFinished(webView: WebView, url: String) {
|
||||
super.onPageFinished(webView, url)
|
||||
callback.onPageLoaded()
|
||||
}
|
||||
|
||||
private fun checkClearance() {
|
||||
val clearance = getClearance()
|
||||
if (clearance != null && clearance != oldClearance) {
|
||||
callback.onCheckPassed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getClearance(): String? {
|
||||
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
|
||||
.find { it.name == CF_CLEARANCE }?.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebSettings
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import okhttp3.Headers
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
|
||||
|
||||
private lateinit var url: String
|
||||
private val pendingResult = Bundle(1)
|
||||
|
||||
@Inject
|
||||
lateinit var cookieJar: MutableCookieJar
|
||||
|
||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
url = requireArguments().getString(ARG_URL).orEmpty()
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = FragmentCloudflareBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
with(binding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
|
||||
}
|
||||
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
|
||||
if (url.isEmpty()) {
|
||||
dismissAllowingStateLoss()
|
||||
} else {
|
||||
binding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
requireViewBinding().webView.stopLoading()
|
||||
requireViewBinding().webView.destroy()
|
||||
onBackPressedCallback = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
override fun onDialogCreated(dialog: AlertDialog) {
|
||||
super.onDialogCreated(dialog)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also {
|
||||
dialog.onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireViewBinding().webView.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
requireViewBinding().webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
setFragmentResult(TAG, pendingResult)
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
|
||||
override fun onPageLoaded() {
|
||||
viewBinding?.progressBar?.isInvisible = true
|
||||
}
|
||||
|
||||
override fun onCheckPassed() {
|
||||
pendingResult.putBoolean(EXTRA_RESULT, true)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback?.onHistoryChanged()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAG = "CloudFlareDialog"
|
||||
const val EXTRA_RESULT = "result"
|
||||
private const val ARG_URL = "url"
|
||||
private const val ARG_UA = "ua"
|
||||
|
||||
fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) {
|
||||
putString(ARG_URL, url)
|
||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
||||
putString(ARG_UA, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt
Normal file
172
app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt
Normal file
@@ -0,0 +1,172 @@
|
||||
package org.koitharu.kotatsu.core
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.text.Html
|
||||
import androidx.collection.arraySetOf
|
||||
import androidx.room.InvalidationTracker
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.util.DebugLogger
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ElementsIntoSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.cache.ContentCache
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.cache.StubContentCache
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.network.*
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
|
||||
import org.koitharu.kotatsu.core.util.ext.activityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||
import org.koitharu.kotatsu.local.data.LocalManga
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface AppModule {
|
||||
|
||||
@Binds
|
||||
fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext
|
||||
|
||||
@Binds
|
||||
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkState(
|
||||
@ApplicationContext context: Context
|
||||
) = NetworkState(context.connectivityManager)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMangaDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): MangaDatabase {
|
||||
return MangaDatabase(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCoil(
|
||||
@ApplicationContext context: Context,
|
||||
@MangaHttpClient okHttpClient: OkHttpClient,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
pagesCache: PagesCache,
|
||||
): ImageLoader {
|
||||
val diskCacheFactory = {
|
||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||
DiskCache.Builder()
|
||||
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
||||
.build()
|
||||
}
|
||||
return ImageLoader.Builder(context)
|
||||
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
|
||||
.interceptorDispatcher(Dispatchers.Default)
|
||||
.fetcherDispatcher(Dispatchers.IO)
|
||||
.decoderDispatcher(Dispatchers.Default)
|
||||
.transformationDispatcher(Dispatchers.Default)
|
||||
.diskCache(diskCacheFactory)
|
||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||
.allowRgb565(context.isLowRamDevice())
|
||||
.components(
|
||||
ComponentRegistry.Builder()
|
||||
.add(SvgDecoder.Factory())
|
||||
.add(CbzFetcher.Factory())
|
||||
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
||||
.add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory))
|
||||
.build(),
|
||||
).build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideSearchSuggestions(
|
||||
@ApplicationContext context: Context,
|
||||
): SearchRecentSuggestions {
|
||||
return MangaSuggestionsProvider.createSuggestions(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
fun provideDatabaseObservers(
|
||||
widgetUpdater: WidgetUpdater,
|
||||
shortcutsUpdater: ShortcutsUpdater,
|
||||
backupObserver: BackupObserver,
|
||||
syncController: SyncController,
|
||||
): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf(
|
||||
widgetUpdater,
|
||||
shortcutsUpdater,
|
||||
backupObserver,
|
||||
syncController,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
fun provideActivityLifecycleCallbacks(
|
||||
appProtectHelper: AppProtectHelper,
|
||||
activityRecreationHandle: ActivityRecreationHandle,
|
||||
incognitoModeIndicator: IncognitoModeIndicator,
|
||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||
appProtectHelper,
|
||||
activityRecreationHandle,
|
||||
incognitoModeIndicator,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideContentCache(
|
||||
application: Application,
|
||||
): ContentCache {
|
||||
return if (application.activityManager?.isLowRamDevice == true) {
|
||||
StubContentCache()
|
||||
} else {
|
||||
MemoryContentCache(application)
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@LocalStorageChanges
|
||||
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow<LocalManga?> = MutableSharedFlow()
|
||||
|
||||
@Provides
|
||||
@LocalStorageChanges
|
||||
fun provideLocalStorageChangesFlow(
|
||||
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
|
||||
): SharedFlow<LocalManga?> = flow.asSharedFlow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import org.json.JSONArray
|
||||
|
||||
class BackupEntry(
|
||||
val name: String,
|
||||
val data: JSONArray
|
||||
) {
|
||||
|
||||
companion object Names {
|
||||
|
||||
const val INDEX = "index"
|
||||
const val HISTORY = "history"
|
||||
const val CATEGORIES = "categories"
|
||||
const val FAVOURITES = "favourites"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 10
|
||||
|
||||
class BackupRepository @Inject constructor(private val db: MangaDatabase) {
|
||||
|
||||
suspend fun dumpHistory(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
|
||||
while (true) {
|
||||
val history = db.historyDao.findAll(offset, PAGE_SIZE)
|
||||
if (history.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += history.size
|
||||
for (item in history) {
|
||||
val manga = JsonSerializer(item.manga).toJson()
|
||||
val tags = JSONArray()
|
||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||
manga.put("tags", tags)
|
||||
val json = JsonSerializer(item.history).toJson()
|
||||
json.put("manga", manga)
|
||||
entry.data.put(json)
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun dumpCategories(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
|
||||
val categories = db.favouriteCategoriesDao.findAll()
|
||||
for (item in categories) {
|
||||
entry.data.put(JsonSerializer(item).toJson())
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun dumpFavourites(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
|
||||
while (true) {
|
||||
val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE)
|
||||
if (favourites.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += favourites.size
|
||||
for (item in favourites) {
|
||||
val manga = JsonSerializer(item.manga).toJson()
|
||||
val tags = JSONArray()
|
||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||
manga.put("tags", tags)
|
||||
val json = JsonSerializer(item.favourite).toJson()
|
||||
json.put("manga", manga)
|
||||
entry.data.put(json)
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
fun createIndex(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.INDEX, JSONArray())
|
||||
val json = JSONObject()
|
||||
json.put("app_id", BuildConfig.APPLICATION_ID)
|
||||
json.put("app_version", BuildConfig.VERSION_CODE)
|
||||
json.put("created_at", System.currentTimeMillis())
|
||||
entry.data.put(json)
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
JsonDeserializer(it).toTagEntity()
|
||||
}
|
||||
val history = JsonDeserializer(item).toHistoryEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(manga, tags)
|
||||
db.historyDao.upsert(history)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.favouriteCategoriesDao.upsert(category)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
JsonDeserializer(it).toTagEntity()
|
||||
}
|
||||
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(manga, tags)
|
||||
db.favouritesDao.upsert(favourite)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class BackupZipInput(val file: File) : Closeable {
|
||||
|
||||
private val zipFile = ZipFile(file)
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
|
||||
val entry = zipFile.getEntry(name)
|
||||
val json = zipFile.getInputStream(entry).use {
|
||||
JSONArray(it.bufferedReader().readText())
|
||||
}
|
||||
BackupEntry(name, json)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
zipFile.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.format
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.Deflater
|
||||
|
||||
class BackupZipOutput(val file: File) : Closeable {
|
||||
|
||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||
|
||||
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
||||
output.put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
||||
output.finish()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
}
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bk.zip")
|
||||
}
|
||||
BackupZipOutput(File(dir, filename))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
class CompositeResult {
|
||||
|
||||
private var successCount: Int = 0
|
||||
private val errors = ArrayList<Throwable?>()
|
||||
|
||||
val size: Int
|
||||
get() = successCount + errors.size
|
||||
|
||||
val failures: List<Throwable>
|
||||
get() = errors.filterNotNull()
|
||||
|
||||
val isAllSuccess: Boolean
|
||||
get() = errors.none { it != null }
|
||||
|
||||
val isAllFailed: Boolean
|
||||
get() = successCount == 0 && errors.isNotEmpty()
|
||||
|
||||
operator fun plusAssign(result: Result<*>) {
|
||||
when {
|
||||
result.isSuccess -> successCount++
|
||||
result.isFailure -> errors.add(result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
operator fun plusAssign(other: CompositeResult) {
|
||||
this.successCount += other.successCount
|
||||
this.errors += other.errors
|
||||
}
|
||||
|
||||
operator fun plus(other: CompositeResult): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
result.successCount = this.successCount + other.successCount
|
||||
result.errors.addAll(this.errors)
|
||||
result.errors.addAll(other.errors)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
|
||||
class JsonDeserializer(private val json: JSONObject) {
|
||||
|
||||
fun toFavouriteEntity() = FavouriteEntity(
|
||||
mangaId = json.getLong("manga_id"),
|
||||
categoryId = json.getLong("category_id"),
|
||||
sortKey = json.getIntOrDefault("sort_key", 0),
|
||||
createdAt = json.getLong("created_at"),
|
||||
deletedAt = 0L,
|
||||
)
|
||||
|
||||
fun toMangaEntity() = MangaEntity(
|
||||
id = json.getLong("id"),
|
||||
title = json.getString("title"),
|
||||
altTitle = json.getStringOrNull("alt_title"),
|
||||
url = json.getString("url"),
|
||||
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
||||
rating = json.getDouble("rating").toFloat(),
|
||||
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
||||
coverUrl = json.getString("cover_url"),
|
||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
||||
state = json.getStringOrNull("state"),
|
||||
author = json.getStringOrNull("author"),
|
||||
source = json.getString("source")
|
||||
)
|
||||
|
||||
fun toTagEntity() = TagEntity(
|
||||
id = json.getLong("id"),
|
||||
title = json.getString("title"),
|
||||
key = json.getString("key"),
|
||||
source = json.getString("source")
|
||||
)
|
||||
|
||||
fun toHistoryEntity() = HistoryEntity(
|
||||
mangaId = json.getLong("manga_id"),
|
||||
createdAt = json.getLong("created_at"),
|
||||
updatedAt = json.getLong("updated_at"),
|
||||
chapterId = json.getLong("chapter_id"),
|
||||
page = json.getInt("page"),
|
||||
scroll = json.getDouble("scroll").toFloat(),
|
||||
percent = json.getFloatOrDefault("percent", -1f),
|
||||
deletedAt = 0L,
|
||||
)
|
||||
|
||||
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
|
||||
categoryId = json.getInt("category_id"),
|
||||
createdAt = json.getLong("created_at"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
title = json.getString("title"),
|
||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
||||
track = json.getBooleanOrDefault("track", true),
|
||||
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
|
||||
deletedAt = 0L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
|
||||
class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
|
||||
constructor(e: FavouriteEntity) : this(
|
||||
JSONObject().apply {
|
||||
put("manga_id", e.mangaId)
|
||||
put("category_id", e.categoryId)
|
||||
put("sort_key", e.sortKey)
|
||||
put("created_at", e.createdAt)
|
||||
}
|
||||
)
|
||||
|
||||
constructor(e: FavouriteCategoryEntity) : this(
|
||||
JSONObject().apply {
|
||||
put("category_id", e.categoryId)
|
||||
put("created_at", e.createdAt)
|
||||
put("sort_key", e.sortKey)
|
||||
put("title", e.title)
|
||||
put("order", e.order)
|
||||
put("track", e.track)
|
||||
put("show_in_lib", e.isVisibleInLibrary)
|
||||
}
|
||||
)
|
||||
|
||||
constructor(e: HistoryEntity) : this(
|
||||
JSONObject().apply {
|
||||
put("manga_id", e.mangaId)
|
||||
put("created_at", e.createdAt)
|
||||
put("updated_at", e.updatedAt)
|
||||
put("chapter_id", e.chapterId)
|
||||
put("page", e.page)
|
||||
put("scroll", e.scroll)
|
||||
put("percent", e.percent)
|
||||
}
|
||||
)
|
||||
|
||||
constructor(e: TagEntity) : this(
|
||||
JSONObject().apply {
|
||||
put("id", e.id)
|
||||
put("title", e.title)
|
||||
put("key", e.key)
|
||||
put("source", e.source)
|
||||
}
|
||||
)
|
||||
|
||||
constructor(e: MangaEntity) : this(
|
||||
JSONObject().apply {
|
||||
put("id", e.id)
|
||||
put("title", e.title)
|
||||
put("alt_title", e.altTitle)
|
||||
put("url", e.url)
|
||||
put("public_url", e.publicUrl)
|
||||
put("rating", e.rating)
|
||||
put("nsfw", e.isNsfw)
|
||||
put("cover_url", e.coverUrl)
|
||||
put("large_cover_url", e.largeCoverUrl)
|
||||
put("state", e.state)
|
||||
put("author", e.author)
|
||||
put("source", e.source)
|
||||
}
|
||||
)
|
||||
|
||||
fun toJson(): JSONObject = json
|
||||
}
|
||||
23
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt
vendored
Normal file
23
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
interface ContentCache {
|
||||
|
||||
val isCachingEnabled: Boolean
|
||||
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga?
|
||||
|
||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
|
||||
|
||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
|
||||
|
||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
|
||||
|
||||
data class Key(
|
||||
val source: MangaSource,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
33
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt
vendored
Normal file
33
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ExpiringLruCache<T>(
|
||||
val maxSize: Int,
|
||||
private val lifetime: Long,
|
||||
private val timeUnit: TimeUnit,
|
||||
) {
|
||||
|
||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
||||
|
||||
operator fun get(key: ContentCache.Key): T? {
|
||||
val value = cache.get(key) ?: return null
|
||||
if (value.isExpired) {
|
||||
cache.remove(key)
|
||||
}
|
||||
return value.get()
|
||||
}
|
||||
|
||||
operator fun set(key: ContentCache.Key, value: T) {
|
||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
cache.evictAll()
|
||||
}
|
||||
|
||||
fun trimToSize(size: Int) {
|
||||
cache.trimToSize(size)
|
||||
}
|
||||
}
|
||||
34
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringValue.kt
vendored
Normal file
34
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringValue.kt
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import android.os.SystemClock
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ExpiringValue<T>(
|
||||
private val value: T,
|
||||
lifetime: Long,
|
||||
timeUnit: TimeUnit,
|
||||
) {
|
||||
|
||||
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
|
||||
|
||||
val isExpired: Boolean
|
||||
get() = SystemClock.elapsedRealtime() >= expiresAt
|
||||
|
||||
fun get(): T? = if (isExpired) null else value
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ExpiringValue<*>
|
||||
|
||||
if (value != other.value) return false
|
||||
return expiresAt == other.expiresAt
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = value?.hashCode() ?: 0
|
||||
result = 31 * result + expiresAt.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
60
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt
vendored
Normal file
60
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.res.Configuration
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
|
||||
|
||||
override val isCachingEnabled: Boolean = true
|
||||
|
||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
||||
detailsCache[ContentCache.Key(source, url)] = details
|
||||
}
|
||||
|
||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
||||
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
||||
pagesCache[ContentCache.Key(source, url)] = pages
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
|
||||
override fun onLowMemory() = Unit
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
trimCache(detailsCache, level)
|
||||
trimCache(pagesCache, level)
|
||||
}
|
||||
|
||||
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
|
||||
when (level) {
|
||||
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
|
||||
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
|
||||
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
|
||||
|
||||
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
|
||||
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
|
||||
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
|
||||
|
||||
else -> cache.trimToSize(cache.maxSize / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/SafeDeferred.kt
vendored
Normal file
20
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/SafeDeferred.kt
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
class SafeDeferred<T>(
|
||||
private val delegate: Deferred<Result<T>>,
|
||||
) {
|
||||
|
||||
suspend fun await(): T {
|
||||
return delegate.await().getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun awaitOrNull(): T? {
|
||||
return delegate.await().getOrNull()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
delegate.cancel()
|
||||
}
|
||||
}
|
||||
18
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt
vendored
Normal file
18
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class StubContentCache : ContentCache {
|
||||
|
||||
override val isCachingEnabled: Boolean = false
|
||||
|
||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
|
||||
|
||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
|
||||
|
||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
|
||||
|
||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.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, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
|
||||
arrayOf(
|
||||
System.currentTimeMillis(),
|
||||
1,
|
||||
resources.getString(R.string.read_later),
|
||||
SortOrder.NEWEST.name,
|
||||
1,
|
||||
1,
|
||||
0L,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
|
||||
import org.koitharu.kotatsu.core.db.dao.MangaDao
|
||||
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
|
||||
import org.koitharu.kotatsu.core.db.dao.TagsDao
|
||||
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration10To11
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration11To12
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration12To13
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration13To14
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration5To6
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration6To7
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration7To8
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration8To9
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration9To10
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 15
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||
ScrobblingEntity::class,
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
)
|
||||
abstract class MangaDatabase : RoomDatabase() {
|
||||
|
||||
abstract val historyDao: HistoryDao
|
||||
|
||||
abstract val tagsDao: TagsDao
|
||||
|
||||
abstract val mangaDao: MangaDao
|
||||
|
||||
abstract val favouritesDao: FavouritesDao
|
||||
|
||||
abstract val preferencesDao: PreferencesDao
|
||||
|
||||
abstract val favouriteCategoriesDao: FavouriteCategoriesDao
|
||||
|
||||
abstract val tracksDao: TracksDao
|
||||
|
||||
abstract val trackLogsDao: TrackLogsDao
|
||||
|
||||
abstract val suggestionDao: SuggestionDao
|
||||
|
||||
abstract val bookmarksDao: BookmarksDao
|
||||
|
||||
abstract val scrobblingDao: ScrobblingDao
|
||||
}
|
||||
|
||||
val databaseMigrations: Array<Migration>
|
||||
get() = arrayOf(
|
||||
Migration1To2(),
|
||||
Migration2To3(),
|
||||
Migration3To4(),
|
||||
Migration4To5(),
|
||||
Migration5To6(),
|
||||
Migration6To7(),
|
||||
Migration7To8(),
|
||||
Migration8To9(),
|
||||
Migration9To10(),
|
||||
Migration10To11(),
|
||||
Migration11To12(),
|
||||
Migration12To13(),
|
||||
Migration13To14(),
|
||||
Migration14To15(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
.databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db")
|
||||
.addMigrations(*databaseMigrations)
|
||||
.addCallback(DatabasePrePopulateCallback(context.resources))
|
||||
.build()
|
||||
|
||||
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
|
||||
val scope = processLifecycleScope
|
||||
if (scope.isActive) {
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
const val TABLE_FAVOURITES = "favourites"
|
||||
const val TABLE_MANGA = "manga"
|
||||
const val TABLE_TAGS = "tags"
|
||||
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
|
||||
const val TABLE_HISTORY = "history"
|
||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.*
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
|
||||
@Dao
|
||||
abstract class MangaDao {
|
||||
|
||||
@Transaction
|
||||
@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) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) 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 AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) 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
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun update(manga: MangaEntity): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insertTagRelation(tag: MangaTagsEntity): Long
|
||||
|
||||
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
|
||||
abstract suspend fun clearTagRelation(mangaId: Long)
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||
if (update(manga) <= 0) {
|
||||
insert(manga)
|
||||
if (tags != null) {
|
||||
clearTagRelation(manga.id)
|
||||
tags.map {
|
||||
MangaTagsEntity(manga.id, it.id)
|
||||
}.forEach {
|
||||
insertTagRelation(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
|
||||
@Dao
|
||||
abstract class PreferencesDao {
|
||||
|
||||
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
||||
abstract suspend fun find(mangaId: Long): MangaPrefsEntity?
|
||||
|
||||
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
||||
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(pref: MangaPrefsEntity)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.*
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
|
||||
@Dao
|
||||
abstract class TagsDao {
|
||||
|
||||
@Query("SELECT * FROM tags WHERE source = :source")
|
||||
abstract suspend fun findTags(source: String): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source AND title LIKE :query
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findTags(source: String, query: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE title LIKE :query
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(tags: Iterable<TagEntity>)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||
|
||||
@Dao
|
||||
interface TrackLogsDao {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
||||
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
||||
|
||||
@Query("DELETE FROM track_logs")
|
||||
suspend fun clear()
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: TrackLogEntity): Long
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
|
||||
suspend fun removeAll(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||
suspend fun gc()
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_logs")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
|
||||
// Entity to model
|
||||
|
||||
fun TagEntity.toMangaTag() = MangaTag(
|
||||
key = this.key,
|
||||
title = this.title.toTitleCase(),
|
||||
source = MangaSource(this.source),
|
||||
)
|
||||
|
||||
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
|
||||
|
||||
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
state = this.state?.let { MangaState(it) },
|
||||
rating = this.rating,
|
||||
isNsfw = this.isNsfw,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
author = this.author,
|
||||
source = MangaSource(this.source),
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
// Model to entity
|
||||
|
||||
fun Manga.toEntity() = MangaEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
source = source.name,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
coverUrl = coverUrl,
|
||||
altTitle = altTitle,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
state = state?.name,
|
||||
title = title,
|
||||
author = author,
|
||||
)
|
||||
|
||||
fun MangaTag.toEntity() = TagEntity(
|
||||
title = title,
|
||||
key = key,
|
||||
source = source.name,
|
||||
id = "${key}_${source.name}".longHashCode(),
|
||||
)
|
||||
|
||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||
|
||||
// Other
|
||||
|
||||
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
SortOrder.valueOf(name)
|
||||
}.getOrDefault(fallback)
|
||||
|
||||
fun MangaState(name: String): MangaState? = runCatching {
|
||||
MangaState.valueOf(name)
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA
|
||||
|
||||
@Entity(tableName = TABLE_MANGA)
|
||||
data class MangaEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||
@ColumnInfo(name = "state") val state: String?,
|
||||
@ColumnInfo(name = "author") val author: String?,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "preferences",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
data class MangaPrefsEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id")
|
||||
val mangaId: Long,
|
||||
@ColumnInfo(name = "mode") val mode: Int,
|
||||
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
|
||||
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
|
||||
|
||||
@Entity(
|
||||
tableName = TABLE_MANGA_TAGS,
|
||||
primaryKeys = ["manga_id", "tag_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = TagEntity::class,
|
||||
parentColumns = ["tag_id"],
|
||||
childColumns = ["tag_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
]
|
||||
)
|
||||
class MangaTagsEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "tag_id", index = true) val tagId: Long,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
|
||||
class MangaWithTags(
|
||||
@Embedded val manga: MangaEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as MangaWithTags
|
||||
|
||||
if (manga != other.manga) return false
|
||||
if (tags != other.tags) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = manga.hashCode()
|
||||
result = 31 * result + tags.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.core.db.TABLE_TAGS
|
||||
|
||||
@Entity(tableName = TABLE_TAGS)
|
||||
data class TagEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "tag_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "key") val key: String,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration10To11 : Migration(10, 11) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `bookmarks` (
|
||||
`manga_id` INTEGER NOT NULL,
|
||||
`page_id` INTEGER NOT NULL,
|
||||
`chapter_id` INTEGER NOT NULL,
|
||||
`page` INTEGER NOT NULL,
|
||||
`scroll` INTEGER NOT NULL,
|
||||
`image` TEXT NOT NULL,
|
||||
`created_at` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`manga_id`, `page_id`),
|
||||
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
|
||||
""".trimIndent()
|
||||
)
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration11To12 : Migration(11, 12) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `scrobblings` (
|
||||
`scrobbler` INTEGER NOT NULL,
|
||||
`id` INTEGER NOT NULL,
|
||||
`manga_id` INTEGER NOT NULL,
|
||||
`target_id` INTEGER NOT NULL,
|
||||
`status` TEXT,
|
||||
`chapter` INTEGER NOT NULL,
|
||||
`comment` TEXT,
|
||||
`rating` REAL NOT NULL,
|
||||
PRIMARY KEY(`scrobbler`, `id`, `manga_id`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
||||
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration12To13 : Migration(12, 13) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
|
||||
database.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration13To14 : Migration(13, 14) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration14To15 : Migration(14, 15) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration1To2 : Migration(1, 2) {
|
||||
/**
|
||||
* Adding foreign keys
|
||||
*/
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
/* manga_tags */
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(manga_id, tag_id), " +
|
||||
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
|
||||
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
|
||||
database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
|
||||
database.execSQL("DROP TABLE manga_tags")
|
||||
database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
|
||||
/* favourites */
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(manga_id, category_id), " +
|
||||
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
|
||||
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
|
||||
database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
|
||||
database.execSQL("DROP TABLE favourites")
|
||||
database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
|
||||
/* history */
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(manga_id), " +
|
||||
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
database.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
|
||||
database.execSQL("DROP TABLE history")
|
||||
database.execSQL("ALTER TABLE history_tmp RENAME TO history")
|
||||
/* preferences */
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
|
||||
" PRIMARY KEY(manga_id), " +
|
||||
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
|
||||
database.execSQL("DROP TABLE preferences")
|
||||
database.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration2To3 : Migration(2, 3) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration3To4 : Migration(3, 4) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration4To5 : Migration(4, 5) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration5To6 : Migration(5, 6) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration6To7 : Migration(6, 7) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration7To8 : Migration(7, 8) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
|
||||
}
|
||||
}
|
||||
@@ -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.parsers.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}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration9To10 : Migration(9, 10) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
class CaughtException(cause: Throwable, override val message: String?) : RuntimeException(cause)
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import okhttp3.Headers
|
||||
import okio.IOException
|
||||
|
||||
class CloudFlareProtectedException(
|
||||
val url: String,
|
||||
@Transient val headers: Headers,
|
||||
) : IOException("Protected by CloudFlare")
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
|
||||
class CompositeException(val errors: Collection<Throwable>) : Exception() {
|
||||
|
||||
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
class EmptyHistoryException : RuntimeException()
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
class SyncApiException(
|
||||
message: String,
|
||||
val code: Int,
|
||||
) : RuntimeException(message)
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
class UnsupportedFileException(message: String? = null) : IOException(message)
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
class WrongPasswordException : IllegalArgumentException()
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.view.View
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
|
||||
class DialogErrorObserver(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
resolver: ExceptionResolver?,
|
||||
private val onResolved: Consumer<Boolean>?,
|
||||
) : ErrorObserver(host, fragment, resolver, onResolved) {
|
||||
|
||||
constructor(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
) : this(host, fragment, null, null)
|
||||
|
||||
override fun onChanged(value: Throwable?) {
|
||||
if (value == null) {
|
||||
return
|
||||
}
|
||||
val listener = DialogListener(value)
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context)
|
||||
.setMessage(value.getDisplayMessage(host.context.resources))
|
||||
.setNegativeButton(R.string.close, listener)
|
||||
.setOnCancelListener(listener)
|
||||
if (canResolve(value)) {
|
||||
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
||||
} else if (value is ParseException) {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
val dialog = dialogBuilder.create()
|
||||
if (activity != null) {
|
||||
dialog.setOwnerActivity(activity)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private inner class DialogListener(
|
||||
private val error: Throwable,
|
||||
) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
|
||||
|
||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_NEGATIVE -> onResolved?.accept(false)
|
||||
DialogInterface.BUTTON_POSITIVE -> resolve(error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface?) {
|
||||
onResolved?.accept(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
|
||||
abstract class ErrorObserver(
|
||||
protected val host: View,
|
||||
protected val fragment: Fragment?,
|
||||
private val resolver: ExceptionResolver?,
|
||||
private val onResolved: Consumer<Boolean>?,
|
||||
) : Observer<Throwable?> {
|
||||
|
||||
protected val activity = host.context.findActivity()
|
||||
|
||||
private val lifecycleScope: LifecycleCoroutineScope
|
||||
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
|
||||
|
||||
protected val fragmentManager: FragmentManager?
|
||||
get() = fragment?.childFragmentManager ?: (activity as? AppCompatActivity)?.supportFragmentManager
|
||||
|
||||
protected fun canResolve(error: Throwable): Boolean {
|
||||
return resolver != null && ExceptionResolver.canResolve(error)
|
||||
}
|
||||
|
||||
protected fun resolve(error: Throwable) {
|
||||
lifecycleScope.launch {
|
||||
val isResolved = resolver?.resolve(error) ?: false
|
||||
if (isActive) {
|
||||
onResolved?.accept(isResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Headers
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.isSuccess
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver private constructor(
|
||||
private val activity: FragmentActivity?,
|
||||
private val fragment: Fragment?,
|
||||
) : ActivityResultCallback<TaggedActivityResult> {
|
||||
|
||||
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
|
||||
private lateinit var sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||
|
||||
constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) {
|
||||
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||
}
|
||||
|
||||
constructor(fragment: Fragment) : this(activity = null, fragment = fragment) {
|
||||
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: TaggedActivityResult) {
|
||||
continuations.remove(result.tag)?.resume(result.isSuccess)
|
||||
}
|
||||
|
||||
fun showDetails(e: Throwable, url: String?) {
|
||||
ErrorDetailsDialog.show(getFragmentManager(), e, url)
|
||||
}
|
||||
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private suspend fun resolveCF(url: String, headers: Headers): Boolean {
|
||||
val dialog = CloudFlareDialog.newInstance(url, headers)
|
||||
val fm = getFragmentManager()
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
fm.clearFragmentResult(CloudFlareDialog.TAG)
|
||||
continuations[CloudFlareDialog.TAG] = cont
|
||||
fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result ->
|
||||
continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT))
|
||||
}
|
||||
dialog.show(fm, CloudFlareDialog.TAG)
|
||||
cont.invokeOnCancellation {
|
||||
continuations.remove(CloudFlareDialog.TAG, cont)
|
||||
fm.clearFragmentResultListener(CloudFlareDialog.TAG)
|
||||
dialog.dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(BrowserActivity.newIntent(context, url, null))
|
||||
}
|
||||
|
||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||
|
||||
companion object {
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
else -> 0
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
|
||||
class SnackbarErrorObserver(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
resolver: ExceptionResolver?,
|
||||
onResolved: Consumer<Boolean>?,
|
||||
) : ErrorObserver(host, fragment, resolver, onResolved) {
|
||||
|
||||
constructor(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
) : this(host, fragment, null, null)
|
||||
|
||||
override fun onChanged(value: Throwable?) {
|
||||
if (value == null) {
|
||||
return
|
||||
}
|
||||
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
||||
if (activity is BottomNavOwner) {
|
||||
snackbar.anchorView = activity.bottomNav
|
||||
}
|
||||
if (canResolve(value)) {
|
||||
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
|
||||
resolve(value)
|
||||
}
|
||||
} else if (value is ParseException) {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
snackbar.setAction(R.string.details) {
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.koitharu.kotatsu.core.github
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
|
||||
import org.koitharu.kotatsu.parsers.util.parseJsonArray
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
|
||||
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
|
||||
|
||||
@Singleton
|
||||
class AppUpdateRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
@BaseHttpClient private val okHttp: OkHttpClient,
|
||||
) {
|
||||
|
||||
private val availableUpdate = MutableStateFlow<AppVersion?>(null)
|
||||
|
||||
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
|
||||
|
||||
suspend fun getAvailableVersions(): List<AppVersion> {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10")
|
||||
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
|
||||
return jsonArray.mapJSONNotNull { json ->
|
||||
val asset = json.optJSONArray("assets")?.find { jo ->
|
||||
jo.optString("content_type") == CONTENT_TYPE_APK
|
||||
} ?: return@mapJSONNotNull null
|
||||
AppVersion(
|
||||
id = json.getLong("id"),
|
||||
url = json.getString("html_url"),
|
||||
name = json.getString("name").removePrefix("v"),
|
||||
apkSize = asset.getLong("size"),
|
||||
apkUrl = asset.getString("browser_download_url"),
|
||||
description = json.getString("body"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchUpdate(): AppVersion? = withContext(Dispatchers.Default) {
|
||||
if (!isUpdateSupported()) {
|
||||
return@withContext null
|
||||
}
|
||||
runCatchingCancellable {
|
||||
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
|
||||
val available = getAvailableVersions().asArrayList()
|
||||
available.sortBy { it.versionId }
|
||||
if (currentVersion.isStable && !settings.isUnstableUpdatesAllowed) {
|
||||
available.retainAll { it.versionId.isStable }
|
||||
}
|
||||
available.maxByOrNull { it.versionId }
|
||||
?.takeIf { it.versionId > currentVersion }
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.onSuccess {
|
||||
availableUpdate.value = it
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun isUpdateSupported(): Boolean {
|
||||
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
|
||||
}
|
||||
|
||||
suspend fun getCurrentVersionChangelog(): String? {
|
||||
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
|
||||
val available = getAvailableVersions()
|
||||
return available.find { x -> x.versionId == currentVersion }?.description
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
|
||||
val signatures = requireNotNull(packageInfo?.signatures)
|
||||
val cert: ByteArray = signatures.first().toByteArray()
|
||||
val input: InputStream = ByteArrayInputStream(cert)
|
||||
val cf = CertificateFactory.getInstance("X509")
|
||||
val c = cf.generateCertificate(input) as X509Certificate
|
||||
val md: MessageDigest = MessageDigest.getInstance("SHA1")
|
||||
val publicKey: ByteArray = md.digest(c.encoded)
|
||||
return publicKey.byte2HexFormatted()
|
||||
}.onFailure { error ->
|
||||
error.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
|
||||
val size = length()
|
||||
for (i in 0 until size) {
|
||||
val jo = getJSONObject(i)
|
||||
if (predicate(jo)) {
|
||||
return jo
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.core.github
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class AppVersion(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val url: String,
|
||||
val apkSize: Long,
|
||||
val apkUrl: String,
|
||||
val description: String,
|
||||
) : Parcelable {
|
||||
|
||||
@IgnoredOnParcel
|
||||
val versionId = VersionId(name)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.koitharu.kotatsu.core.github
|
||||
|
||||
import java.util.*
|
||||
|
||||
class VersionId(
|
||||
val major: Int,
|
||||
val minor: Int,
|
||||
val build: Int,
|
||||
val variantType: String,
|
||||
val variantNumber: Int,
|
||||
) : Comparable<VersionId> {
|
||||
|
||||
override fun compareTo(other: VersionId): Int {
|
||||
var diff = major.compareTo(other.major)
|
||||
if (diff != 0) {
|
||||
return diff
|
||||
}
|
||||
diff = minor.compareTo(other.minor)
|
||||
if (diff != 0) {
|
||||
return diff
|
||||
}
|
||||
diff = build.compareTo(other.build)
|
||||
if (diff != 0) {
|
||||
return diff
|
||||
}
|
||||
diff = variantWeight(variantType).compareTo(variantWeight(other.variantType))
|
||||
if (diff != 0) {
|
||||
return diff
|
||||
}
|
||||
return variantNumber.compareTo(other.variantNumber)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as VersionId
|
||||
|
||||
if (major != other.major) return false
|
||||
if (minor != other.minor) return false
|
||||
if (build != other.build) return false
|
||||
if (variantType != other.variantType) return false
|
||||
if (variantNumber != other.variantNumber) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = major
|
||||
result = 31 * result + minor
|
||||
result = 31 * result + build
|
||||
result = 31 * result + variantType.hashCode()
|
||||
result = 31 * result + variantNumber
|
||||
return result
|
||||
}
|
||||
|
||||
private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
|
||||
"a", "alpha" -> 1
|
||||
"b", "beta" -> 2
|
||||
"rc" -> 4
|
||||
"" -> 8
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
val VersionId.isStable: Boolean
|
||||
get() = variantType.isEmpty()
|
||||
|
||||
fun VersionId(versionName: String): VersionId {
|
||||
val parts = versionName.substringBeforeLast('-').split('.')
|
||||
val variant = versionName.substringAfterLast('-', "")
|
||||
return VersionId(
|
||||
major = parts.getOrNull(0)?.toIntOrNull() ?: 0,
|
||||
minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
|
||||
build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
|
||||
variantType = variant.filter(Char::isLetter),
|
||||
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
151
app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt
Normal file
151
app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt
Normal file
@@ -0,0 +1,151 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.subdir
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
private const val DIR = "logs"
|
||||
private const val FLUSH_DELAY = 2_000L
|
||||
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
||||
|
||||
class FileLogger(
|
||||
context: Context,
|
||||
private val settings: AppSettings,
|
||||
name: String,
|
||||
) {
|
||||
|
||||
val file by lazy {
|
||||
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
||||
File(dir, "$name.log")
|
||||
}
|
||||
val isEnabled: Boolean
|
||||
get() = settings.isLoggingEnabled
|
||||
private val dateFormat = SimpleDateFormat.getDateTimeInstance(
|
||||
SimpleDateFormat.SHORT,
|
||||
SimpleDateFormat.SHORT,
|
||||
Locale.ROOT,
|
||||
)
|
||||
private val buffer = ConcurrentLinkedQueue<String>()
|
||||
private val mutex = Mutex()
|
||||
private var flushJob: Job? = null
|
||||
|
||||
fun log(message: String, e: Throwable? = null) {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
val text = buildString {
|
||||
append(dateFormat.format(Date()))
|
||||
append(": ")
|
||||
if (e != null) {
|
||||
append("E!")
|
||||
}
|
||||
append(message)
|
||||
if (e != null) {
|
||||
append(' ')
|
||||
append(e.stackTraceToString())
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
buffer.add(text)
|
||||
postFlush()
|
||||
}
|
||||
|
||||
inline fun log(messageProducer: () -> String) {
|
||||
if (isEnabled) {
|
||||
log(messageProducer())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun flush() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
flushJob?.cancelAndJoin()
|
||||
flushImpl()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun flushBlocking() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
runBlockingSafe { flushJob?.cancelAndJoin() }
|
||||
runBlockingSafe { flushImpl() }
|
||||
}
|
||||
|
||||
private fun postFlush() {
|
||||
if (flushJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
delay(FLUSH_DELAY)
|
||||
runCatchingCancellable {
|
||||
flushImpl()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun flushImpl() = withContext(NonCancellable) {
|
||||
mutex.withLock {
|
||||
if (buffer.isEmpty()) {
|
||||
return@withContext
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
if (file.length() > MAX_SIZE_BYTES) {
|
||||
rotate()
|
||||
}
|
||||
FileOutputStream(file, true).use {
|
||||
while (true) {
|
||||
val message = buffer.poll() ?: break
|
||||
it.write(message.toByteArray())
|
||||
it.write('\n'.code)
|
||||
}
|
||||
it.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun rotate() {
|
||||
val length = file.length()
|
||||
val bakFile = File(file.parentFile, file.name + ".bak")
|
||||
file.renameTo(bakFile)
|
||||
bakFile.inputStream().use { input ->
|
||||
input.skip(length - MAX_SIZE_BYTES / 2)
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
bakFile.delete()
|
||||
}
|
||||
|
||||
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
||||
runBlocking(NonCancellable) { block() }
|
||||
} catch (_: InterruptedException) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class TrackerLogger
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncLogger
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.collection.arraySetOf
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ElementsIntoSet
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object LoggersModule {
|
||||
|
||||
@Provides
|
||||
@TrackerLogger
|
||||
fun provideTrackerLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "tracker")
|
||||
|
||||
@Provides
|
||||
@SyncLogger
|
||||
fun provideSyncLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "sync")
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
fun provideAllLoggers(
|
||||
@TrackerLogger trackerLogger: FileLogger,
|
||||
@SyncLogger syncLogger: FileLogger,
|
||||
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
||||
trackerLogger,
|
||||
syncLogger,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.*
|
||||
|
||||
@Parcelize
|
||||
data class FavouriteCategory(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val sortKey: Int,
|
||||
val order: SortOrder,
|
||||
val createdAt: Date,
|
||||
val isTrackingEnabled: Boolean,
|
||||
val isVisibleInLibrary: Boolean,
|
||||
) : Parcelable
|
||||
56
app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt
Normal file
56
app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
|
||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||
|
||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||
if (size <= 1) {
|
||||
return size
|
||||
}
|
||||
val acc = HashMap<String?, Int>()
|
||||
for (item in this) {
|
||||
val branch = item.chapter.branch
|
||||
acc[branch] = (acc[branch] ?: 0) + 1
|
||||
}
|
||||
return acc.values.max()
|
||||
}
|
||||
|
||||
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
val ch = chapters
|
||||
if (ch.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
if (history != null) {
|
||||
val currentChapter = ch.find { it.id == history.chapterId }
|
||||
if (currentChapter != null) {
|
||||
return currentChapter.branch
|
||||
}
|
||||
}
|
||||
val groups = ch.groupBy { it.branch }
|
||||
if (groups.size == 1) {
|
||||
return groups.keys.first()
|
||||
}
|
||||
val candidates = HashMap<String?, List<MangaChapter>>(groups.size)
|
||||
for (locale in LocaleListCompat.getAdjustedDefault()) {
|
||||
val displayLanguage = locale.getDisplayLanguage(locale)
|
||||
val displayName = locale.getDisplayName(locale)
|
||||
for (branch in groups.keys) {
|
||||
if (branch != null && (
|
||||
branch.contains(displayLanguage, ignoreCase = true) ||
|
||||
branch.contains(displayName, ignoreCase = true)
|
||||
)
|
||||
) {
|
||||
candidates[branch] = groups[branch] ?: continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
|
||||
@Parcelize
|
||||
data class MangaHistory(
|
||||
val createdAt: Date,
|
||||
val updatedAt: Date,
|
||||
val chapterId: Long,
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
val percent: Float,
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.util.Locale
|
||||
|
||||
fun MangaSource.getLocaleTitle(): String? {
|
||||
val lc = Locale(locale ?: return null)
|
||||
return lc.getDisplayLanguage(lc).toTitleCase(lc)
|
||||
}
|
||||
|
||||
fun MangaSource(name: String): MangaSource {
|
||||
MangaSource.values().forEach {
|
||||
if (it.name == name) return it
|
||||
}
|
||||
return MangaSource.DUMMY
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
enum class ZoomMode {
|
||||
|
||||
FIT_CENTER, FIT_HEIGHT, FIT_WIDTH, KEEP_START
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
|
||||
out.writeLong(id)
|
||||
out.writeString(title)
|
||||
out.writeString(altTitle)
|
||||
out.writeString(url)
|
||||
out.writeString(publicUrl)
|
||||
out.writeFloat(rating)
|
||||
ParcelCompat.writeBoolean(out, isNsfw)
|
||||
out.writeString(coverUrl)
|
||||
out.writeString(largeCoverUrl)
|
||||
out.writeString(description)
|
||||
out.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||
out.writeSerializable(state)
|
||||
out.writeString(author)
|
||||
if (withChapters) {
|
||||
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
||||
} else {
|
||||
out.writeString(null)
|
||||
}
|
||||
out.writeSerializable(source)
|
||||
}
|
||||
|
||||
fun Parcel.readManga() = Manga(
|
||||
id = readLong(),
|
||||
title = requireNotNull(readString()),
|
||||
altTitle = readString(),
|
||||
url = requireNotNull(readString()),
|
||||
publicUrl = requireNotNull(readString()),
|
||||
rating = readFloat(),
|
||||
isNsfw = ParcelCompat.readBoolean(this),
|
||||
coverUrl = requireNotNull(readString()),
|
||||
largeCoverUrl = readString(),
|
||||
description = readString(),
|
||||
tags = requireNotNull(readParcelableCompat<ParcelableMangaTags>()).tags,
|
||||
state = readSerializableCompat(),
|
||||
author = readString(),
|
||||
chapters = readParcelableCompat<ParcelableMangaChapters>()?.chapters,
|
||||
source = checkNotNull(readSerializableCompat()),
|
||||
)
|
||||
|
||||
fun MangaPage.writeToParcel(out: Parcel) {
|
||||
out.writeLong(id)
|
||||
out.writeString(url)
|
||||
out.writeString(preview)
|
||||
out.writeSerializable(source)
|
||||
}
|
||||
|
||||
fun Parcel.readMangaPage() = MangaPage(
|
||||
id = readLong(),
|
||||
url = requireNotNull(readString()),
|
||||
preview = readString(),
|
||||
source = checkNotNull(readSerializableCompat()),
|
||||
)
|
||||
|
||||
fun MangaChapter.writeToParcel(out: Parcel) {
|
||||
out.writeLong(id)
|
||||
out.writeString(name)
|
||||
out.writeInt(number)
|
||||
out.writeString(url)
|
||||
out.writeString(scanlator)
|
||||
out.writeLong(uploadDate)
|
||||
out.writeString(branch)
|
||||
out.writeSerializable(source)
|
||||
}
|
||||
|
||||
fun Parcel.readMangaChapter() = MangaChapter(
|
||||
id = readLong(),
|
||||
name = requireNotNull(readString()),
|
||||
number = readInt(),
|
||||
url = requireNotNull(readString()),
|
||||
scanlator = readString(),
|
||||
uploadDate = readLong(),
|
||||
branch = readString(),
|
||||
source = checkNotNull(readSerializableCompat()),
|
||||
)
|
||||
|
||||
fun MangaTag.writeToParcel(out: Parcel) {
|
||||
out.writeString(title)
|
||||
out.writeString(key)
|
||||
out.writeSerializable(source)
|
||||
}
|
||||
|
||||
fun Parcel.readMangaTag() = MangaTag(
|
||||
title = requireNotNull(readString()),
|
||||
key = requireNotNull(readString()),
|
||||
source = checkNotNull(readSerializableCompat()),
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
// Limits to avoid TransactionTooLargeException
|
||||
private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
|
||||
private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
|
||||
|
||||
class ParcelableManga(
|
||||
val manga: Manga,
|
||||
private val withChapters: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(parcel.readManga(), true)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
val chapters = manga.chapters
|
||||
if (!withChapters || chapters == null) {
|
||||
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||
return
|
||||
}
|
||||
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
|
||||
// fast path
|
||||
manga.writeToParcel(parcel, flags, withChapters = true)
|
||||
return
|
||||
}
|
||||
val tempParcel = Parcel.obtain()
|
||||
manga.writeToParcel(tempParcel, flags, withChapters = true)
|
||||
val size = tempParcel.dataSize()
|
||||
if (size < MAX_SAFE_SIZE) {
|
||||
parcel.appendFrom(tempParcel, 0, size)
|
||||
} else {
|
||||
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||
}
|
||||
tempParcel.recycle()
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ParcelableManga> {
|
||||
override fun createFromParcel(parcel: Parcel): ParcelableManga {
|
||||
return ParcelableManga(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<ParcelableManga?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
|
||||
class ParcelableMangaChapters(
|
||||
val chapters: List<MangaChapter>,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
List(parcel.readInt()) { parcel.readMangaChapter() }
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeInt(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
chapter.writeToParcel(parcel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> {
|
||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters {
|
||||
return ParcelableMangaChapters(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<ParcelableMangaChapters?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
|
||||
class ParcelableMangaPages(
|
||||
val pages: List<MangaPage>,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
List(parcel.readInt()) { parcel.readMangaPage() }
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeInt(pages.size)
|
||||
for (page in pages) {
|
||||
page.writeToParcel(parcel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
|
||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
|
||||
return ParcelableMangaPages(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<ParcelableMangaPages?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import org.koitharu.kotatsu.core.util.ext.Set
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
class ParcelableMangaTags(
|
||||
val tags: Set<MangaTag>,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
Set(parcel.readInt()) { parcel.readMangaTag() },
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeInt(tags.size)
|
||||
for (tag in tags) {
|
||||
tag.writeToParcel(parcel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> {
|
||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaTags {
|
||||
return ParcelableMangaTags(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<ParcelableMangaTags?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.ProxySelector
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
|
||||
class AppProxySelector(
|
||||
private val settings: AppSettings,
|
||||
) : ProxySelector() {
|
||||
|
||||
private var cachedProxy: Proxy? = null
|
||||
|
||||
override fun select(uri: URI?): List<Proxy> {
|
||||
return listOf(getProxy())
|
||||
}
|
||||
|
||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
||||
ioe?.printStackTraceDebug()
|
||||
}
|
||||
|
||||
private fun getProxy(): Proxy {
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) {
|
||||
return Proxy.NO_PROXY
|
||||
}
|
||||
cachedProxy?.let {
|
||||
val addr = it.address() as? InetSocketAddress
|
||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||
return it
|
||||
}
|
||||
}
|
||||
val proxy = Proxy(type, InetSocketAddress(address, port))
|
||||
cachedProxy = proxy
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class CacheLimitInterceptor : Interceptor {
|
||||
|
||||
private val defaultMaxAge = TimeUnit.HOURS.toSeconds(1)
|
||||
private val defaultCacheControl = CacheControl.Builder()
|
||||
.maxAge(defaultMaxAge.toInt(), TimeUnit.SECONDS)
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
val responseCacheControl = CacheControl.parse(response.headers)
|
||||
if (responseCacheControl.noStore || responseCacheControl.maxAgeSeconds <= defaultMaxAge) {
|
||||
return response
|
||||
}
|
||||
return response.newBuilder()
|
||||
.header(CommonHeaders.CACHE_CONTROL, defaultCacheControl)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
||||
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
||||
|
||||
private const val HEADER_SERVER = "Server"
|
||||
private const val SERVER_CLOUDFLARE = "cloudflare"
|
||||
|
||||
class CloudFlareInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
||||
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
|
||||
response.closeQuietly()
|
||||
throw CloudFlareProtectedException(
|
||||
url = response.request.url.toString(),
|
||||
headers = request.headers,
|
||||
)
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
|
||||
object CommonHeaders {
|
||||
|
||||
const val REFERER = "Referer"
|
||||
const val USER_AGENT = "User-Agent"
|
||||
const val ACCEPT = "Accept"
|
||||
const val CONTENT_TYPE = "Content-Type"
|
||||
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||
const val COOKIE = "Cookie"
|
||||
const val CONTENT_ENCODING = "Content-Encoding"
|
||||
const val ACCEPT_ENCODING = "Accept-Encoding"
|
||||
const val AUTHORIZATION = "Authorization"
|
||||
const val CACHE_CONTROL = "Cache-Control"
|
||||
|
||||
val CACHE_CONTROL_NO_STORE: CacheControl
|
||||
get() = CacheControl.Builder().noStore().build()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import dagger.Lazy
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.net.IDN
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CommonHeadersInterceptor @Inject constructor(
|
||||
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val source = request.tag(MangaSource::class.java)
|
||||
val repository = if (source != null) {
|
||||
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("Http", "Request without source tag: ${request.url}")
|
||||
}
|
||||
null
|
||||
}
|
||||
val headersBuilder = request.headers.newBuilder()
|
||||
repository?.headers?.let {
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
|
||||
val idn = IDN.toASCII(repository.domain)
|
||||
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
|
||||
}
|
||||
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
||||
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
||||
set(name, value)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
|
||||
private class ProxyChain(
|
||||
private val delegate: Interceptor.Chain,
|
||||
private val request: Request,
|
||||
) : Interceptor.Chain by delegate {
|
||||
|
||||
override fun request(): Request = request
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val userAgentFallback
|
||||
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
|
||||
BuildConfig.VERSION_NAME,
|
||||
Build.VERSION.RELEASE,
|
||||
Build.MODEL,
|
||||
Build.BRAND,
|
||||
Build.DEVICE,
|
||||
Locale.getDefault().language,
|
||||
)
|
||||
|
||||
val userAgentChrome
|
||||
get() = (
|
||||
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||
).format(
|
||||
Build.VERSION.RELEASE,
|
||||
Build.MODEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.dnsoverhttps.DnsOverHttps
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.net.InetAddress
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class DoHManager(
|
||||
cache: Cache,
|
||||
private val settings: AppSettings,
|
||||
) : Dns {
|
||||
|
||||
private val bootstrapClient = OkHttpClient.Builder().cache(cache).build()
|
||||
|
||||
private var cachedDelegate: Dns? = null
|
||||
private var cachedProvider: DoHProvider? = null
|
||||
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
return try {
|
||||
getDelegate().lookup(hostname)
|
||||
} catch (e: UnknownHostException) {
|
||||
// fallback
|
||||
Dns.SYSTEM.lookup(hostname)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getDelegate(): Dns {
|
||||
var delegate = cachedDelegate
|
||||
val provider = settings.dnsOverHttps
|
||||
if (delegate == null || provider != cachedProvider) {
|
||||
delegate = createDelegate(provider)
|
||||
cachedDelegate = delegate
|
||||
cachedProvider = provider
|
||||
}
|
||||
return delegate
|
||||
}
|
||||
|
||||
private fun createDelegate(provider: DoHProvider): Dns = when (provider) {
|
||||
DoHProvider.NONE -> Dns.SYSTEM
|
||||
DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://dns.google/dns-query".toHttpUrl())
|
||||
.resolvePrivateAddresses(true)
|
||||
.bootstrapDnsHosts(
|
||||
listOfNotNull(
|
||||
tryGetByIp("8.8.4.4"),
|
||||
tryGetByIp("8.8.8.8"),
|
||||
tryGetByIp("2001:4860:4860::8888"),
|
||||
tryGetByIp("2001:4860:4860::8844"),
|
||||
),
|
||||
).build()
|
||||
|
||||
DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
|
||||
.resolvePrivateAddresses(true)
|
||||
.bootstrapDnsHosts(
|
||||
listOfNotNull(
|
||||
tryGetByIp("162.159.36.1"),
|
||||
tryGetByIp("162.159.46.1"),
|
||||
tryGetByIp("1.1.1.1"),
|
||||
tryGetByIp("1.0.0.1"),
|
||||
tryGetByIp("162.159.132.53"),
|
||||
tryGetByIp("2606:4700:4700::1111"),
|
||||
tryGetByIp("2606:4700:4700::1001"),
|
||||
tryGetByIp("2606:4700:4700::0064"),
|
||||
tryGetByIp("2606:4700:4700::6400"),
|
||||
),
|
||||
).build()
|
||||
|
||||
DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
|
||||
.resolvePrivateAddresses(true)
|
||||
.bootstrapDnsHosts(
|
||||
listOfNotNull(
|
||||
tryGetByIp("94.140.14.140"),
|
||||
tryGetByIp("94.140.14.141"),
|
||||
tryGetByIp("2a10:50c0::1:ff"),
|
||||
tryGetByIp("2a10:50c0::2:ff"),
|
||||
),
|
||||
).build()
|
||||
}
|
||||
|
||||
private fun tryGetByIp(ip: String): InetAddress? = try {
|
||||
InetAddress.getByName(ip)
|
||||
} catch (e: UnknownHostException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
enum class DoHProvider {
|
||||
|
||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
|
||||
|
||||
class GZipInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val newRequest = chain.request().newBuilder()
|
||||
newRequest.addHeader(CONTENT_ENCODING, "gzip")
|
||||
return chain.proceed(newRequest.build())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user