Merge branch 'devel' into feature/sync

This commit is contained in:
Koitharu
2022-05-03 16:28:24 +03:00
53 changed files with 755 additions and 165 deletions

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 404
versionName '3.2'
versionCode 405
versionName '3.2.1'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -65,7 +65,7 @@ android {
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:72cd6fbadf') {
implementation('com.github.nv95:kotatsu-parsers:090ad4b256') {
exclude group: 'org.json', module: 'json'
}

View File

@@ -5,6 +5,7 @@ import android.os.Parcel
import android.os.Parcelable
import android.os.Parcelable.Creator
import android.util.AttributeSet
import android.view.View
import android.widget.Checkable
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
@@ -61,6 +62,12 @@ class CheckableImageView @JvmOverloads constructor(
}
}
class ToggleOnClickListener : OnClickListener {
override fun onClick(view: View) {
(view as? Checkable)?.toggle()
}
}
fun interface OnCheckedChangeListener {
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)

View File

@@ -11,10 +11,6 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
@@ -24,6 +20,10 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class AppSettings(context: Context) {
@@ -67,6 +67,10 @@ class AppSettings(context: Context) {
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
var isAllFavouritesVisible: Boolean
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
val isUpdateCheckingEnabled: Boolean
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
@@ -130,6 +134,20 @@ class AppSettings(context: Context) {
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
val newSources: Set<MangaSource>
get() {
val known = sourcesOrder.toSet()
val hidden = hiddenSources
return remoteMangaSources
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
x.name in known || x.name in hidden
}
}
fun markKnownSources(sources: Collection<MangaSource>) {
sourcesOrder = sourcesOrder + sources.map { it.name }
}
val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -278,6 +296,7 @@ class AppSettings(context: Context) {
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.download.domain
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.ConnectivityManager
import android.webkit.MimeTypeMap
import coil.ImageLoader
@@ -75,10 +74,12 @@ class DownloadManager(
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
@Suppress("NAME_SHADOWING") var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover)
localMangaRepository.lockManga(manga.id)
semaphore.acquire()
coroutineContext[WakeLockNode]?.acquire()
outState.value = DownloadState.Preparing(startId, manga, null)
var cover: Drawable? = null
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp"
@@ -88,16 +89,6 @@ class DownloadManager(
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
}
val repo = MangaRepository(manga.source)
cover = runCatching {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.referer(manga.publicUrl)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
).drawable
}.getOrNull()
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = CbzMangaOutput.get(destination, data)
@@ -176,6 +167,7 @@ class DownloadManager(
}
coroutineContext[WakeLockNode]?.release()
semaphore.release()
localMangaRepository.unlockManga(manga.id)
}
}
@@ -208,6 +200,17 @@ class DownloadManager(
)
}
private suspend fun loadCover(manga: Manga) = runCatching {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.referer(manga.publicUrl)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
).drawable
}.getOrNull()
class Factory(
private val context: Context,
private val imageLoader: ImageLoader,

View File

@@ -15,7 +15,7 @@ val favouritesModule
viewModel { categoryId ->
FavouritesListViewModel(categoryId.get(), get(), get(), get())
}
viewModel { FavouritesCategoriesViewModel(get()) }
viewModel { FavouritesCategoriesViewModel(get(), get()) }
viewModel { manga ->
MangaCategoriesViewModel(manga.get(), get())
}

View File

@@ -64,7 +64,7 @@ class FavouritesRepository(private val db: MangaDatabase) {
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0,
order = SortOrder.UPDATED.name,
order = SortOrder.NEWEST.name,
)
val id = db.favouriteCategoriesDao.insert(entity)
return entity.toFavouriteCategory(id)

View File

@@ -21,12 +21,11 @@ import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
import java.util.*
class FavouritesContainerFragment :
BaseFragment<FragmentFavouritesBinding>(),
@@ -53,15 +52,15 @@ class FavouritesContainerFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = FavouritesPagerAdapter(this, this)
viewModel.categories.value?.let {
adapter.replaceData(wrapCategories(it))
viewModel.visibleCategories.value?.let {
adapter.replaceData(it)
}
binding.pager.adapter = adapter
pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
actionModeDelegate.addListener(this, viewLifecycleOwner)
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
}
@@ -86,7 +85,8 @@ class FavouritesContainerFragment :
top = headerHeight - insets.top
)
binding.pager.updatePadding(
top = -headerHeight + resources.resolveDp(8) // 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active)
// 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active)
top = -headerHeight + resources.resolveDp(8)
)
binding.tabs.apply {
updatePadding(
@@ -99,8 +99,8 @@ class FavouritesContainerFragment :
}
}
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
pagerAdapter?.replaceData(wrapCategories(categories))
private fun onCategoriesChanged(categories: List<CategoryListModel>) {
pagerAdapter?.replaceData(categories)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -122,26 +122,11 @@ class FavouritesContainerFragment :
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
val menu = PopupMenu(tabView.context, tabView)
menu.inflate(menuRes)
createOrderSubmenu(menu.menu, category)
menu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_rename -> editDelegate.renameCategory(category)
R.id.action_create -> editDelegate.createCategory()
R.id.action_order -> return@setOnMenuItemClickListener false
else -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
?: return@setOnMenuItemClickListener false
viewModel.setCategoryOrder(category.id, order)
}
}
true
override fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean {
when (item) {
is CategoryListModel.All -> showAllCategoriesMenu(tabView)
is CategoryListModel.CategoryItem -> showCategoryMenu(tabView, item.category)
}
menu.show()
return true
}
@@ -157,13 +142,6 @@ class FavouritesContainerFragment :
viewModel.createCategory(name)
}
private fun wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> {
val data = ArrayList<FavouriteCategory>(categories.size + 1)
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date())
data += categories
return data
}
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
@@ -181,6 +159,40 @@ class FavouritesContainerFragment :
}
}
private fun showCategoryMenu(tabView: View, category: FavouriteCategory) {
val menu = PopupMenu(tabView.context, tabView)
menu.inflate(R.menu.popup_category)
createOrderSubmenu(menu.menu, category)
menu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_rename -> editDelegate.renameCategory(category)
R.id.action_create -> editDelegate.createCategory()
R.id.action_order -> return@setOnMenuItemClickListener false
else -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
?: return@setOnMenuItemClickListener false
viewModel.setCategoryOrder(category.id, order)
}
}
true
}
menu.show()
}
private fun showAllCategoriesMenu(tabView: View) {
val menu = PopupMenu(tabView.context, tabView)
menu.inflate(R.menu.popup_category_all)
menu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_create -> editDelegate.createCategory()
R.id.action_hide -> viewModel.setAllCategoriesVisible(false)
}
true
}
menu.show()
}
companion object {
fun newInstance() = FavouritesContainerFragment()

View File

@@ -7,14 +7,16 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
class FavouritesPagerAdapter(
fragment: Fragment,
private val longClickListener: FavouritesTabLongClickListener
) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
TabLayoutMediator.TabConfigurationStrategy, View.OnLongClickListener {
TabLayoutMediator.TabConfigurationStrategy,
View.OnLongClickListener {
private val differ = AsyncListDiffer(this, DiffCallback())
@@ -35,12 +37,15 @@ class FavouritesPagerAdapter(
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position]
tab.text = item.title
tab.text = when (item) {
is CategoryListModel.All -> tab.view.context.getString(R.string.all_favourites)
is CategoryListModel.CategoryItem -> item.category.title
}
tab.view.tag = item.id
tab.view.setOnLongClickListener(this)
}
fun replaceData(data: List<FavouriteCategory>) {
fun replaceData(data: List<CategoryListModel>) {
differ.submitList(data)
}
@@ -50,16 +55,22 @@ class FavouritesPagerAdapter(
return longClickListener.onTabLongClick(v, item)
}
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
override fun areItemsTheSame(
oldItem: FavouriteCategory,
newItem: FavouriteCategory
): Boolean = oldItem.id == newItem.id
oldItem: CategoryListModel,
newItem: CategoryListModel
): Boolean = when {
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> true
oldItem is CategoryListModel.CategoryItem && newItem is CategoryListModel.CategoryItem -> {
oldItem.category.id == newItem.category.id
}
else -> false
}
override fun areContentsTheSame(
oldItem: FavouriteCategory,
newItem: FavouriteCategory
): Boolean = oldItem.id == newItem.id && oldItem.title == newItem.title
oldItem: CategoryListModel,
newItem: CategoryListModel
): Boolean = oldItem == newItem
}
}

View File

@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.favourites.ui
import android.view.View
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
fun interface FavouritesTabLongClickListener {
fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean
fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.categories
interface AllCategoriesToggleListener {
fun onAllCategoriesToggle(isVisible: Boolean)
}

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
@@ -29,7 +30,7 @@ class CategoriesActivity :
BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<FavouriteCategory>,
View.OnClickListener,
CategoriesEditDelegate.CategoriesEditCallback {
CategoriesEditDelegate.CategoriesEditCallback, AllCategoriesToggleListener {
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
@@ -41,7 +42,7 @@ class CategoriesActivity :
super.onCreate(savedInstanceState)
setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
adapter = CategoriesAdapter(this)
adapter = CategoriesAdapter(this, this)
editDelegate = CategoriesEditDelegate(this, this)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
@@ -49,7 +50,7 @@ class CategoriesActivity :
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
reorderHelper.attachToRecyclerView(binding.recyclerView)
viewModel.categories.observe(this, ::onCategoriesChanged)
viewModel.allCategories.observe(this, ::onCategoriesChanged)
viewModel.onError.observe(this, ::onError)
}
@@ -84,6 +85,10 @@ class CategoriesActivity :
return true
}
override fun onAllCategoriesToggle(isVisible: Boolean) {
viewModel.setAllCategoriesVisible(isVisible)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = topMargin + insets.right
@@ -97,7 +102,7 @@ class CategoriesActivity :
)
}
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
private fun onCategoriesChanged(categories: List<CategoryListModel>) {
adapter.items = categories
binding.textViewHolder.isVisible = categories.isEmpty()
}
@@ -138,13 +143,19 @@ class CategoriesActivity :
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
) {
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = true
): Boolean = viewHolder.itemViewType == target.itemViewType
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun canDropOver(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType
override fun onMoved(
recyclerView: RecyclerView,
@@ -158,6 +169,8 @@ class CategoriesActivity :
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderCategories(fromPos, toPos)
}
override fun isLongPressDragEnabled(): Boolean = false
}
companion object {

View File

@@ -4,13 +4,18 @@ import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.allCategoriesAD
import org.koitharu.kotatsu.favourites.ui.categories.adapter.categoryAD
class CategoriesAdapter(
onItemClickListener: OnListItemClickListener<FavouriteCategory>,
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
allCategoriesToggleListener: AllCategoriesToggleListener,
) : AsyncListDifferDelegationAdapter<CategoryListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(categoryAD(onItemClickListener))
.addDelegate(allCategoriesAD(allCategoriesToggleListener))
setHasStableIds(true)
}
@@ -18,28 +23,23 @@ class CategoriesAdapter(
return items[position].id
}
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
override fun areItemsTheSame(
oldItem: FavouriteCategory,
newItem: FavouriteCategory,
): Boolean {
return oldItem.id == newItem.id
}
oldItem: CategoryListModel,
newItem: CategoryListModel,
): Boolean = oldItem.id == newItem.id
override fun areContentsTheSame(
oldItem: FavouriteCategory,
newItem: FavouriteCategory,
): Boolean {
return oldItem.id == newItem.id && oldItem.title == newItem.title
&& oldItem.order == newItem.order
}
oldItem: CategoryListModel,
newItem: CategoryListModel,
): Boolean = oldItem == newItem
override fun getChangePayload(
oldItem: FavouriteCategory,
newItem: FavouriteCategory,
oldItem: CategoryListModel,
newItem: CategoryListModel,
): Any? = when {
oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
}

