diff --git a/app/build.gradle b/app/build.gradle index 4a72ad3bb..541d2759f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 23 targetSdk = 36 - versionCode = 1031 - versionName = '9.3' + versionCode = 1032 + versionName = '9.4' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt index 88f3593ac..e364af3af 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt @@ -5,7 +5,10 @@ import android.view.View import androidx.annotation.Px import androidx.recyclerview.widget.RecyclerView -class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDecoration() { +class SpacingItemDecoration( + @Px private val spacing: Int, + private val withBottomPadding: Boolean, +) : RecyclerView.ItemDecoration() { override fun getItemOffsets( outRect: Rect, @@ -13,6 +16,6 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec parent: RecyclerView, state: RecyclerView.State, ) { - outRect.set(spacing, spacing, spacing, spacing) + outRect.set(spacing, spacing, spacing, if (withBottomPadding) spacing else 0) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt index e11b9b616..fe1efdd0d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -13,11 +13,11 @@ import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat import androidx.core.net.toFile import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.Cache +import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.exceptions.NonFileUriException import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.computeSize @@ -39,8 +39,8 @@ private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB @Reusable class LocalStorageManager @Inject constructor( - @ApplicationContext private val context: Context, - private val settings: AppSettings, + @LocalizedAppContext private val context: Context, + private val settings: AppSettings, ) { val contentResolver: ContentResolver diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt index fefed0f7e..66ce519ba 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt @@ -51,6 +51,7 @@ class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipC binding.chipsType.onChipClickListener = this binding.chipBackup.setOnClickListener(this) binding.chipSync.setOnClickListener(this) + binding.chipDirectories.setOnClickListener(this) viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged) viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged) @@ -86,6 +87,10 @@ class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipC val accountType = getString(R.string.account_type_sync) am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) } + + R.id.chip_directories -> { + router.openDirectoriesSettings() + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt index 2fdff892e..dccd22167 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt @@ -37,7 +37,7 @@ fun searchResultsAD( binding.recyclerView.addItemDecoration(selectionDecoration) binding.recyclerView.adapter = adapter val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) - binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) + binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = true)) val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener) binding.buttonMore.setOnClickListener(eventListener) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt index 2eac1ab88..f361b5b4f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt @@ -29,7 +29,7 @@ fun searchSuggestionMangaListAD( left = recyclerView.paddingLeft - spacing, right = recyclerView.paddingRight - spacing, ) - recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) + recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = true)) val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0, 0) bind { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt index 57c6f9fca..71d98a923 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt @@ -1,38 +1,66 @@ package org.koitharu.kotatsu.settings.storage.directories +import android.view.View import androidx.core.content.ContextCompat -import androidx.core.view.isVisible +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.color +import androidx.core.view.isGone import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.drawableStart +import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.setTooltipCompat import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding -import org.koitharu.kotatsu.settings.storage.DirectoryModel +import org.koitharu.kotatsu.databinding.ItemStorageConfig2Binding fun directoryConfigAD( - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) }, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemStorageConfig2Binding.inflate(layoutInflater, parent, false) }, ) { - binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) } - binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription) + binding.buttonRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) } + binding.buttonRemove.setTooltipCompat(binding.buttonRemove.contentDescription) - bind { - binding.textViewTitle.text = item.title ?: getString(item.titleRes) - binding.textViewSubtitle.textAndVisible = item.file?.absolutePath - binding.buttonRemove.isVisible = item.isRemovable - binding.buttonRemove.isEnabled = !item.isChecked - binding.textViewTitle.drawableStart = if (!item.isAvailable) { - ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)?.apply { - setTint(ContextCompat.getColor(context, R.color.warning)) - } - } else if (item.isChecked) { - ContextCompat.getDrawable(context, R.drawable.ic_download) - } else { - null - } - } + bind { + binding.textViewTitle.text = item.title + binding.textViewSubtitle.text = item.path.absolutePath + binding.buttonRemove.isGone = item.isAppPrivate + binding.buttonRemove.isEnabled = !item.isDefault + binding.spacer.visibility = if (item.isAppPrivate) { + View.INVISIBLE + } else { + View.GONE + } + binding.textViewInfo.textAndVisible = buildSpannedString { + if (item.isDefault) { + bold { + append(getString(R.string.download_default_directory)) + } + } + if (!item.isAccessible) { + if (isNotEmpty()) appendLine() + color( + context.getThemeColor( + androidx.appcompat.R.attr.colorError, + ContextCompat.getColor(context, R.color.common_red), + ), + ) { + append(getString(R.string.no_write_permission_to_file)) + } + } + if (item.isAppPrivate) { + if (isNotEmpty()) appendLine() + append(getString(R.string.private_app_directory_warning)) + } + } + binding.indicatorSize.max = FileSize.BYTES.convert(item.available, FileSize.KILOBYTES).toInt() + binding.indicatorSize.progress = FileSize.BYTES.convert(item.size, FileSize.KILOBYTES).toInt() + binding.textViewSize.text = context.getString( + R.string.available_pattern, + FileSize.BYTES.format(context, item.available), + ) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigDiffCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigDiffCallback.kt new file mode 100644 index 000000000..1a60ba422 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigDiffCallback.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.settings.storage.directories + +import androidx.recyclerview.widget.DiffUtil.ItemCallback + +class DirectoryConfigDiffCallback : ItemCallback() { + + override fun areItemsTheSame(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Boolean { + return oldItem.path == newItem.path + } + + override fun areContentsTheSame(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: DirectoryConfigModel, newItem: DirectoryConfigModel): Any? { + return if (oldItem.isDefault != newItem.isDefault) { + Unit + } else { + super.getChangePayload(oldItem, newItem) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigModel.kt new file mode 100644 index 000000000..418bd1edf --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigModel.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.settings.storage.directories + +import org.koitharu.kotatsu.list.ui.model.ListModel +import java.io.File + +data class DirectoryConfigModel( + val title: String, + val path: File, + val isDefault: Boolean, + val isAppPrivate: Boolean, + val isAccessible: Boolean, + val size: Long, + val available: Long, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is DirectoryConfigModel && path == other.path + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt index 514c3238d..c229f6448 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt @@ -20,18 +20,17 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding -import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback -import org.koitharu.kotatsu.settings.storage.DirectoryModel import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract @AndroidEntryPoint class MangaDirectoriesActivity : BaseActivity(), - OnListItemClickListener, View.OnClickListener { + OnListItemClickListener, View.OnClickListener { private val viewModel: MangaDirectoriesViewModel by viewModels() private val pickFileTreeLauncher = OpenDocumentTreeHelper( @@ -63,8 +62,10 @@ class MangaDirectoriesActivity : BaseActivity() super.onCreate(savedInstanceState) setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) - val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this)) - viewBinding.recyclerView.adapter = adapter + val adapter = AsyncListDifferDelegationAdapter(DirectoryConfigDiffCallback(), directoryConfigAD(this)) + val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing_large) + viewBinding.recyclerView.adapter = adapter + viewBinding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing, withBottomPadding = false)) viewBinding.fabAdd.setOnClickListener(this) viewModel.items.observe(this) { adapter.items = it } viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it } @@ -76,8 +77,8 @@ class MangaDirectoriesActivity : BaseActivity() ) } - override fun onItemClick(item: DirectoryModel, view: View) { - viewModel.onRemoveClick(item.file ?: return) + override fun onItemClick(item: DirectoryConfigModel, view: View) { + viewModel.onRemoveClick(item.path) } override fun onClick(v: View?) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt index da71c261d..986dfe340 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.settings.storage.directories import android.net.Uri +import android.os.StatFs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -8,82 +9,87 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.isReadable import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.settings.storage.DirectoryModel import java.io.File import javax.inject.Inject @HiltViewModel class MangaDirectoriesViewModel @Inject constructor( - private val storageManager: LocalStorageManager, - private val settings: AppSettings, + private val storageManager: LocalStorageManager, + private val settings: AppSettings, ) : BaseViewModel() { - val items = MutableStateFlow(emptyList()) - private var loadingJob: Job? = null + val items = MutableStateFlow(emptyList()) + private var loadingJob: Job? = null - init { - loadList() - } + init { + loadList() + } - fun updateList() { - loadList() - } + fun updateList() { + loadList() + } - fun onCustomDirectoryPicked(uri: Uri) { - launchLoadingJob(Dispatchers.Default) { - loadingJob?.cancelAndJoin() - storageManager.takePermissions(uri) - val dir = storageManager.resolveUri(uri) - if (!dir.canRead()) { - throw AccessDeniedException(dir) - } - if (dir !in storageManager.getApplicationStorageDirs()) { - settings.userSpecifiedMangaDirectories += dir - loadList() - } - } - } + fun onCustomDirectoryPicked(uri: Uri) { + launchLoadingJob(Dispatchers.Default) { + loadingJob?.cancelAndJoin() + storageManager.takePermissions(uri) + val dir = storageManager.resolveUri(uri) + if (!dir.canRead()) { + throw AccessDeniedException(dir) + } + if (dir !in storageManager.getApplicationStorageDirs()) { + settings.userSpecifiedMangaDirectories += dir + loadList() + } + } + } - fun onRemoveClick(directory: File) { - settings.userSpecifiedMangaDirectories -= directory - if (settings.mangaStorageDir == directory) { - settings.mangaStorageDir = null - } - loadList() - } + fun onRemoveClick(directory: File) { + settings.userSpecifiedMangaDirectories -= directory + if (settings.mangaStorageDir == directory) { + settings.mangaStorageDir = null + } + loadList() + } - private fun loadList() { - val prevJob = loadingJob - loadingJob = launchJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - val downloadDir = storageManager.getDefaultWriteableDir() - val applicationDirs = storageManager.getApplicationStorageDirs() - val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs - items.value = buildList(applicationDirs.size + customDirs.size) { - applicationDirs.mapTo(this) { dir -> - DirectoryModel( - title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), - titleRes = 0, - file = dir, - isChecked = dir == downloadDir, - isAvailable = dir.isReadable() && dir.isWriteable(), - isRemovable = false, - ) - } - customDirs.mapTo(this) { dir -> - DirectoryModel( - title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), - titleRes = 0, - file = dir, - isChecked = dir == downloadDir, - isAvailable = dir.isReadable() && dir.isWriteable(), - isRemovable = true, - ) - } - } - } - } + private fun loadList() { + val prevJob = loadingJob + loadingJob = launchJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val downloadDir = storageManager.getDefaultWriteableDir() + val applicationDirs = storageManager.getApplicationStorageDirs() + val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs + items.value = buildList(applicationDirs.size + customDirs.size) { + applicationDirs.mapTo(this) { dir -> + dir.toDirectoryModel( + isDefault = dir == downloadDir, + isAppPrivate = true, + ) + } + customDirs.mapTo(this) { dir -> + dir.toDirectoryModel( + isDefault = dir == downloadDir, + isAppPrivate = false, + ) + } + } + } + } + + private suspend fun File.toDirectoryModel( + isDefault: Boolean, + isAppPrivate: Boolean, + ) = DirectoryConfigModel( + title = storageManager.getDirectoryDisplayName(this, isFullPath = false), + path = this, + isDefault = isDefault, + isAccessible = isReadable() && isWriteable(), + isAppPrivate = isAppPrivate, + size = computeSize(), + available = StatFs(this.absolutePath).availableBytes, + ) } diff --git a/app/src/main/res/layout/activity_manga_directories.xml b/app/src/main/res/layout/activity_manga_directories.xml index 5e8ffdf4b..d871f4b37 100644 --- a/app/src/main/res/layout/activity_manga_directories.xml +++ b/app/src/main/res/layout/activity_manga_directories.xml @@ -1,57 +1,60 @@ - - - - - - - - - - - - - - + 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="match_parent"> + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_storage_config2.xml b/app/src/main/res/layout/item_storage_config2.xml new file mode 100644 index 000000000..932b058fa --- /dev/null +++ b/app/src/main/res/layout/item_storage_config2.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + +