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' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 404 versionCode 405
versionName '3.2' versionName '3.2.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -65,7 +65,7 @@ android {
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) 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' exclude group: 'org.json', module: 'json'
} }

View File

@@ -5,6 +5,7 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import android.os.Parcelable.Creator import android.os.Parcelable.Creator
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import android.widget.Checkable import android.widget.Checkable
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView 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 interface OnCheckedChangeListener {
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)

View File

@@ -11,10 +11,6 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors 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.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow 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.getEnumValue
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull 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) { class AppSettings(context: Context) {
@@ -67,6 +67,10 @@ class AppSettings(context: Context) {
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } 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 val isUpdateCheckingEnabled: Boolean
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true) get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
@@ -130,6 +134,20 @@ class AppSettings(context: Context) {
val isSourcesSelected: Boolean val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs 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 val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) 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_SEARCH_SINGLE_SOURCE = "search_single_source"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

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

View File

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

View File

@@ -64,7 +64,7 @@ class FavouritesRepository(private val db: MangaDatabase) {
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(), sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0, categoryId = 0,
order = SortOrder.UPDATED.name, order = SortOrder.NEWEST.name,
) )
val id = db.favouriteCategoriesDao.insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
return entity.toFavouriteCategory(id) 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.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel 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.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import java.util.*
class FavouritesContainerFragment : class FavouritesContainerFragment :
BaseFragment<FragmentFavouritesBinding>(), BaseFragment<FragmentFavouritesBinding>(),
@@ -53,15 +52,15 @@ class FavouritesContainerFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val adapter = FavouritesPagerAdapter(this, this) val adapter = FavouritesPagerAdapter(this, this)
viewModel.categories.value?.let { viewModel.visibleCategories.value?.let {
adapter.replaceData(wrapCategories(it)) adapter.replaceData(it)
} }
binding.pager.adapter = adapter binding.pager.adapter = adapter
pagerAdapter = adapter pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
actionModeDelegate.addListener(this, viewLifecycleOwner) actionModeDelegate.addListener(this, viewLifecycleOwner)
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged) viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
} }
@@ -86,7 +85,8 @@ class FavouritesContainerFragment :
top = headerHeight - insets.top top = headerHeight - insets.top
) )
binding.pager.updatePadding( 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 { binding.tabs.apply {
updatePadding( updatePadding(
@@ -99,8 +99,8 @@ class FavouritesContainerFragment :
} }
} }
private fun onCategoriesChanged(categories: List<FavouriteCategory>) { private fun onCategoriesChanged(categories: List<CategoryListModel>) {
pagerAdapter?.replaceData(wrapCategories(categories)) pagerAdapter?.replaceData(categories)
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -122,26 +122,11 @@ class FavouritesContainerFragment :
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
} }
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean { override fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean {
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category when (item) {
val menu = PopupMenu(tabView.context, tabView) is CategoryListModel.All -> showAllCategoriesMenu(tabView)
menu.inflate(menuRes) is CategoryListModel.CategoryItem -> showCategoryMenu(tabView, item.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()
return true return true
} }
@@ -157,13 +142,6 @@ class FavouritesContainerFragment :
viewModel.createCategory(name) 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) { private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) { 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 { companion object {
fun newInstance() = FavouritesContainerFragment() fun newInstance() = FavouritesContainerFragment()

View File

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

View File

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

View File

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

View File

@@ -3,20 +3,36 @@ package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.ui.BaseViewModel 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.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import java.util.* import java.util.*
class FavouritesCategoriesViewModel( class FavouritesCategoriesViewModel(
private val repository: FavouritesRepository private val repository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
private var reorderJob: Job? = null private var reorderJob: Job? = null
val categories = repository.observeCategories() val allCategories = combine(
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) 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) { fun createCategory(name: String) {
launchJob { launchJob {
@@ -42,14 +58,40 @@ class FavouritesCategoriesViewModel(
} }
} }
fun setAllCategoriesVisible(isVisible: Boolean) {
settings.isAllFavouritesVisible = isVisible
}
fun reorderCategories(oldPos: Int, newPos: Int) { fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) { reorderJob = launchJob(Dispatchers.Default) {
prevJob?.join() 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 } val ids = items.mapTo(ArrayList(items.size)) { it.id }
Collections.swap(ids, oldPos, newPos) Collections.swap(ids, oldPos, newPos)
ids.remove(0L)
repository.reorderCategories(ids) 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 android.view.MotionEvent
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -8,23 +8,23 @@ import org.koitharu.kotatsu.databinding.ItemCategoryBinding
fun categoryAD( fun categoryAD(
clickListener: OnListItemClickListener<FavouriteCategory> clickListener: OnListItemClickListener<FavouriteCategory>
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryBinding>( ) = adapterDelegateViewBinding<CategoryListModel.CategoryItem, CategoryListModel, ItemCategoryBinding>(
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }
) { ) {
binding.imageViewMore.setOnClickListener { binding.imageViewMore.setOnClickListener {
clickListener.onItemClick(item, it) clickListener.onItemClick(item.category, it)
} }
@Suppress("ClickableViewAccessibility") @Suppress("ClickableViewAccessibility")
binding.imageViewHandle.setOnTouchListener { v, event -> binding.imageViewHandle.setOnTouchListener { v, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) { if (event.actionMasked == MotionEvent.ACTION_DOWN) {
clickListener.onItemLongClick(item, itemView) clickListener.onItemLongClick(item.category, itemView)
} else { } else {
false false
} }
} }
bind { 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.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.ui.LocalListViewModel import org.koitharu.kotatsu.local.ui.LocalListViewModel
import org.koitharu.kotatsu.utils.ExternalStorageHelper
val localModule val localModule
get() = module { get() = module {
@@ -15,8 +14,6 @@ val localModule
single { LocalStorageManager(androidContext(), get()) } single { LocalStorageManager(androidContext(), get()) }
single { LocalMangaRepository(get()) } single { LocalMangaRepository(get()) }
factory { ExternalStorageHelper(androidContext()) }
factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) } factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) }
viewModel { LocalListViewModel(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.content.Context
import android.os.StatFs import android.os.StatFs
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -11,7 +12,6 @@ import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import java.io.File
private const val DIR_NAME = "manga" private const val DIR_NAME = "manga"
private const val CACHE_DISK_PERCENTAGE = 0.02 private const val CACHE_DISK_PERCENTAGE = 0.02
@@ -71,7 +71,7 @@ class LocalStorageManager(
private fun getAvailableStorageDirs(): MutableSet<File> { private fun getAvailableStorageDirs(): MutableSet<File> {
val result = LinkedHashSet<File>() val result = LinkedHashSet<File>()
result += File(context.filesDir, DIR_NAME) result += File(context.filesDir, DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME) context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result)
result.retainAll { it.exists() || it.mkdirs() } result.retainAll { it.exists() || it.mkdirs() }
return result return result
} }
@@ -87,8 +87,8 @@ class LocalStorageManager(
private fun getCacheDirs(subDir: String): MutableSet<File> { private fun getCacheDirs(subDir: String): MutableSet<File> {
val result = LinkedHashSet<File>() val result = LinkedHashSet<File>()
result += File(context.cacheDir, subDir) result += File(context.cacheDir, subDir)
context.externalCacheDirs.mapTo(result) { context.externalCacheDirs.mapNotNullTo(result) {
File(it, subDir) File(it ?: return@mapNotNullTo null, subDir)
} }
return result return result
} }
@@ -110,4 +110,4 @@ class LocalStorageManager(
private fun File.isWriteable() = runCatching { private fun File.isWriteable() = runCatching {
canWrite() canWrite()
}.getOrDefault(false) }.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.longHashCode
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.koitharu.kotatsu.utils.AlphanumComparator 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.deleteAwait
import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.resolveName import org.koitharu.kotatsu.utils.ext.resolveName
@@ -34,6 +35,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
override val source = MangaSource.LOCAL override val source = MangaSource.LOCAL
private val filenameFilter = CbzFilter() private val filenameFilter = CbzFilter()
private val locks = CompositeMutex<Long>()
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
@@ -112,11 +114,18 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return file.deleteAwait() return file.deleteAwait()
} }
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) = runInterruptible(Dispatchers.IO) { suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
val uri = Uri.parse(manga.url) lockManga(manga.id)
val file = uri.toFile() try {
val cbz = CbzMangaOutput(file, manga) runInterruptible(Dispatchers.IO) {
CbzMangaOutput.filterChapters(cbz, ids) val uri = Uri.parse(manga.url)
val file = uri.toFile()
val cbz = CbzMangaOutput(file, manga)
CbzMangaOutput.filterChapters(cbz, ids)
}
} finally {
unlockManga(manga.id)
}
} }
@WorkerThread @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 -> private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty() 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.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.AppUpdateChecker import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
@@ -390,10 +391,14 @@ class MainActivity :
if (AppUpdateChecker.isUpdateSupported(this@MainActivity)) { if (AppUpdateChecker.isUpdateSupported(this@MainActivity)) {
AppUpdateChecker(this@MainActivity).checkIfNeeded() AppUpdateChecker(this@MainActivity).checkIfNeeded()
} }
if (!get<AppSettings>().isSourcesSelected) { val settings = get<AppSettings>()
withContext(Dispatchers.Main) { when {
!settings.isSourcesSelected -> withContext(Dispatchers.Main) {
OnboardDialogFragment.showWelcome(supportFragmentManager) OnboardDialogFragment.showWelcome(supportFragmentManager)
} }
settings.newSources.isNotEmpty() -> withContext(Dispatchers.Main) {
NewSourcesDialogFragment.show(supportFragmentManager)
}
} }
} }
} }

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.reader package org.koitharu.kotatsu.reader
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
val readerModule val readerModule
@@ -12,6 +14,8 @@ val readerModule
single { MangaDataRepository(get()) } single { MangaDataRepository(get()) }
single { PagesCache(get()) } single { PagesCache(get()) }
factory { PageSaveHelper(get(), androidContext()) }
viewModel { params -> viewModel { params ->
ReaderViewModel( ReaderViewModel(
intent = params[0], intent = params[0],
@@ -21,7 +25,7 @@ val readerModule
historyRepository = get(), historyRepository = get(),
shortcutsRepository = get(), shortcutsRepository = get(),
settings = 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() { private fun onIdle() {
synchronized(prefetchQueue) { synchronized(prefetchQueue) {
while (prefetchQueue.isNotEmpty()) { while (prefetchQueue.isNotEmpty()) {
@@ -151,7 +155,7 @@ class PageLoader : KoinComponent, Closeable {
} }
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File { 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" } check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl) val uri = Uri.parse(pageUrl)
return if (uri.scheme == "cbz") { 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 android.widget.Toast
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.* import androidx.core.view.*
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -187,10 +186,7 @@ class ReaderActivity :
R.id.action_save_page -> { R.id.action_save_page -> {
viewModel.getCurrentPage()?.also { page -> viewModel.getCurrentPage()?.also { page ->
viewModel.saveCurrentState(reader?.getCurrentState()) viewModel.saveCurrentState(reader?.getCurrentState())
val name = page.url.toUri().run { viewModel.saveCurrentPage(page, savePageRequest)
fragment ?: lastPathSegment ?: ""
}
savePageRequest.launch(name)
} ?: showWaitWhileLoading() } ?: showWaitWhileLoading()
} }
else -> return super.onOptionsItemSelected(item) else -> return super.onOptionsItemSelected(item)
@@ -199,9 +195,7 @@ class ReaderActivity :
} }
override fun onActivityResult(uri: Uri?) { override fun onActivityResult(uri: Uri?) {
if (uri != null) { viewModel.onActivityResult(uri)
viewModel.saveCurrentPage(uri)
}
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {

View File

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

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.getViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener 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.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader 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.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -81,7 +84,7 @@ class PagesThumbnailsSheet :
dataSet = thumbnails, dataSet = thumbnails,
coil = get(), coil = get(),
scope = viewLifecycleScope, scope = viewLifecycleScope,
loader = PageLoader().also { pageLoader = it }, loader = getPageLoader(),
clickListener = this@PagesThumbnailsSheet clickListener = this@PagesThumbnailsSheet
) )
addOnLayoutChangeListener(spanResolver) 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) { private inner class ToolbarController(toolbar: Toolbar) : BottomSheetToolbarController(toolbar) {
override fun onStateChanged(bottomSheet: View, newState: Int) { override fun onStateChanged(bottomSheet: View, newState: Int) {
super.onStateChanged(bottomSheet, newState) 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.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.backup.BackupViewModel import org.koitharu.kotatsu.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel 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.onboard.OnboardViewModel
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
@@ -27,4 +28,5 @@ val settingsModule
viewModel { ProtectSetupViewModel(get()) } viewModel { ProtectSetupViewModel(get()) }
viewModel { OnboardViewModel(get()) } viewModel { OnboardViewModel(get()) }
viewModel { SourcesSettingsViewModel(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) super.onViewCreated(view, savedInstanceState)
val adapter = SourceLocalesAdapter(this) val adapter = SourceLocalesAdapter(this)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.textViewTitle.setText(R.string.onboard_text)
viewModel.list.observeNotNull(viewLifecycleOwner) { viewModel.list.observeNotNull(viewLifecycleOwner) {
adapter.items = it adapter.items = it
} }

View File

@@ -83,7 +83,9 @@ fun sourceConfigDraggableItemDelegate(
on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable } on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable }
) { ) {
val eventListener = object : View.OnClickListener, View.OnTouchListener, val eventListener = object :
View.OnClickListener,
View.OnTouchListener,
CompoundButton.OnCheckedChangeListener { CompoundButton.OnCheckedChangeListener {
override fun onClick(v: View?) = listener.onItemSettingsClick(item) 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 package org.koitharu.kotatsu.utils.progress
import android.os.SystemClock import android.os.SystemClock
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.roundToLong import kotlin.math.roundToLong
@@ -11,6 +12,7 @@ class TimeLeftEstimator {
private var times = ArrayList<Int>() private var times = ArrayList<Int>()
private var lastTick: Tick? = null private var lastTick: Tick? = null
private val tooLargeTime = TimeUnit.DAYS.toMillis(1)
fun tick(value: Int, total: Int) { fun tick(value: Int, total: Int) {
if (total < 0) { if (total < 0) {
@@ -36,7 +38,8 @@ class TimeLeftEstimator {
} }
val timePerTick = times.average() val timePerTick = times.average()
val ticksLeft = progress.total - progress.value 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( 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"> android:orientation="vertical">
<TextView <TextView
android:id="@+id/textView_title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="?listPreferredItemPaddingStart" android:paddingStart="?listPreferredItemPaddingStart"
android:paddingTop="6dp" android:paddingTop="6dp"
android:paddingEnd="?listPreferredItemPaddingEnd" android:paddingEnd="?listPreferredItemPaddingEnd"
android:paddingBottom="6dp" 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 <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view" 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 <menu
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_hide"
android:title="@string/hide" />
<item <item
android:id="@+id/action_create" android:id="@+id/action_create"
android:title="@string/create_category" /> android:title="@string/create_category" />

View File

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

View File

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

View File

@@ -271,4 +271,11 @@
<string name="suggestions_excluded_genres">Sulje pois genrejä</string> <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="text_delete_local_manga_batch">Poista valitut kohteet laitteesta pysyvästi\?</string>
<string name="removal_completed">Poisto valmis</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> </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="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="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="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> </resources>

View File

@@ -272,4 +272,10 @@
<string name="removal_completed">Rimozione completata</string> <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="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="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> </resources>

View File

@@ -272,4 +272,10 @@
<string name="text_delete_local_manga_batch">選択した項目をデバイスから完全に削除しますか?</string> <string name="text_delete_local_manga_batch">選択した項目をデバイスから完全に削除しますか?</string>
<string name="removal_completed">削除が完了しました</string> <string name="removal_completed">削除が完了しました</string>
<string name="batch_manga_save_confirm">本当に選択したマンガを全編ダウンロードしますか?この動作は多くのトラフィックとストレージを消費する可能性があります</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> </resources>

View File

@@ -272,4 +272,10 @@
<string name="removal_completed">Remoção concluída</string> <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="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="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> </resources>

View File

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

View File

@@ -285,4 +285,6 @@
<string name="sync">Synchronization</string> <string name="sync">Synchronization</string>
<string name="sync_title">Sync your data</string> <string name="sync_title">Sync your data</string>
<string name="email_enter_hint">Enter your email to continue</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> </resources>