View File

@@ -3,20 +3,36 @@ package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import java.util.*
class FavouritesCategoriesViewModel(
private val repository: FavouritesRepository
private val repository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
private var reorderJob: Job? = null
val categories = repository.observeCategories()
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val allCategories = combine(
repository.observeCategories(),
observeAllCategoriesVisible(),
) { list, showAll ->
mapCategories(list, showAll, true)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val visibleCategories = combine(
repository.observeCategories(),
observeAllCategoriesVisible(),
) { list, showAll ->
mapCategories(list, showAll, showAll)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
fun createCategory(name: String) {
launchJob {
@@ -42,14 +58,40 @@ class FavouritesCategoriesViewModel(
}
}
fun setAllCategoriesVisible(isVisible: Boolean) {
settings.isAllFavouritesVisible = isVisible
}
fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) {
prevJob?.join()
val items = categories.value ?: error("This should not happen")
val items = allCategories.value ?: error("This should not happen")
val ids = items.mapTo(ArrayList(items.size)) { it.id }
Collections.swap(ids, oldPos, newPos)
ids.remove(0L)
repository.reorderCategories(ids)
}
}
private fun mapCategories(
categories: List<FavouriteCategory>,
isAllCategoriesVisible: Boolean,
withAllCategoriesItem: Boolean,
): List<CategoryListModel> {
val result = ArrayList<CategoryListModel>(categories.size + 1)
if (withAllCategoriesItem) {
result.add(CategoryListModel.All(isAllCategoriesVisible))
}
categories.mapTo(result) {
CategoryListModel.CategoryItem(it)
}
return result
}
private fun observeAllCategoriesVisible() = settings.observe()
.filter { it == AppSettings.KEY_ALL_FAVOURITES_VISIBLE }
.map { settings.isAllFavouritesVisible }
.onStart { emit(settings.isAllFavouritesVisible) }
.distinctUntilChanged()
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
import org.koitharu.kotatsu.favourites.ui.categories.AllCategoriesToggleListener
fun allCategoriesAD(
allCategoriesToggleListener: AllCategoriesToggleListener,
) = adapterDelegateViewBinding<CategoryListModel.All, CategoryListModel, ItemCategoriesAllBinding>(
{ inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) }
) {
binding.imageViewToggle.setOnClickListener {
allCategoriesToggleListener.onAllCategoriesToggle(!item.isVisible)
}
bind {
binding.imageViewToggle.isChecked = item.isVisible
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.favourites.ui.categories
package org.koitharu.kotatsu.favourites.ui.categories.adapter
import android.view.MotionEvent
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -8,23 +8,23 @@ import org.koitharu.kotatsu.databinding.ItemCategoryBinding
fun categoryAD(
clickListener: OnListItemClickListener<FavouriteCategory>
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryBinding>(
) = adapterDelegateViewBinding<CategoryListModel.CategoryItem, CategoryListModel, ItemCategoryBinding>(
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }
) {
binding.imageViewMore.setOnClickListener {
clickListener.onItemClick(item, it)
clickListener.onItemClick(item.category, it)
}
@Suppress("ClickableViewAccessibility")
binding.imageViewHandle.setOnTouchListener { v, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
clickListener.onItemLongClick(item, itemView)
clickListener.onItemLongClick(item.category, itemView)
} else {
false
}
}
bind {
binding.textViewTitle.text = item.title
binding.textViewTitle.text = item.category.title
}
}

View File

@@ -0,0 +1,59 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.list.ui.model.ListModel
sealed interface CategoryListModel : ListModel {
val id: Long
class All(
val isVisible: Boolean,
) : CategoryListModel {
override val id: Long = 0L
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as All
if (isVisible != other.isVisible) return false
return true
}
override fun hashCode(): Int {
return isVisible.hashCode()
}
}
class CategoryItem(
val category: FavouriteCategory,
) : CategoryListModel {
override val id: Long
get() = category.id
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CategoryItem
if (category.id != other.category.id) return false
if (category.title != other.category.title) return false
if (category.order != other.category.order) return false
return true
}
override fun hashCode(): Int {
var result = category.id.hashCode()
result = 31 * result + category.title.hashCode()
result = 31 * result + category.order.hashCode()
return result
}
}
}

View File

@@ -7,7 +7,6 @@ import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.ui.LocalListViewModel
import org.koitharu.kotatsu.utils.ExternalStorageHelper
val localModule
get() = module {
@@ -15,8 +14,6 @@ val localModule
single { LocalStorageManager(androidContext(), get()) }
single { LocalMangaRepository(get()) }
factory { ExternalStorageHelper(androidContext()) }
factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) }
viewModel { LocalListViewModel(get(), get(), get(), get()) }

View File

