feat: Composable Data and storage Settings

This commit is contained in:
Ahmad Ansori Palembani 2024-06-10 12:23:53 +07:00
parent b2b93d6d50
commit 9d0cefa11f
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
18 changed files with 748 additions and 429 deletions

View file

@ -148,6 +148,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "17"
@ -295,6 +296,8 @@ dependencies {
implementation(kotlinx.immutable)
"coreLibraryDesugaring"(libs.desugar)
// Tests
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.bundles.test.runtime)

View file

@ -4,10 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import com.google.common.collect.ImmutableList
import com.google.common.collect.ImmutableMap
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import eu.kanade.tachiyomi.core.preference.Preference as PreferenceData
sealed class Preference {

View file

@ -27,4 +27,12 @@ interface ComposableSettings {
appBarActions = { AppBarAction() },
)
}
companion object {
// HACK: for the background blipping thingy.
// The title of the target PreferenceItem
// Set before showing the destination screen and reset after
// See BasePreferenceWidget.highlightBackground
var highlightKey: String? = null
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@ -23,7 +24,9 @@ import dev.yokai.presentation.component.preference.widget.PreferenceGroupHeader
import eu.kanade.tachiyomi.core.preference.collectAsState
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.compose.LocalBackPress
import kotlinx.coroutines.delay
import uy.kohesive.injekt.injectLazy
import kotlin.time.Duration.Companion.seconds
@Composable
fun SettingsScaffold(
@ -62,12 +65,23 @@ fun PreferenceScreen(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val highlightKey = ComposableSettings.highlightKey
if (highlightKey != null) {
LaunchedEffect(Unit) {
val i = items.findHighlightedIndex(highlightKey)
if (i >= 0) {
delay(0.5.seconds)
listState.animateScrollToItem(i)
}
ComposableSettings.highlightKey = null
}
}
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
state = listState
) {
val highlightKey = null as String? // TODO
items.fastForEachIndexed { i, preference ->
when (preference) {
is Preference.PreferenceGroup -> {
@ -94,3 +108,17 @@ fun PreferenceScreen(
}
}
}
private fun List<Preference>.findHighlightedIndex(highlightKey: String): Int {
return flatMap {
if (it is Preference.PreferenceGroup) {
buildList<String?> {
add(null) // Header
addAll(it.preferenceItems.map { groupItem -> groupItem.title })
add(null) // Spacer
}
} else {
listOf(it.title)
}
}.indexOfFirst { it == highlightKey }
}

View file

@ -1,4 +1,4 @@
package dev.yokai.presentation.settings
package dev.yokai.presentation.settings.screen
import android.content.ActivityNotFoundException
import android.content.Intent
@ -16,20 +16,37 @@ import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.google.common.collect.ImmutableList
import co.touchlab.kermit.Logger
import com.hippo.unifile.UniFile
import dev.yokai.domain.storage.StoragePreferences
import dev.yokai.presentation.component.preference.Preference
import dev.yokai.presentation.component.preference.storageLocationText
import dev.yokai.presentation.component.preference.widget.BasePreferenceWidget
import dev.yokai.presentation.component.preference.widget.PrefsHorizontalPadding
import dev.yokai.presentation.settings.ComposableSettings
import dev.yokai.presentation.settings.screen.data.StorageInfo
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.create.BackupCreatorJob
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.system.e
import eu.kanade.tachiyomi.util.system.launchNonCancellable
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.tryTakePersistableUriPermission
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
object SettingsDataScreen : ComposableSettings {
@ -41,9 +58,10 @@ object SettingsDataScreen : ComposableSettings {
val storagePreferences: StoragePreferences by injectLazy()
val preferences: PreferencesHelper by injectLazy()
return ImmutableList.of(
return persistentListOf(
getStorageLocationPreference(storagePreferences = storagePreferences),
getBackupAndRestoreGroup(preferences = preferences),
getDataGroup(),
)
}
@ -86,9 +104,11 @@ object SettingsDataScreen : ComposableSettings {
@Composable
private fun getBackupAndRestoreGroup(preferences: PreferencesHelper): Preference.PreferenceGroup {
val context = LocalContext.current
return Preference.PreferenceGroup(
title = stringResource(R.string.backup_and_restore),
preferenceItems = ImmutableList.of(
preferenceItems = persistentListOf(
Preference.PreferenceItem.CustomPreference(
title = stringResource(R.string.backup_and_restore),
) {
@ -120,6 +140,87 @@ object SettingsDataScreen : ComposableSettings {
},
)
},
// Automatic backups
Preference.PreferenceItem.ListPreference(
pref = preferences.backupInterval(),
title = stringResource(R.string.backup_frequency),
entries = persistentMapOf(
0 to stringResource(R.string.manual),
6 to stringResource(R.string.every_6_hours),
12 to stringResource(R.string.every_12_hours),
24 to stringResource(R.string.daily),
48 to stringResource(R.string.every_2_days),
168 to stringResource(R.string.weekly),
),
onValueChanged = {
BackupCreatorJob.setupTask(context, it)
true
},
),
Preference.PreferenceItem.InfoPreference(
stringResource(R.string.backup_info)
/*+ "\n\n" + stringResource(R.string.last_auto_backup_info, relativeTimeSpanString(lastAutoBackup))*/,
),
),
)
}
@Composable
private fun getDataGroup(): Preference.PreferenceGroup {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// TODO
// val libraryPreferences = remember { Injekt.get<LibraryPreferences>() }
val coverCache = remember { Injekt.get<CoverCache>() }
val chapterCache = remember { Injekt.get<ChapterCache>() }
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize }
return Preference.PreferenceGroup(
title = stringResource(R.string.storage_usage),
preferenceItems = persistentListOf(
Preference.PreferenceItem.CustomPreference(
title = stringResource(R.string.storage_usage),
) {
BasePreferenceWidget(
subcomponent = {
StorageInfo(
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
)
},
)
},
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.clear_chapter_cache),
subtitle = stringResource(R.string.used_, cacheReadableSize),
onClick = {
scope.launchNonCancellable {
try {
val deletedFiles = chapterCache.clear()
withUIContext {
context.toast(context.resources?.getQuantityString(
R.plurals.cache_cleared,
deletedFiles,
deletedFiles,
))
cacheReadableSizeSema++
}
} catch (e: Throwable) {
Logger.e(e)
withUIContext { context.toast(R.string.cache_delete_error) }
}
}
},
),
/*
Preference.PreferenceItem.SwitchPreference(
pref = libraryPreferences.autoClearChapterCache(),
title = stringResource(MR.strings.pref_auto_clear_chapter_cache),
),
*/
),
)
}

View file

@ -0,0 +1,74 @@
package dev.yokai.presentation.settings.screen.data
import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.yokai.presentation.core.util.secondaryItemAlpha
import dev.yokai.presentation.theme.Size
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.storage.DiskUtil
import java.io.File
@Composable
fun StorageInfo(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val storages = remember { DiskUtil.getExternalStorages(context) }
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(Size.small),
) {
storages.forEach {
StorageInfo(it)
}
}
}
@Composable
private fun StorageInfo(
file: File,
) {
val context = LocalContext.current
val available = remember(file) { DiskUtil.getAvailableStorageSpace(file) }
val availableText = remember(available) { Formatter.formatFileSize(context, available) }
val total = remember(file) { DiskUtil.getTotalStorageSpace(file) }
val totalText = remember(total) { Formatter.formatFileSize(context, total) }
Column(
verticalArrangement = Arrangement.spacedBy(Size.tiny),
) {
Text(
text = file.absolutePath,
style = MaterialTheme.typography.headlineMedium,
)
LinearProgressIndicator(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.fillMaxWidth()
.height(12.dp),
progress = { (1 - (available / total.toFloat())) },
)
Text(
text = stringResource(R.string.available_disk_space_info, availableText, totalText),
modifier = Modifier.secondaryItemAlpha(),
style = MaterialTheme.typography.bodySmall,
)
}
}

View file

@ -14,7 +14,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Response
@ -227,6 +226,17 @@ class ChapterCache(private val context: Context) {
}
}
fun clear(): Int {
val files = cacheDir.listFiles() ?: return 0
var deletedFiles = 0
files.forEach { file ->
if (removeFileFromCache(file.name)) {
deletedFiles++
}
}
return deletedFiles
}
private fun getKey(chapter: Chapter): String {
return "${chapter.manga_id}${chapter.url}"
}

View file

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.ui.setting
import android.content.Context
import android.util.AttributeSet
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
class LongClickablePreference(context: Context, attrs: AttributeSet? = null) : Preference(context, attrs) {
var onPreferenceLongClickListener: ((Preference) -> Boolean)? = null
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
holder.itemView.setOnLongClickListener {
performLongClick()
}
}
private fun performLongClick(): Boolean {
if (!isEnabled || !isSelectable) {
return false
}
return onPreferenceLongClickListener?.invoke(this) ?: false
}
}

View file

@ -37,6 +37,10 @@ inline fun PreferenceManager.newScreen(block: (@DSL PreferenceScreen).() -> Unit
return createPreferenceScreen(context).also { it.block() }
}
inline fun PreferenceGroup.preferenceLongClickable(block: (@DSL LongClickablePreference).() -> Unit): Preference {
return initThenAdd(LongClickablePreference(context), block)
}
inline fun PreferenceGroup.preference(block: (@DSL Preference).() -> Unit): Preference {
return initThenAdd(Preference(context), block)
}
@ -147,6 +151,10 @@ inline fun <P : Preference> PreferenceGroup.addThenInit(p: P, block: P.() -> Uni
}
}
inline fun LongClickablePreference.onLongClick(crossinline block: () -> Unit) {
onPreferenceLongClickListener = { block(); true }
}
inline fun Preference.onClick(crossinline block: () -> Unit) {
setOnPreferenceClickListener { block(); true }
}
@ -248,3 +256,4 @@ var Preference.summaryRes: Int
var Preference.iconTint: Int
get() = 0 // set only
set(value) { icon?.setTint(value) }

