diff --git a/app/build.gradle b/app/build.gradle index 5e0481475..c7b570972 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 33 - versionCode 555 - versionName '5.2.3' + versionCode 556 + versionName '5.3' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 26ab7650a..2b2c8e6c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -105,6 +105,9 @@ + = runInterruptible(Dispatchers.IO) { + getAvailableStorageDirs() + } + suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) { uri.resolveFile(context) } + suspend fun setDirIsNoMedia(dir: File) = runInterruptible(Dispatchers.IO) { + File(dir, NOMEDIA).createNewFile() + } + fun takePermissions(uri: Uri) { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION contentResolver.takePersistableUriPermission(uri, flags) } - @Deprecated("") - fun getStorageDisplayName(file: File) = file.getStorageName(context) - suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) { val packageName = context.packageName if (dir.absolutePath.contains(packageName)) { @@ -104,9 +110,6 @@ class LocalStorageManager @Inject constructor( private fun getConfiguredStorageDirs(): MutableSet { val set = getAvailableStorageDirs() set.addAll(settings.userSpecifiedMangaDirectories) - settings.mangaStorageDir?.let { - set.add(it) - } return set } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 0b2d0ef5e..d02a89b92 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -33,7 +33,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) - addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick)) + addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick)) viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() } } @@ -45,7 +45,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner { FilterSheetFragment.show(childFragmentManager) } - override fun onScrolledToEnd() = Unit + override fun onScrolledToEnd() = viewModel.loadNextPage() override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { mode.menuInflater.inflate(R.menu.mode_local, menu) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt index f2be49775..bdceb0328 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt @@ -1,12 +1,15 @@ package org.koitharu.kotatsu.local.ui +import android.content.Context import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity class LocalListMenuProvider( + private val context: Context, private val onImportClick: Function0, ) : MenuProvider { @@ -20,6 +23,12 @@ class LocalListMenuProvider( onImportClick() true } + + R.id.action_settings -> { + context.startActivity(MangaDirectoriesActivity.newIntent(context)) + true + } + else -> false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 99723036a..04b7ed947 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.local.ui +import android.content.SharedPreferences import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -24,7 +25,7 @@ class LocalListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, filter: FilterCoordinator, - settings: AppSettings, + private val settings: AppSettings, downloadScheduler: DownloadWorker.Scheduler, listExtraProvider: ListExtraProvider, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, @@ -36,7 +37,7 @@ class LocalListViewModel @Inject constructor( settings, listExtraProvider, downloadScheduler, -) { +), SharedPreferences.OnSharedPreferenceChangeListener { val onMangaRemoved = MutableEventFlow() @@ -47,6 +48,18 @@ class LocalListViewModel @Inject constructor( loadList(filter.snapshot(), append = false).join() } } + settings.subscribe(this) + } + + override fun onCleared() { + settings.unsubscribe(this) + super.onCleared() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == AppSettings.KEY_LOCAL_MANGA_DIRS) { + onRefresh() + } } fun delete(ids: Set) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt index 85eb56d6d..7572b3965 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt @@ -16,14 +16,13 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog -import java.io.File +import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity import javax.inject.Inject @AndroidEntryPoint class DownloadsSettingsFragment : BasePreferenceFragment(R.string.downloads), - SharedPreferences.OnSharedPreferenceChangeListener, - StorageSelectDialog.OnStorageSelectListener { + SharedPreferences.OnSharedPreferenceChangeListener { @Inject lateinit var storageManager: LocalStorageManager @@ -38,6 +37,7 @@ class DownloadsSettingsFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() + findPreference(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount() settings.subscribe(this) } @@ -52,6 +52,10 @@ class DownloadsSettingsFragment : findPreference(key)?.bindStorageName() } + AppSettings.KEY_LOCAL_MANGA_DIRS -> { + findPreference(key)?.bindDirectoriesCount() + } + AppSettings.KEY_DOWNLOADS_WIFI -> { updateDownloadsConstraints() } @@ -65,14 +69,15 @@ class DownloadsSettingsFragment : true } + AppSettings.KEY_LOCAL_MANGA_DIRS -> { + startActivity(MangaDirectoriesActivity.newIntent(preference.context)) + true + } + else -> super.onPreferenceTreeClick(preference) } } - override fun onStorageSelected(file: File) { - settings.mangaStorageDir = file - } - private fun Preference.bindStorageName() { viewLifecycleScope.launch { val storage = storageManager.getDefaultWriteableDir() @@ -84,6 +89,13 @@ class DownloadsSettingsFragment : } } + private fun Preference.bindDirectoriesCount() { + viewLifecycleScope.launch { + val dirs = storageManager.getReadableDirs().size + summary = resources.getQuantityString(R.plurals.items, dirs, dirs) + } + } + private fun updateDownloadsConstraints() { val preference = findPreference(AppSettings.KEY_DOWNLOADS_WIFI) viewLifecycleScope.launch { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt index 5113bb2ea..b6226aa03 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt @@ -9,6 +9,7 @@ class DirectoryModel( @StringRes val titleRes: Int, val file: File?, val isChecked: Boolean, + val isAvailable: Boolean, ) : ListModel { override fun equals(other: Any?): Boolean { @@ -20,7 +21,8 @@ class DirectoryModel( if (title != other.title) return false if (titleRes != other.titleRes) return false if (file != other.file) return false - return isChecked == other.isChecked + if (isChecked != other.isChecked) return false + return isAvailable == other.isAvailable } override fun hashCode(): Int { @@ -28,6 +30,7 @@ class DirectoryModel( result = 31 * result + titleRes result = 31 * result + (file?.hashCode() ?: 0) result = 31 * result + isChecked.hashCode() + result = 31 * result + isAvailable.hashCode() return result } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt index f276ef639..ab9d565d3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt @@ -36,7 +36,10 @@ class MangaDirectorySelectDialog : AlertDialogFragment() init { - launchJob { - val defaultValue = storageManager.getDefaultWriteableDir() - val available = storageManager.getWriteableDirs() - items.value = buildList(available.size + 1) { - available.mapTo(this) { dir -> - DirectoryModel( - title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), - titleRes = 0, - file = dir, - isChecked = dir == defaultValue, - ) - } - this += DirectoryModel( - title = null, - titleRes = R.string.pick_custom_directory, - file = null, - isChecked = false, - ) - } - } + refresh() } fun onItemClick(item: DirectoryModel) { @@ -62,9 +43,36 @@ class MangaDirectorySelectViewModel @Inject constructor( if (!dir.canWrite()) { throw AccessDeniedException(dir) } - settings.userSpecifiedMangaDirectories += dir - settings.mangaStorageDir = dir + if (dir !in storageManager.getApplicationStorageDirs()) { + settings.mangaStorageDir = dir + storageManager.setDirIsNoMedia(dir) + } onDismissDialog.call(Unit) } } + + fun refresh() { + launchJob(Dispatchers.Default) { + val defaultValue = storageManager.getDefaultWriteableDir() + val available = storageManager.getWriteableDirs() + items.value = buildList(available.size + 1) { + available.mapTo(this) { dir -> + DirectoryModel( + title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), + titleRes = 0, + file = dir, + isChecked = dir == defaultValue, + isAvailable = true, + ) + } + this += DirectoryModel( + title = null, + titleRes = R.string.pick_custom_directory, + file = null, + isChecked = false, + isAvailable = true, + ) + } + } + } } 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 new file mode 100644 index 000000000..e4aa8c5fe --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.settings.storage.directories + +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +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.ext.textAndVisible +import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding +import org.koitharu.kotatsu.settings.storage.DirectoryModel + +fun directoryConfigAD( + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) }, +) { + + binding.imageViewRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) } + + bind { + binding.textViewTitle.text = item.title ?: getString(item.titleRes) + binding.textViewSubtitle.textAndVisible = item.file?.absolutePath + binding.imageViewRemove.isVisible = item.isChecked + binding.textViewTitle.drawableStart = if (item.isAvailable) { + null + } else { + ContextCompat.getDrawable(context, R.drawable.ic_alert_outline) + } + } +} 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 new file mode 100644 index 000000000..d4f3283f9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt @@ -0,0 +1,93 @@ +package org.koitharu.kotatsu.settings.storage.directories + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +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 { + + private val viewModel: MangaDirectoriesViewModel by viewModels() + private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { + if (it != null) viewModel.onCustomDirectoryPicked(it) + } + private val permissionRequestLauncher = registerForActivityResult( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + RequestStorageManagerPermissionContract() + } else { + ActivityResultContracts.RequestPermission() + }, + ) { + if (it) { + viewModel.updateList() + pickFileTreeLauncher.launch(null) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this)) + viewBinding.recyclerView.adapter = adapter + viewBinding.fabAdd.setOnClickListener(this) + viewModel.items.observe(this) { adapter.items = it } + viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it } + viewModel.onError.observeEvent( + this, + SnackbarErrorObserver(viewBinding.root, null, exceptionResolver) { + if (it) viewModel.updateList() + }, + ) + } + + override fun onItemClick(item: DirectoryModel, view: View) { + viewModel.onRemoveClick(item.file ?: return) + } + + override fun onClick(v: View?) { + permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + override fun onWindowInsetsChanged(insets: Insets) { + viewBinding.fabAdd.updateLayoutParams { + rightMargin = topMargin + insets.right + leftMargin = topMargin + insets.left + bottomMargin = topMargin + insets.bottom + } + viewBinding.root.updatePadding( + left = insets.left, + right = insets.right, + ) + viewBinding.recyclerView.updatePadding( + bottom = insets.bottom, + ) + } + + companion object { + + fun newIntent(context: Context) = Intent(context, MangaDirectoriesActivity::class.java) + } +} 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 new file mode 100644 index 000000000..6450e59ac --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt @@ -0,0 +1,85 @@ +package org.koitharu.kotatsu.settings.storage.directories + +import android.net.Uri +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import okio.FileNotFoundException +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +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, +) : BaseViewModel() { + + val items = MutableStateFlow(emptyList()) + private var loadingJob: Job? = null + + init { + loadList() + } + + fun updateList() { + loadList() + } + + fun onCustomDirectoryPicked(uri: Uri) { + launchLoadingJob(Dispatchers.Default) { + loadingJob?.cancelAndJoin() + storageManager.takePermissions(uri) + val dir = storageManager.resolveUri(uri) ?: throw FileNotFoundException() + if (!dir.canWrite()) { + 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() + } + + private fun loadList() { + val prevJob = loadingJob + loadingJob = launchJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val applicationDirs = storageManager.getApplicationStorageDirs() + val customDirs = settings.userSpecifiedMangaDirectories + items.value = buildList(applicationDirs.size + customDirs.size) { + applicationDirs.mapTo(this) { dir -> + DirectoryModel( + title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), + titleRes = 0, + file = dir, + isChecked = false, + isAvailable = dir.canRead() && dir.canWrite(), + ) + } + customDirs.mapTo(this) { dir -> + DirectoryModel( + title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), + titleRes = 0, + file = dir, + isChecked = true, + isAvailable = dir.canRead() && dir.canWrite(), + ) + } + } + } + } +} diff --git a/app/src/main/res/layout/activity_categories.xml b/app/src/main/res/layout/activity_categories.xml index 07ab6871f..97799592d 100644 --- a/app/src/main/res/layout/activity_categories.xml +++ b/app/src/main/res/layout/activity_categories.xml @@ -58,7 +58,6 @@ android:layout_height="wrap_content" android:layout_margin="16dp" android:contentDescription="@string/add_new_category" - android:src="@drawable/ic_add" android:text="@string/create_category" app:fabSize="normal" app:icon="@drawable/ic_add" diff --git a/app/src/main/res/layout/activity_manga_directories.xml b/app/src/main/res/layout/activity_manga_directories.xml new file mode 100644 index 000000000..5e8ffdf4b --- /dev/null +++ b/app/src/main/res/layout/activity_manga_directories.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_storage_config.xml b/app/src/main/res/layout/item_storage_config.xml new file mode 100644 index 000000000..e1a552325 --- /dev/null +++ b/app/src/main/res/layout/item_storage_config.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/opt_local.xml b/app/src/main/res/menu/opt_local.xml index 517cb28f9..af3cce235 100644 --- a/app/src/main/res/menu/opt_local.xml +++ b/app/src/main/res/menu/opt_local.xml @@ -9,4 +9,10 @@ android:title="@string/_import" app:showAsAction="never" /> - \ No newline at end of file + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c95c0b16..90f39ddb6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,4 +448,5 @@ Custom directory Pick custom directory You have no access to this file or directory + Local manga directories diff --git a/app/src/main/res/xml/pref_downloads.xml b/app/src/main/res/xml/pref_downloads.xml index 82554d8b9..c08522ac4 100644 --- a/app/src/main/res/xml/pref_downloads.xml +++ b/app/src/main/res/xml/pref_downloads.xml @@ -1,6 +1,12 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + + android:title="@string/downloads_wifi_only" + app:allowDividerAbove="true" />