@@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.content.Context
import android.os.StatFs
import androidx.annotation.WorkerThread
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
@@ -11,7 +12,6 @@ import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.getStorageName
import java.io.File
private const val DIR_NAME = "manga"
private const val CACHE_DISK_PERCENTAGE = 0.02
@@ -71,7 +71,7 @@ class LocalStorageManager(
private fun getAvailableStorageDirs(): MutableSet<File> {
val result = LinkedHashSet<File>()
result += File(context.filesDir, DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME)
context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result)
result.retainAll { it.exists() || it.mkdirs() }
return result
}
@@ -87,8 +87,8 @@ class LocalStorageManager(
private fun getCacheDirs(subDir: String): MutableSet<File> {
val result = LinkedHashSet<File>()
result += File(context.cacheDir, subDir)
context.externalCacheDirs.mapTo(result) {
File(it, subDir)
context.externalCacheDirs.mapNotNullTo(result) {
File(it ?: return@mapNotNullTo null, subDir)
}
return result
}
@@ -110,4 +110,4 @@ class LocalStorageManager(
private fun File.isWriteable() = runCatching {
canWrite()
}.getOrDefault(false)
}
}

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.CompositeMutex
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.resolveName
@@ -34,6 +35,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
override val source = MangaSource.LOCAL
private val filenameFilter = CbzFilter()
private val locks = CompositeMutex<Long>()
override suspend fun getList(
offset: Int,
@@ -112,11 +114,18 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return file.deleteAwait()
}
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) = runInterruptible(Dispatchers.IO) {
val uri = Uri.parse(manga.url)
val file = uri.toFile()
val cbz = CbzMangaOutput(file, manga)
CbzMangaOutput.filterChapters(cbz, ids)
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
lockManga(manga.id)
try {
runInterruptible(Dispatchers.IO) {
val uri = Uri.parse(manga.url)
val file = uri.toFile()
val cbz = CbzMangaOutput(file, manga)
CbzMangaOutput.filterChapters(cbz, ids)
}
} finally {
unlockManga(manga.id)
}
}
@WorkerThread
@@ -278,6 +287,14 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}
}
suspend fun lockManga(id: Long) {
locks.lock(id)
}
suspend fun unlockManga(id: Long) {
locks.unlock(id)
}
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty()
}

View File

@@ -49,6 +49,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
@@ -390,10 +391,14 @@ class MainActivity :
if (AppUpdateChecker.isUpdateSupported(this@MainActivity)) {
AppUpdateChecker(this@MainActivity).checkIfNeeded()
}
if (!get<AppSettings>().isSourcesSelected) {
withContext(Dispatchers.Main) {
val settings = get<AppSettings>()
when {
!settings.isSourcesSelected -> withContext(Dispatchers.Main) {
OnboardDialogFragment.showWelcome(supportFragmentManager)
}
settings.newSources.isNotEmpty() -> withContext(Dispatchers.Main) {
NewSourcesDialogFragment.show(supportFragmentManager)
}
}
}
}

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.reader
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
val readerModule
@@ -12,6 +14,8 @@ val readerModule
single { MangaDataRepository(get()) }
single { PagesCache(get()) }
factory { PageSaveHelper(get(), androidContext()) }
viewModel { params ->
ReaderViewModel(
intent = params[0],
@@ -21,7 +25,7 @@ val readerModule
historyRepository = get(),
shortcutsRepository = get(),
settings = get(),
externalStorageHelper = get(),
pageSaveHelper = get(),
)
}
}

View File