View file

@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController
abstract class SettingsComposeController: BaseComposeController(), SettingsControllerInterface {
override fun getTitle(): String? = __getTitle()
override fun getSearchTitle(): String? = __getTitle()
fun setTitle() = __setTitle()

View file

@ -1,406 +1,9 @@
package eu.kanade.tachiyomi.ui.setting.controllers
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceScreen
import com.hippo.unifile.UniFile
import dev.yokai.domain.storage.StorageManager
import dev.yokai.domain.storage.StoragePreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.BackupCreatorJob
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.setting.SettingsLegacyController
import eu.kanade.tachiyomi.ui.setting.bindTo
import eu.kanade.tachiyomi.ui.setting.infoPreference
import eu.kanade.tachiyomi.ui.setting.intListPreference
import eu.kanade.tachiyomi.ui.setting.onChange
import eu.kanade.tachiyomi.ui.setting.onClick
import eu.kanade.tachiyomi.ui.setting.preference
import eu.kanade.tachiyomi.ui.setting.preferenceCategory
import eu.kanade.tachiyomi.ui.setting.summaryRes
import eu.kanade.tachiyomi.ui.setting.titleRes
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.disableItems
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.materialAlertDialog
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.tryTakePersistableUriPermission
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import uy.kohesive.injekt.injectLazy
import dev.yokai.presentation.settings.ComposableSettings
import dev.yokai.presentation.settings.screen.SettingsDataScreen
import eu.kanade.tachiyomi.ui.setting.SettingsComposeController
class SettingsDataController : SettingsLegacyController() {
/**
* Flags containing information of what to backup.
*/
private var backupFlags: BackupOptions = BackupOptions()
internal val storagePreferences: StoragePreferences by injectLazy()
internal val storageManager: StorageManager by injectLazy()
private val coverCache: CoverCache by injectLazy()
private val chapterCache: ChapterCache by injectLazy()
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
titleRes = R.string.data_and_storage
preference {
key = "pref_storage_location"
bindTo(storagePreferences.baseStorageDirectory())
titleRes = R.string.storage_location
onClick {
try {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, CODE_DATA_DIR)
} catch (e: ActivityNotFoundException) {
activity?.toast(R.string.file_picker_error)
}
}
storagePreferences.baseStorageDirectory().changes()
.onEach { path ->
summary = UniFile.fromUri(context, path.toUri())?.let { dir ->
dir.filePath ?: context.getString(R.string.invalid_location, dir.uri)
} ?: context.getString(R.string.invalid_location_generic)
}
.launchIn(viewScope)
}
preference {
key = "pref_create_backup"
titleRes = R.string.create_backup
summaryRes = R.string.can_be_used_to_restore
onClick {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
}
if (!BackupCreatorJob.isManualJobRunning(context)) {
showBackupCreateDialog()
} else {
context.toast(R.string.backup_in_progress)
}
}
}
preference {
key = "pref_restore_backup"
titleRes = R.string.restore_backup
summaryRes = R.string.restore_from_backup_file
onClick {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
}
if (!BackupRestoreJob.isRunning(context)) {
(activity as? MainActivity)?.getExtensionUpdates(true)
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
storageManager.getBackupsDirectory()?.let {
intent.setDataAndType(it.uri, "*/*")
}
val title = resources?.getString(R.string.select_backup_file)
val chooser = Intent.createChooser(intent, title)
startActivityForResult(chooser, CODE_BACKUP_RESTORE)
} else {
context.toast(R.string.restore_in_progress)
}
}
}
preferenceCategory {
titleRes = R.string.automatic_backups
intListPreference(activity) {
bindTo(preferences.backupInterval())
titleRes = R.string.backup_frequency
entriesRes = arrayOf(
R.string.manual,
R.string.every_6_hours,
R.string.every_12_hours,
R.string.daily,
R.string.every_2_days,
R.string.weekly,
)
entryValues = listOf(0, 6, 12, 24, 48, 168)
onChange { newValue ->
val interval = newValue as Int
BackupCreatorJob.setupTask(context, interval)
true
}
}
intListPreference(activity) {
bindTo(preferences.numberOfBackups())
titleRes = R.string.max_auto_backups
entries = (1..5).map(Int::toString)
entryRange = 1..5
visibleIf(preferences.backupInterval()) { it > 0 }
}
}
infoPreference(R.string.backup_info)
preferenceCategory {
titleRes = R.string.storage_usage
preference {
key = CLEAR_CACHE_KEY
titleRes = R.string.clear_chapter_cache
summary = context.getString(R.string.used_, chapterCache.readableSize)
onClick { clearChapterCache() }
}
preference {
key = "clear_cached_not_library"
titleRes = R.string.clear_cached_covers_non_library
summary = context.getString(
R.string.delete_all_covers__not_in_library_used_,
coverCache.getOnlineCoverCacheSize(),
)
onClick {
context.toast(R.string.starting_cleanup)
(activity as? AppCompatActivity)?.lifecycleScope?.launchIO {
coverCache.deleteAllCachedCovers()
}
}
}
preference {
key = "clean_cached_covers"
titleRes = R.string.clean_up_cached_covers
summary = context.getString(
R.string.delete_old_covers_in_library_used_,
coverCache.getChapterCacheSize(),
)
onClick {
context.toast(R.string.starting_cleanup)
(activity as? AppCompatActivity)?.lifecycleScope?.launchIO {
coverCache.deleteOldCovers()
}
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.settings_backup, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_backup_help -> activity?.openInBrowser(HELP_URL)
}
return super.onOptionsItemSelected(item)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (data != null && resultCode == Activity.RESULT_OK) {
val activity = activity ?: return
val uri = data.data
if (uri == null) {
activity.toast(R.string.backup_restore_invalid_uri)
return
}
when (requestCode) {
CODE_DATA_DIR -> {
// Get UriPermission so it's possible to write files
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
activity.tryTakePersistableUriPermission(uri, flags)
val file = UniFile.fromUri(activity, uri)!!
storagePreferences.baseStorageDirectory().set(file.uri.toString())
}
CODE_BACKUP_CREATE -> {
doBackup(backupFlags, uri, true)
}
CODE_BACKUP_RESTORE -> {
(activity as? MainActivity)?.showNotificationPermissionPrompt(true)
showBackupRestoreDialog(uri)
}
}
}
}
private fun doBackup(options: BackupOptions, uri: Uri, requirePersist: Boolean = false) {
val activity = activity ?: return
val actualUri =
if (requirePersist) {
val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
activity.tryTakePersistableUriPermission(uri, intentFlags)
uri
} else {
UniFile.fromUri(activity, uri)?.createFile(Backup.getBackupFilename())?.uri
} ?: return
activity.toast(R.string.creating_backup)
BackupCreatorJob.startNow(activity, actualUri, options)
}
private fun createBackup(options: BackupOptions, picker: Boolean = false) {
backupFlags = options
val dir = storageManager.getBackupsDirectory()
if (dir == null) {
activity?.toast(R.string.invalid_location_generic)
return
}
if (!picker) {
doBackup(backupFlags, dir.uri)
return
}
try {
// Use Android's built-in file creator
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/*")
.putExtra(Intent.EXTRA_TITLE, Backup.getBackupFilename())
startActivityForResult(intent, CODE_BACKUP_CREATE)
} catch (e: ActivityNotFoundException) {
activity?.toast(R.string.file_picker_error)
}
}
private fun showBackupCreateDialog() {
val activity = activity ?: return
val options = BackupOptions.getOptions().map { activity.getString(it) }
activity.materialAlertDialog()
.setTitle(R.string.what_should_backup)
.setMultiChoiceItems(
options.toTypedArray(),
BackupOptions().asBooleanArray(),
) { dialog, position, _ ->
if (position == 0) {
val listView = (dialog as AlertDialog).listView
listView.setItemChecked(position, true)
}
}
.setPositiveButton(R.string.create) { dialog, _ ->
val listView = (dialog as AlertDialog).listView
val booleanArrayList = arrayListOf(true)
// TODO: Allow library_entries to be disabled
for (i in 1 until listView.count) { // skip 0, since 0 is always enabled
booleanArrayList.add(listView.isItemChecked(i))
}
createBackup(BackupOptions.fromBooleanArray(booleanArrayList.toBooleanArray()))
}
.setNegativeButton(android.R.string.cancel, null)
.show().apply {
disableItems(arrayOf(options.first()))
}
}
private fun showBackupRestoreDialog(uri: Uri) {
val activity = activity ?: return
try {
val results = BackupFileValidator().validate(activity, uri)
var message = activity.getString(R.string.restore_content_full)
if (results.missingSources.isNotEmpty()) {
message += "\n\n${activity.getString(R.string.restore_missing_sources)}\n${
results.missingSources.joinToString(
"\n",
) { "- $it" }
}"
}
if (results.missingTrackers.isNotEmpty()) {
message += "\n\n${activity.getString(R.string.restore_missing_trackers)}\n${
results.missingTrackers.joinToString(
"\n",
) { "- $it" }
}"
}
activity.materialAlertDialog()
.setTitle(R.string.restore_backup)
.setMessage(message)
.setPositiveButton(R.string.restore) { _, _ ->
val context = applicationContext
if (context != null) {
activity.toast(R.string.restoring_backup)
BackupRestoreJob.start(context, uri)
}
}.show()
} catch (e: Exception) {
activity.materialAlertDialog()
.setTitle(R.string.invalid_backup_file)
.setMessage(e.message)
.setPositiveButton(android.R.string.cancel, null)
.show()
}
}
private fun clearChapterCache() {
if (activity == null) return
viewScope.launchIO {
val files = chapterCache.cacheDir.listFiles() ?: return@launchIO
var deletedFiles = 0
try {
files.forEach { file ->
if (chapterCache.removeFileFromCache(file.name)) {
deletedFiles++
}
}
withUIContext {
activity?.toast(
resources?.getQuantityString(
R.plurals.cache_cleared,
deletedFiles,
deletedFiles,
),
)
findPreference(CLEAR_CACHE_KEY)?.summary =
resources?.getString(R.string.used_, chapterCache.readableSize)
}
} catch (_: Exception) {
withUIContext {
activity?.toast(R.string.cache_delete_error)
}
}
}
}
class SettingsDataController : SettingsComposeController() {
override fun getComposableSettings(): ComposableSettings = SettingsDataScreen
}
private const val CLEAR_CACHE_KEY = "pref_clear_cache_key"
private const val CODE_DATA_DIR = 104
private const val CODE_BACKUP_CREATE = 504
private const val CODE_BACKUP_RESTORE = 505
private const val HELP_URL = "https://tachiyomi.org/docs/guides/backups"

View file

@ -13,13 +13,17 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface
import eu.kanade.tachiyomi.ui.more.AboutController
import eu.kanade.tachiyomi.ui.setting.SettingsLegacyController
import eu.kanade.tachiyomi.ui.setting.controllers.legacy.SettingsDataLegacyController
import eu.kanade.tachiyomi.ui.setting.iconRes
import eu.kanade.tachiyomi.ui.setting.iconTint
import eu.kanade.tachiyomi.ui.setting.onClick
import eu.kanade.tachiyomi.ui.setting.onLongClick
import eu.kanade.tachiyomi.ui.setting.preference
import eu.kanade.tachiyomi.ui.setting.preferenceLongClickable
import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchController
import eu.kanade.tachiyomi.ui.setting.titleRes
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.activityBinding
import eu.kanade.tachiyomi.util.view.fadeTransactionHandler
import eu.kanade.tachiyomi.util.view.openInBrowser
@ -78,11 +82,15 @@ class SettingsMainController : SettingsLegacyController(), FloatingSearchInterfa
titleRes = R.string.tracking
onClick { navigateTo(SettingsTrackingController()) }
}
preference {
preferenceLongClickable {
iconRes = R.drawable.ic_storage_24dp
iconTint = tintColor
titleRes = R.string.data_and_storage
onClick { navigateTo(SettingsDataController()) }
onClick { navigateTo(SettingsDataLegacyController()) }
onLongClick {
navigateTo(SettingsDataController())
context.toast("You're entering beta version of 'Data and storage'")
}
}
preference {
iconRes = R.drawable.ic_security_24dp

View file

@ -0,0 +1,400 @@
package eu.kanade.tachiyomi.ui.setting.controllers.legacy
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceScreen
import com.hippo.unifile.UniFile
import dev.yokai.domain.storage.StorageManager
import dev.yokai.domain.storage.StoragePreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.BackupCreatorJob
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.setting.SettingsLegacyController
import eu.kanade.tachiyomi.ui.setting.bindTo
import eu.kanade.tachiyomi.ui.setting.infoPreference
import eu.kanade.tachiyomi.ui.setting.intListPreference
import eu.kanade.tachiyomi.ui.setting.onChange
import eu.kanade.tachiyomi.ui.setting.onClick
import eu.kanade.tachiyomi.ui.setting.preference
import eu.kanade.tachiyomi.ui.setting.preferenceCategory
import eu.kanade.tachiyomi.ui.setting.summaryRes
import eu.kanade.tachiyomi.ui.setting.titleRes
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.disableItems
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.materialAlertDialog
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.tryTakePersistableUriPermission
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import uy.kohesive.injekt.injectLazy
class SettingsDataLegacyController : SettingsLegacyController() {
/**
* Flags containing information of what to backup.
*/
private var backupFlags: BackupOptions = BackupOptions()
internal val storagePreferences: StoragePreferences by injectLazy()
internal val storageManager: StorageManager by injectLazy()
private val coverCache: CoverCache by injectLazy()
private val chapterCache: ChapterCache by injectLazy()
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
titleRes = R.string.data_and_storage
preference {
key = "pref_storage_location"
bindTo(storagePreferences.baseStorageDirectory())
titleRes = R.string.storage_location
onClick {
try {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, CODE_DATA_DIR)
} catch (e: ActivityNotFoundException) {
activity?.toast(R.string.file_picker_error)
}
}
storagePreferences.baseStorageDirectory().changes()
.onEach { path ->
summary = UniFile.fromUri(context, path.toUri())?.let { dir ->
dir.filePath ?: context.getString(R.string.invalid_location, dir.uri)
} ?: context.getString(R.string.invalid_location_generic)
}
.launchIn(viewScope)
}
preference {
key = "pref_create_backup"
titleRes = R.string.create_backup
summaryRes = R.string.can_be_used_to_restore
onClick {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
}
if (!BackupCreatorJob.isManualJobRunning(context)) {
showBackupCreateDialog()
} else {
context.toast(R.string.backup_in_progress)
}
}
}
preference {
key = "pref_restore_backup"
titleRes = R.string.restore_backup
summaryRes = R.string.restore_from_backup_file
onClick {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
}
if (!BackupRestoreJob.isRunning(context)) {
(activity as? MainActivity)?.getExtensionUpdates(true)
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
storageManager.getBackupsDirectory()?.let {
intent.setDataAndType(it.uri, "*/*")
}
val title = resources?.getString(R.string.select_backup_file)
val chooser = Intent.createChooser(intent, title)
startActivityForResult(chooser, CODE_BACKUP_RESTORE)
} else {
context.toast(R.string.restore_in_progress)
}
}
}
preferenceCategory {
titleRes = R.string.automatic_backups
intListPreference(activity) {
bindTo(preferences.backupInterval())
titleRes = R.string.backup_frequency
entriesRes = arrayOf(
R.string.manual,
R.string.every_6_hours,
R.string.every_12_hours,
R.string.daily,
R.string.every_2_days,
R.string.weekly,
)
entryValues = listOf(0, 6, 12, 24, 48, 168)
onChange { newValue ->
val interval = newValue as Int
BackupCreatorJob.setupTask(context, interval)
true
}
}
intListPreference(activity) {
bindTo(preferences.numberOfBackups())
titleRes = R.string.max_auto_backups
entries = (1..5).map(Int::toString)
entryRange = 1..5
visibleIf(preferences.backupInterval()) { it > 0 }
}
}
infoPreference(R.string.backup_info)
preferenceCategory {
titleRes = R.string.storage_usage
preference {
key = CLEAR_CACHE_KEY
titleRes = R.string.clear_chapter_cache
summary = context.getString(R.string.used_, chapterCache.readableSize)
onClick { clearChapterCache() }
}
preference {
key = "clear_cached_not_library"
titleRes = R.string.clear_cached_covers_non_library
summary = context.getString(
R.string.delete_all_covers__not_in_library_used_,
coverCache.getOnlineCoverCacheSize(),
)
onClick {
context.toast(R.string.starting_cleanup)
(activity as? AppCompatActivity)?.lifecycleScope?.launchIO {
coverCache.deleteAllCachedCovers()
}
}
}
preference {
key = "clean_cached_covers"
titleRes = R.string.clean_up_cached_covers
summary = context.getString(
R.string.delete_old_covers_in_library_used_,
coverCache.getChapterCacheSize(),
)
onClick {
context.toast(R.string.starting_cleanup)
(activity as? AppCompatActivity)?.lifecycleScope?.launchIO {
coverCache.deleteOldCovers()
}
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.settings_backup, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_backup_help -> activity?.openInBrowser(HELP_URL)
}
return super.onOptionsItemSelected(item)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (data != null && resultCode == Activity.RESULT_OK) {
val activity = activity ?: return
val uri = data.data
if (uri == null) {
activity.toast(R.string.backup_restore_invalid_uri)
return
}
when (requestCode) {
CODE_DATA_DIR -> {
// Get UriPermission so it's possible to write files
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
activity.tryTakePersistableUriPermission(uri, flags)
val file = UniFile.fromUri(activity, uri)!!
storagePreferences.baseStorageDirectory().set(file.uri.toString())
}
CODE_BACKUP_CREATE -> {
doBackup(backupFlags, uri, true)
}
CODE_BACKUP_RESTORE -> {
(activity as? MainActivity)?.showNotificationPermissionPrompt(true)
showBackupRestoreDialog(uri)
}
}
}
}
private fun doBackup(options: BackupOptions, uri: Uri, requirePersist: Boolean = false) {
val activity = activity ?: return
val actualUri =
if (requirePersist) {
val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
activity.tryTakePersistableUriPermission(uri, intentFlags)
uri
} else {
UniFile.fromUri(activity, uri)?.createFile(Backup.getBackupFilename())?.uri
} ?: return
activity.toast(R.string.creating_backup)
BackupCreatorJob.startNow(activity, actualUri, options)
}
private fun createBackup(options: BackupOptions, picker: Boolean = false) {
backupFlags = options
val dir = storageManager.getBackupsDirectory()
if (dir == null) {
activity?.toast(R.string.invalid_location_generic)
return
}
if (!picker) {
doBackup(backupFlags, dir.uri)
return
}
try {
// Use Android's built-in file creator
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/*")
.putExtra(Intent.EXTRA_TITLE, Backup.getBackupFilename())
startActivityForResult(intent, CODE_BACKUP_CREATE)
} catch (e: ActivityNotFoundException) {
activity?.toast(R.string.file_picker_error)
}
}
private fun showBackupCreateDialog() {
val activity = activity ?: return
val options = BackupOptions.getOptions().map { activity.getString(it) }
activity.materialAlertDialog()
.setTitle(R.string.what_should_backup)
.setMultiChoiceItems(
options.toTypedArray(),
BackupOptions().asBooleanArray(),
) { dialog, position, _ ->
if (position == 0) {
val listView = (dialog as AlertDialog).listView
listView.setItemChecked(position, true)
}
}
.setPositiveButton(R.string.create) { dialog, _ ->
val listView = (dialog as AlertDialog).listView
val booleanArrayList = arrayListOf(true)
// TODO: Allow library_entries to be disabled
for (i in 1 until listView.count) { // skip 0, since 0 is always enabled
booleanArrayList.add(listView.isItemChecked(i))
}
createBackup(BackupOptions.fromBooleanArray(booleanArrayList.toBooleanArray()))
}
.setNegativeButton(android.R.string.cancel, null)
.show().apply {
disableItems(arrayOf(options.first()))
}
}
private fun showBackupRestoreDialog(uri: Uri) {
val activity = activity ?: return
try {
val results = BackupFileValidator().validate(activity, uri)
var message = activity.getString(R.string.restore_content_full)
if (results.missingSources.isNotEmpty()) {
message += "\n\n${activity.getString(R.string.restore_missing_sources)}\n${
results.missingSources.joinToString(
"\n",
) { "- $it" }
}"
}
if (results.missingTrackers.isNotEmpty()) {
message += "\n\n${activity.getString(R.string.restore_missing_trackers)}\n${
results.missingTrackers.joinToString(
"\n",
) { "- $it" }
}"
}
activity.materialAlertDialog()
.setTitle(R.string.restore_backup)
.setMessage(message)
.setPositiveButton(R.string.restore) { _, _ ->
val context = applicationContext
if (context != null) {
activity.toast(R.string.restoring_backup)
BackupRestoreJob.start(context, uri)
}
}.show()
} catch (e: Exception) {
activity.materialAlertDialog()
.setTitle(R.string.invalid_backup_file)
.setMessage(e.message)
.setPositiveButton(android.R.string.cancel, null)
.show()
}
}
private fun clearChapterCache() {
if (activity == null) return
viewScope.launchIO {
try {
val deletedFiles = chapterCache.clear()
withUIContext {
activity?.toast(
resources?.getQuantityString(
R.plurals.cache_cleared,
deletedFiles,
deletedFiles,
),
)
findPreference(CLEAR_CACHE_KEY)?.summary =
resources?.getString(R.string.used_, chapterCache.readableSize)
}
} catch (_: Exception) {
withUIContext {
activity?.toast(R.string.cache_delete_error)
}
}
}
}
}
private const val CLEAR_CACHE_KEY = "pref_clear_cache_key"
private const val CODE_DATA_DIR = 104
private const val CODE_BACKUP_CREATE = 504
private const val CODE_BACKUP_RESTORE = 505
private const val HELP_URL = "https://tachiyomi.org/docs/guides/backups"

View file

@ -9,17 +9,17 @@ import androidx.preference.PreferenceGroup
import androidx.preference.PreferenceManager
import eu.kanade.tachiyomi.ui.setting.SettingsComposeController
import eu.kanade.tachiyomi.ui.setting.SettingsControllerInterface
import eu.kanade.tachiyomi.ui.setting.SettingsLegacyController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsAdvancedController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsAppearanceController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsDataController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsBrowseController
import eu.kanade.tachiyomi.ui.setting.SettingsLegacyController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsDownloadController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsGeneralController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsLibraryController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsReaderController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsSecurityController
import eu.kanade.tachiyomi.ui.setting.controllers.SettingsTrackingController
import eu.kanade.tachiyomi.ui.setting.controllers.legacy.SettingsDataLegacyController
import eu.kanade.tachiyomi.util.system.isLTR
import eu.kanade.tachiyomi.util.system.launchNow
import kotlin.reflect.KClass
@ -31,9 +31,10 @@ object SettingsSearchHelper {
/**
* All subclasses of `SettingsController` should be listed here, in order to have their preferences searchable.
*/
private val settingLegacyControllersList: List<KClass<out SettingsLegacyController>> = listOf(
private val settingControllersList: List<KClass<out SettingsControllerInterface>> = listOf(
SettingsAdvancedController::class,
SettingsDataController::class,
SettingsDataLegacyController::class,
// SettingsDataController::class, // compose
SettingsBrowseController::class,
SettingsDownloadController::class,
SettingsGeneralController::class,
@ -44,9 +45,6 @@ object SettingsSearchHelper {
SettingsTrackingController::class,
)
private val settingComposeControllersList: List<KClass<out SettingsComposeController>> = listOf(
)
/**
* Must be called to populate `prefSearchResultList`
*/
@ -56,14 +54,22 @@ object SettingsSearchHelper {
prefSearchResultList.clear()
launchNow {
settingLegacyControllersList.forEach { kClass ->
val ctrl = kClass.createInstance()
val settingsPrefScreen = ctrl.setupPreferenceScreen(preferenceManager.createPreferenceScreen(context))
val prefCount = settingsPrefScreen.preferenceCount
for (i in 0 until prefCount) {
val rootPref = settingsPrefScreen.getPreference(i)
if (rootPref.title == null) continue // no title, not a preference. (note: only info notes appear to not have titles)
getLegacySettingSearchResult(ctrl, rootPref, "${settingsPrefScreen.title}")
settingControllersList.forEach { kClass ->
when (val ctrl = kClass.createInstance()) {
is SettingsLegacyController -> {
val settingsPrefScreen =
ctrl.setupPreferenceScreen(preferenceManager.createPreferenceScreen(context))
val prefCount = settingsPrefScreen.preferenceCount
for (i in 0 until prefCount) {
val rootPref = settingsPrefScreen.getPreference(i)
if (rootPref.title == null) continue // no title, not a preference. (note: only info notes appear to not have titles)
getLegacySettingSearchResult(ctrl, rootPref, "${settingsPrefScreen.title}")
}
}
is SettingsComposeController -> {
// TODO: Impossible to achieve, require search to be composable
// ctrl.getComposableSettings().getPreferences()
}
}
}
}

View file

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.util
import android.text.format.DateUtils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import eu.kanade.tachiyomi.R
import java.time.Instant
import kotlin.time.Duration.Companion.minutes
@Composable
@ReadOnlyComposable
fun relativeTimeSpanString(epochMillis: Long): String {
val now = Instant.now().toEpochMilli()
return when {
epochMillis <= 0L -> stringResource(R.string.never)
now - epochMillis < 1.minutes.inWholeMilliseconds -> stringResource(
R.string.just_now,
)
else -> DateUtils.getRelativeTimeSpanString(epochMillis, now, DateUtils.MINUTE_IN_MILLIS).toString()
}
}

View file

@ -29,12 +29,30 @@ object DiskUtil {
return size
}
fun getTotalStorageSpace(file: UniFile): Long = getTotalStorageSpace(file.uri.path)
fun getTotalStorageSpace(file: File): Long = getTotalStorageSpace(file.absolutePath)
/**
* Gets the total space for the disk that a file path points to, in bytes.
*/
fun getTotalStorageSpace(path: String?): Long {
return try {
val stat = StatFs(path)
stat.blockCountLong * stat.blockSizeLong
} catch (_: Exception) {
-1L
}
}
fun getAvailableStorageSpace(file: UniFile): Long = getAvailableStorageSpace(file.uri.path)
fun getAvailableStorageSpace(file: File): Long = getAvailableStorageSpace(file.absolutePath)
/**
* Gets the available space for the disk that a file path points to, in bytes.
*/
fun getAvailableStorageSpace(f: UniFile): Long {
fun getAvailableStorageSpace(path: String?): Long {
return try {
val stat = StatFs(f.uri.path)
val stat = StatFs(path)
stat.availableBlocksLong * stat.blockSizeLong
} catch (_: Exception) {
-1L

View file

@ -806,6 +806,7 @@
<string name="data_and_storage">Data and storage</string>
<string name="storage_location">Storage location</string>
<string name="storage_usage">Storage usage</string>
<string name="available_disk_space_info">Available: %1$s / Total: %2$s</string>
<!-- Backup -->
<string name="backup">Backup</string>
@ -847,6 +848,7 @@
</plurals>
<string name="not_logged_into_">Not logged into %1$s</string>
<string name="backup_private_pref">Include sensitive settings (e.g. tracker login tokens)</string>
<string name="last_auto_backup_info">Last automatically backed up: %s</string>
<!-- Advanced section -->
<string name="clear_chapter_cache">Clear chapter cache</string>
@ -1195,6 +1197,7 @@
<string name="recently_installed">Recently installed</string>
<string name="language">Language</string>
<string name="never">Never</string>
<string name="just_now">Just now</string>
<string name="newest">Newest</string>
<string name="next">Next</string>
<string name="no_animation">No animation</string>

View file

@ -23,6 +23,7 @@ compose-theme-adapter3 = { module = "com.google.accompanist:accompanist-themeada
conductor = { module = "com.bluelinelabs:conductor", version = "4.0.0-preview-4" }
conductor-support-preference = { module = "com.github.tachiyomiorg:conductor-support-preference", version = "3.0.0" }
conscrypt = { module = "org.conscrypt:conscrypt-android", version = "2.5.2" }
desugar = { module = "com.android.tools:desugar_jdk_libs", version = "2.0.4" }
directionalviewpager = { module = "com.github.tachiyomiorg:DirectionalViewPager", version = "1.0.0" }
disklrucache = { module = "com.jakewharton:disklrucache", version = "2.0.2" }
fastadapter-extensions-binding = { module = "com.mikepenz:fastadapter-extensions-binding", version.ref = "fast_adapter" }