@@ -113,6 +113,10 @@ class PageLoader : KoinComponent, Closeable {
}
}
suspend fun getPageUrl(page: MangaPage): String {
return getRepository(page.source).getPageUrl(page)
}
private fun onIdle() {
synchronized(prefetchQueue) {
while (prefetchQueue.isNotEmpty()) {
@@ -151,7 +155,7 @@ class PageLoader : KoinComponent, Closeable {
}
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File {
val pageUrl = getRepository(page.source).getPageUrl(page)
val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl)
return if (uri.scheme == "cbz") {

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.reader.ui
import android.content.Context
import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.IOException
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import kotlin.coroutines.Continuation
import kotlin.coroutines.coroutineContext
import kotlin.coroutines.resume
class PageSaveHelper(
private val cache: PagesCache,
context: Context,
) {
private var continuation: Continuation<Uri>? = null
private val contentResolver = context.contentResolver
suspend fun savePage(
pageLoader: PageLoader,
page: MangaPage,
saveLauncher: ActivityResultLauncher<String>,
): Uri {
var pageFile = cache[page.url]
var fileName = pageFile?.name
if (fileName == null) {
fileName = pageLoader.getPageUrl(page).toHttpUrl().pathSegments.last()
}
val cc = coroutineContext
val destination = suspendCancellableCoroutine<Uri> { cont ->
continuation = cont
Dispatchers.Main.dispatch(cc) {
saveLauncher.launch(fileName)
}
}
continuation = null
if (pageFile == null) {
pageFile = pageLoader.loadPage(page, force = false)
}
runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.use { output ->
pageFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IOException("Output stream is null")
}
return destination
}
fun onActivityResult(uri: Uri): Boolean = continuation?.apply {
resume(uri)
} != null
}

View File

@@ -9,7 +9,6 @@ import android.view.*
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.*
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
@@ -187,10 +186,7 @@ class ReaderActivity :
R.id.action_save_page -> {
viewModel.getCurrentPage()?.also { page ->
viewModel.saveCurrentState(reader?.getCurrentState())
val name = page.url.toUri().run {
fragment ?: lastPathSegment ?: ""
}
savePageRequest.launch(name)
viewModel.saveCurrentPage(page, savePageRequest)
} ?: showWaitWhileLoading()
}
else -> return super.onOptionsItemSelected(item)
@@ -199,9 +195,7 @@ class ReaderActivity :
}
override fun onActivityResult(uri: Uri?) {
if (uri != null) {
viewModel.saveCurrentPage(uri)
}
viewModel.onActivityResult(uri)
}
private fun onLoadingStateChanged(isLoading: Boolean) {

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui
import android.net.Uri
import android.util.LongSparseArray
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
@@ -26,7 +27,6 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.ExternalStorageHelper
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -40,10 +40,11 @@ class ReaderViewModel(
private val historyRepository: HistoryRepository,
private val shortcutsRepository: ShortcutsRepository,
private val settings: AppSettings,
private val externalStorageHelper: ExternalStorageHelper,
private val pageSaveHelper: PageSaveHelper,
) : BaseViewModel() {
private var loadingJob: Job? = null
private var pageSaveJob: Job? = null
private val currentState = MutableStateFlow(initialState)
private val mangaData = MutableStateFlow(intent.manga)
private val chapters = LongSparseArray<MangaChapter>()
@@ -54,7 +55,7 @@ class ReaderViewModel(
val onPageSaved = SingleLiveEvent<Uri?>()
val uiState = combine(
mangaData,
currentState
currentState,
) { manga, state ->
val chapter = state?.chapterId?.let(chapters::get)
ReaderUiState(
@@ -137,12 +138,16 @@ class ReaderViewModel(
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
}
fun saveCurrentPage(destination: Uri) {
launchJob(Dispatchers.Default) {
fun saveCurrentPage(
page: MangaPage,
saveLauncher: ActivityResultLauncher<String>,
) {
val prevJob = pageSaveJob
pageSaveJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
try {
val page = getCurrentPage() ?: error("Page not found")
externalStorageHelper.savePage(page, destination)
onPageSaved.postCall(destination)
val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher)
onPageSaved.postCall(dest)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -154,6 +159,15 @@ class ReaderViewModel(
}
}
fun onActivityResult(uri: Uri?) {
if (uri != null) {
pageSaveHelper.onActivityResult(uri)
} else {
pageSaveJob?.cancel()
pageSaveJob = null
}
}
fun getCurrentPage(): MangaPage? {
val state = currentState.value ?: return null
return content.value?.pages?.find {

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.getViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -20,6 +21,8 @@ import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -81,7 +84,7 @@ class PagesThumbnailsSheet :
dataSet = thumbnails,
coil = get(),
scope = viewLifecycleScope,
loader = PageLoader().also { pageLoader = it },
loader = getPageLoader(),
clickListener = this@PagesThumbnailsSheet
)
addOnLayoutChangeListener(spanResolver)
@@ -109,6 +112,11 @@ class PagesThumbnailsSheet :
}
}
private fun getPageLoader(): PageLoader {
val viewModel = (activity as? ReaderActivity)?.getViewModel<ReaderViewModel>()
return viewModel?.pageLoader ?: PageLoader().also { pageLoader = it }
}
private inner class ToolbarController(toolbar: Toolbar) : BottomSheetToolbarController(toolbar) {
override fun onStateChanged(bottomSheet: View, newState: Int) {
super.onStateChanged(bottomSheet, newState)

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel
import org.koitharu.kotatsu.settings.newsources.NewSourcesViewModel
import org.koitharu.kotatsu.settings.onboard.OnboardViewModel
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
@@ -27,4 +28,5 @@ val settingsModule
viewModel { ProtectSetupViewModel(get()) }
viewModel { OnboardViewModel(get()) }
viewModel { SourcesSettingsViewModel(get()) }
viewModel { NewSourcesViewModel(get()) }
}

View File

@@ -0,0 +1,68 @@
package org.koitharu.kotatsu.settings.newsources
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class NewSourcesDialogFragment :
AlertDialogFragment<DialogOnboardBinding>(),
SourceConfigListener,
DialogInterface.OnClickListener {
private val viewModel by viewModel<NewSourcesViewModel>()
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogOnboardBinding {
return DialogOnboardBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = SourceConfigAdapter(this, get(), viewLifecycleOwner)
binding.recyclerView.adapter = adapter
binding.textViewTitle.setText(R.string.new_sources_text)
viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it }
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder
.setPositiveButton(R.string.done, this)
.setCancelable(true)
.setTitle(R.string.remote_sources)
}
override fun onClick(dialog: DialogInterface, which: Int) {
viewModel.apply()
dialog.dismiss()
}
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.onItemEnabledChanged(item, isEnabled)
}
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) = Unit
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) = Unit
companion object {
private const val TAG = "NewSources"
fun show(fm: FragmentManager) = NewSourcesDialogFragment().show(fm, TAG)
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.settings.newsources
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class NewSourcesViewModel(
private val settings: AppSettings,
) : BaseViewModel() {
val sources = MutableLiveData<List<SourceConfigItem>>()
private val initialList = settings.newSources
init {
buildList()
}
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
if (isEnabled) {
settings.hiddenSources -= item.source.name
} else {
settings.hiddenSources += item.source.name
}
}
fun apply() {
settings.markKnownSources(initialList)
}
private fun buildList() {
val hidden = settings.hiddenSources
sources.value = initialList.map {
SourceConfigItem.SourceItem(
source = it,
summary = null,
isEnabled = it.name !in hidden,
isDraggable = false,
)
}
}
}

View File

@@ -53,6 +53,7 @@ class OnboardDialogFragment : AlertDialogFragment<DialogOnboardBinding>(),
super.onViewCreated(view, savedInstanceState)
val adapter = SourceLocalesAdapter(this)
binding.recyclerView.adapter = adapter
binding.textViewTitle.setText(R.string.onboard_text)
viewModel.list.observeNotNull(viewLifecycleOwner) {
adapter.items = it
}

View File

@@ -83,7 +83,9 @@ fun sourceConfigDraggableItemDelegate(
on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable }
) {
val eventListener = object : View.OnClickListener, View.OnTouchListener,
val eventListener = object :
View.OnClickListener,
View.OnTouchListener,
CompoundButton.OnCheckedChangeListener {
override fun onClick(v: View?) = listener.onItemSettingsClick(item)

View File

@@ -0,0 +1,66 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.*
import kotlin.coroutines.resume
class CompositeMutex<T : Any> : Set<T> {
private val data = HashMap<T, MutableList<CancellableContinuation<Unit>>>()
private val mutex = Mutex()
override val size: Int
get() = data.size
override fun contains(element: T): Boolean {
return data.containsKey(element)
}
override fun containsAll(elements: Collection<T>): Boolean {
return elements.all { x -> data.containsKey(x) }
}
override fun isEmpty(): Boolean {
return data.isEmpty()
}
override fun iterator(): Iterator<T> {
return data.keys.iterator()
}
suspend fun lock(element: T) {
waitForRemoval(element)
mutex.withLock {
val lastValue = data.put(element, LinkedList<CancellableContinuation<Unit>>())
check(lastValue == null) {
"CompositeMutex is double-locked for $element"
}
}
}
suspend fun unlock(element: T) {
val continuations = mutex.withLock {
checkNotNull(data.remove(element)) {
"CompositeMutex is not locked for $element"
}
}
continuations.forEach { c ->
if (c.isActive) {
c.resume(Unit)
}
}
}
private suspend fun waitForRemoval(element: T) {
val list = data[element] ?: return
suspendCancellableCoroutine<Unit> { continuation ->
list.add(continuation)
continuation.invokeOnCancellation {
list.remove(continuation)
}
}
}
}

View File

@@ -1,26 +0,0 @@
package org.koitharu.kotatsu.utils
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
class ExternalStorageHelper(context: Context) {
private val contentResolver = context.contentResolver
suspend fun savePage(page: MangaPage, destination: Uri) {
val pageLoader = PageLoader()
val pageFile = pageLoader.loadPage(page, force = false)
runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.use { output ->
pageFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IOException("Output stream is null")
}
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.utils.progress
import android.os.SystemClock
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.math.roundToLong
@@ -11,6 +12,7 @@ class TimeLeftEstimator {
private var times = ArrayList<Int>()
private var lastTick: Tick? = null
private val tooLargeTime = TimeUnit.DAYS.toMillis(1)
fun tick(value: Int, total: Int) {
if (total < 0) {
@@ -36,7 +38,8 @@ class TimeLeftEstimator {
}
val timePerTick = times.average()
val ticksLeft = progress.total - progress.value
return (ticksLeft * timePerTick).roundToLong()
val eta = (ticksLeft * timePerTick).roundToLong()
return if (eta < tooLargeTime) eta else NO_TIME
}
private class Tick(

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M2,5.27L3.28,4L20,20.72L18.73,22L15.65,18.92C14.5,19.3 13.28,19.5 12,19.5C7,19.5 2.73,16.39 1,12C1.69,10.24 2.79,8.69 4.19,7.46L2,5.27M12,9A3,3 0 0,1 15,12C15,12.35 14.94,12.69 14.83,13L11,9.17C11.31,9.06 11.65,9 12,9M12,4.5C17,4.5 21.27,7.61 23,12C22.18,14.08 20.79,15.88 19,17.19L17.58,15.76C18.94,14.82 20.06,13.54 20.82,12C19.17,8.64 15.76,6.5 12,6.5C10.91,6.5 9.84,6.68 8.84,7L7.3,5.47C8.74,4.85 10.33,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C12.69,17.5 13.37,17.43 14,17.29L11.72,15C10.29,14.85 9.15,13.71 9,12.28L5.6,8.87C4.61,9.72 3.78,10.78 3.18,12Z" />
</vector>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.12950581"
android:scaleY="0.12950581"
android:translateX="20.846512"
android:translateY="20.846512">
<path
android:fillColor="#ffffff"
android:pathData="m256,206c-50.54,0 -91.67,44.86 -91.67,100 0,55.14 41.13,100 91.67,100 50.54,0 91.67,-44.86 91.67,-100 0,-55.14 -41.13,-100 -91.67,-100zM221.79,284.73c-1.46,2.91 -4.41,4.61 -7.45,4.61 -1.25,0 -2.52,-0.28 -3.73,-0.88l-12.94,-6.48 -12.94,6.48c-4.13,2.07 -9.11,0.37 -11.18,-3.73 -2.05,-4.12 -0.39,-9.11 3.73,-11.18l16.67,-8.33c2.34,-1.17 5.11,-1.17 7.45,0l16.67,8.33c4.12,2.07 5.78,7.06 3.73,11.18zM280.12,259.73c-1.46,2.91 -4.41,4.61 -7.45,4.61 -1.25,0 -2.52,-0.28 -3.73,-0.88l-12.94,-6.48 -12.94,6.48c-4.13,2.07 -9.11,0.37 -11.18,-3.73 -2.05,-4.12 -0.39,-9.11 3.73,-11.18l16.67,-8.33c2.34,-1.17 5.11,-1.17 7.45,0l16.67,8.33c4.12,2.07 5.78,7.06 3.73,11.18zM334.73,273.54c4.12,2.07 5.78,7.06 3.73,11.18 -1.46,2.91 -4.41,4.61 -7.45,4.61 -1.25,0 -2.52,-0.28 -3.73,-0.88l-12.94,-6.48 -12.94,6.48c-4.12,2.07 -9.11,0.37 -11.18,-3.73 -2.05,-4.12 -0.39,-9.11 3.73,-11.18l16.67,-8.33c2.34,-1.17 5.11,-1.17 7.45,0z"
android:strokeWidth="0.781247" />
<path
android:fillColor="#ffffff"
android:pathData="m364.24,169.25c-3.48,-6.91 -6.92,-13.42 -10.21,-19.37l-8.27,-14.48c-6.8,-11.52 -12.26,-19.9 -14.78,-23.67 -0.72,-43.33 -19.12,-53.79 -21.26,-54.87 -3.21,-1.58 -7.1,-0.98 -9.62,1.56 -12.26,12.26 -20.23,24.46 -24.01,30.89h-40.2c-3.78,-6.43 -11.75,-18.64 -24.01,-30.89 -2.52,-2.54 -6.4,-3.14 -9.62,-1.56 -2.13,1.07 -20.54,11.54 -21.26,54.87 -2.53,3.76 -7.99,12.15 -14.78,23.66l-8.27,14.49c-3.29,5.96 -6.73,12.47 -10.21,19.38l-7.44,15.33c-17.73,38.05 -34.3,85.43 -34.3,129.73 0,69.32 58.27,128.42 60.76,130.89 0.91,0.91 2.03,1.61 3.26,2.02 1.07,0.36 26.79,8.76 69.32,8.76 2.21,0 4.33,-0.88 5.89,-2.44l5.89,-5.89h9.77l5.89,5.89c1.56,1.56 3.68,2.44 5.89,2.44 42.53,0 68.25,-8.4 69.32,-8.76 1.22,-0.41 2.34,-1.11 3.26,-2.02 2.49,-2.47 60.76,-61.57 60.76,-130.89 0,-44.3 -16.57,-91.67 -34.3,-129.73zM297.67,122.67c4.61,0 4.41,7.35 4.41,11.96 4.61,0 12.25,0.1 12.25,4.71 0,9.2 -7.47,16.67 -16.67,16.67 -9.2,0 -16.67,-7.47 -16.67,-16.67 0,-9.2 7.47,-16.67 16.67,-16.67zM239.97,152.81c1.29,-3.11 4.33,-5.14 7.7,-5.14h16.67c3.37,0 6.41,2.03 7.7,5.14 1.29,3.11 0.57,6.71 -1.81,9.08l-8.33,8.33c-1.63,1.63 -3.76,2.44 -5.89,2.44 -2.13,0 -4.26,-0.81 -5.89,-2.44l-8.33,-8.33c-2.37,-2.38 -3.09,-5.97 -1.8,-9.08zM214.33,122.67c4.61,0 4.41,7.35 4.41,11.96 4.61,0 12.25,0.1 12.25,4.71 0,9.2 -7.47,16.67 -16.67,16.67 -9.2,0 -16.67,-7.47 -16.67,-16.67 -0,-9.2 7.47,-16.67 16.67,-16.67zM256,422.67c-59.73,0 -108.33,-52.34 -108.33,-116.67 0,-64.32 48.6,-116.67 108.33,-116.67 59.73,0 108.33,52.34 108.33,116.67 0,64.32 -48.6,116.67 -108.33,116.67z"
android:strokeWidth="0.781247" />
</group>
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9M12,4.5C17,4.5 21.27,7.61 23,12C21.27,16.39 17,19.5 12,19.5C7,19.5 2.73,16.39 1,12C2.73,7.61 7,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C15.76,17.5 19.17,15.36 20.82,12C19.17,8.64 15.76,6.5 12,6.5C8.24,6.5 4.83,8.64 3.18,12Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_shown" android:state_checked="true" />
<item android:drawable="@drawable/ic_hidden" android:state_checked="false" />
</selector>

View File

@@ -8,14 +8,15 @@
android:orientation="vertical">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingTop="6dp"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:paddingBottom="6dp"
android:text="@string/onboard_text"
android:textAppearance="?attr/textAppearanceBodyMedium" />
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@string/onboard_text" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="?listPreferredItemPaddingStart"
tools:ignore="Overdraw">
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:singleLine="true"
android:text="@string/all_favourites"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<org.koitharu.kotatsu.base.ui.widgets.CheckableImageView
android:id="@+id/imageView_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:padding="?listPreferredItemPaddingEnd"
android:scaleType="center"
app:srcCompat="@drawable/ic_shown_hidden" />
</LinearLayout>

View File

@@ -2,6 +2,10 @@
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_hide"
android:title="@string/hide" />
<item
android:id="@+id/action_create"
android:title="@string/create_category" />

View File

@@ -3,5 +3,5 @@
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/blue_primary" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_totoro" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/blue_primary"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_totoro" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@@ -272,4 +272,10 @@
<string name="text_delete_local_manga_batch">Ausgewählte Elemente dauerhaft vom Gerät löschen\?</string>
<string name="batch_manga_save_confirm">Sind Sie sicher, dass Sie alle ausgewählten Mangas mit allen Kapiteln herunterladen möchten\? Diese Aktion kann eine Menge Datenverkehr und Speicherplatz verbrauchen</string>
<string name="removal_completed">Entfernung abgeschlossen</string>
<string name="download_slowdown">Download-Verzögerung</string>
<string name="parallel_downloads">Parallele Downloads</string>
<string name="local_manga_processing">Gespeicherte Manga-Verarbeitung</string>
<string name="download_slowdown_summary">Hilft, das Blockieren Ihrer IP-Adresse zu vermeiden</string>
<string name="chapters_will_removed_background">Die Kapitel werden im Hintergrund entfernt. Das kann einige Zeit dauern</string>
<string name="hide">Ausblenden</string>
</resources>

View File

@@ -271,4 +271,11 @@
<string name="suggestions_excluded_genres">Sulje pois genrejä</string>
<string name="text_delete_local_manga_batch">Poista valitut kohteet laitteesta pysyvästi\?</string>
<string name="removal_completed">Poisto valmis</string>
<string name="parallel_downloads">Rinnakkaislataukset</string>
<string name="download_slowdown">Latauksen hidastuminen</string>
<string name="download_slowdown_summary">Auttaa välttämään IP-osoitteesi estämisen</string>
<string name="chapters_will_removed_background">Luvut poistetaan taustalla. Se voi kestää jonkin aikaa</string>
<string name="batch_manga_save_confirm">Oletko varma, että haluat ladata kaikki valitut mangat kaikkine lukuineen\? Tämä toiminto voi kuluttaa paljon liikennettä ja tallennustilaa</string>
<string name="local_manga_processing">Tallennettujen mangojen käsittely</string>
<string name="hide">Piilota</string>
</resources>

View File

@@ -272,4 +272,10 @@
<string name="text_delete_local_manga_batch">Supprimer définitivement les éléments sélectionnés de l\'appareil \?</string>
<string name="removal_completed">Suppression terminée</string>
<string name="batch_manga_save_confirm">Voulez-vous vraiment télécharger tous les mangas sélectionnés avec tous leurs chapitres \? Cette action peut consommer beaucoup de trafic et de stockage</string>
<string name="parallel_downloads">Téléchargements parallèles</string>
<string name="download_slowdown">Ralentissement du téléchargement</string>
<string name="download_slowdown_summary">Permet d\'éviter le blocage de votre adresse IP</string>
<string name="chapters_will_removed_background">Les chapitres seront supprimés en arrière-plan. Cela peut prendre un certain temps</string>
<string name="local_manga_processing">Traitement des mangas sauvegardés</string>
<string name="hide">Masquer</string>
</resources>

View File

@@ -272,4 +272,10 @@
<string name="removal_completed">Rimozione completata</string>
<string name="text_delete_local_manga_batch">Eliminare gli elementi selezionati dal dispositivo in modo permanente\?</string>
<string name="batch_manga_save_confirm">Vuoi davvero scaricare tutti i manga selezionati con tutti i loro capitoli\? Questa azione può consumare molto traffico e memoria</string>
<string name="parallel_downloads">Scaricamenti paralleli</string>
<string name="download_slowdown">Rallentamento dello scaricamento</string>
<string name="local_manga_processing">Elaborazione dei manga salvati</string>
<string name="chapters_will_removed_background">I capitoli saranno rimossi in sfondo. Può richiedere un po\' di tempo</string>
<string name="download_slowdown_summary">Aiuta ad evitare il blocco del tuo indirizzo IP</string>
<string name="hide">Nascondi</string>
</resources>

View File

@@ -272,4 +272,10 @@
<string name="text_delete_local_manga_batch">選択した項目をデバイスから完全に削除しますか?</string>
<string name="removal_completed">削除が完了しました</string>
<string name="batch_manga_save_confirm">本当に選択したマンガを全編ダウンロードしますか?この動作は多くのトラフィックとストレージを消費する可能性があります</string>
<string name="download_slowdown_summary">IPアドレスのブロックを回避することができます</string>
<string name="local_manga_processing">保存されたマンガの処理</string>
<string name="download_slowdown">ダウンロードの速度低下</string>
<string name="parallel_downloads">並列ダウンロード</string>
<string name="chapters_will_removed_background">チャプターはバックグラウンドで削除されます。時間がかかる場合があります</string>
<string name="hide">隠す</string>
</resources>

View File

@@ -272,4 +272,10 @@
<string name="removal_completed">Remoção concluída</string>
<string name="text_delete_local_manga_batch">Excluir itens selecionados do dispositivo permanentemente\?</string>
<string name="batch_manga_save_confirm">Tem certeza de que deseja baixar todos os mangás selecionados com todos os seus capítulos\? Essa ação pode consumir muito tráfego e armazenamento</string>
<string name="hide">Esconder</string>
<string name="download_slowdown">Baixar lentidão</string>
<string name="download_slowdown_summary">Ajuda a evitar o bloqueio do seu endereço IP</string>
<string name="local_manga_processing">Processamento de mangá salvo</string>
<string name="chapters_will_removed_background">Os capítulos serão removidos em segundo plano. Pode levar algum tempo</string>
<string name="parallel_downloads">Downloads paralelos</string>
</resources>

View File

@@ -276,4 +276,6 @@
<string name="download_slowdown_summary">Помогает избежать блокировки IP-адреса</string>
<string name="local_manga_processing">Обработка сохранённой манги</string>
<string name="chapters_will_removed_background">Главы будут удалены в фоновом режиме. Это может занять какое-то время</string>
<string name="hide">Скрыть</string>
<string name="new_sources_text">Доступны новые источники манги</string>
</resources>

View File

@@ -272,4 +272,5 @@
<string name="text_delete_local_manga_batch">Vill du ta bort markerade objekt från enheten permanent\?</string>
<string name="percent_string_pattern">%1$s%%</string>
<string name="batch_manga_save_confirm">Är du säker på att du vill ladda ner alla utvalda manga med alla kapitel\? Den här åtgärden kan kräva mycket nätverkstrafik och lagringsutrymme</string>
<string name="parallel_downloads">Parallella nedladdningar</string>
</resources>

View File

@@ -272,4 +272,10 @@
<string name="text_delete_local_manga_batch">Seçilen ögeler aygıttan kalıcı olarak silinsin mi\?</string>
<string name="batch_manga_save_confirm">Seçilen tüm mangaları tüm bölümleriyle birlikte indirmek istediğinizden emin misiniz\? Bu işlem çok fazla trafik ve depolama alanı tüketebilir</string>
<string name="removal_completed">Kaldırma tamamlandı</string>
<string name="chapters_will_removed_background">Bölümler arka planda kaldırılacaktır. Bu biraz zaman alabilir</string>
<string name="parallel_downloads">Paralel indirmeler</string>
<string name="download_slowdown">İndirmeyi yavaşlat</string>
<string name="download_slowdown_summary">IP adresinizin engellenmesinden kaçınmanıza yardımcı olur</string>
<string name="local_manga_processing">Kaydedilen manga işleme</string>
<string name="hide">Gizle</string>
</resources>

View File

@@ -285,4 +285,6 @@
<string name="sync">Synchronization</string>
<string name="sync_title">Sync your data</string>
<string name="email_enter_hint">Enter your email to continue</string>
<string name="hide">Hide</string>
<string name="new_sources_text">New manga sources are available</string>
</resources>