Storage screen

master
Zakhar Timoshenko 2 years ago
parent 98451e55e2
commit 2a6edad196
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -84,8 +84,8 @@ dependencies {
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material-icons-extended:1.6.0")
implementation("androidx.compose.material3:material3-android:1.2.0-rc01")
implementation("androidx.compose.material3:material3-window-size-class:1.2.0-rc01")
implementation("androidx.compose.material3:material3-android:1.2.0")
implementation("androidx.compose.material3:material3-window-size-class:1.2.0")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
@ -94,6 +94,7 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.9.0")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
implementation("com.google.accompanist:accompanist-pager:0.32.0")
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")

@ -45,6 +45,8 @@ import org.xtimms.tokusho.sections.settings.appearance.DARK_THEME_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView
import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.LanguagesView
import org.xtimms.tokusho.sections.settings.storage.STORAGE_DESTINATION
import org.xtimms.tokusho.sections.settings.storage.StorageView
import org.xtimms.tokusho.sections.shelf.ShelfMap
import org.xtimms.tokusho.sections.shelf.ShelfView
import org.xtimms.tokusho.utils.lang.removeFirstAndLast
@ -144,7 +146,8 @@ fun Navigation(
navigateBack = navigateBack,
navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) },
navigateToAbout = { navController.navigate(ABOUT_DESTINATION) },
navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) }
navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) },
navigateToStorage = { navController.navigate(STORAGE_DESTINATION) }
)
}
@ -169,6 +172,12 @@ fun Navigation(
)
}
composable(STORAGE_DESTINATION) {
StorageView(
navigateBack = navigateBack,
)
}
composable(ADVANCED_DESTINATION) {
AdvancedView(
navigateBack = navigateBack,

@ -9,8 +9,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
@Composable
fun ConfirmButton(
text: String = stringResource(R.string.confirm),
enabled: Boolean = true,
onClick: () -> Unit
) {
TextButton(onClick = onClick, enabled = enabled) {
Text(text)
}
}
@Composable
fun DismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) {
TextButton(onClick = onClick) {
Text(text)
}
}
@Composable
fun ActionButton(

@ -0,0 +1,44 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
@Composable
fun DialogCheckBoxItem(
modifier: Modifier = Modifier,
text: String,
checked: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = modifier
.fillMaxWidth()
.toggleable(
value = checked,
enabled = true,
onValueChange = { onClick() },
)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
modifier = Modifier.clearAndSetSemantics { },
checked = checked, onCheckedChange = { onClick() },
)
Text(
modifier = Modifier.weight(1f),
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}

@ -0,0 +1,137 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
private val DialogVerticalPadding = PaddingValues(vertical = 24.dp)
private val IconPadding = PaddingValues(bottom = 16.dp)
private val DialogHorizontalPadding = PaddingValues(horizontal = 24.dp)
private val TitlePadding = PaddingValues(bottom = 16.dp)
private val TextPadding = PaddingValues(bottom = 24.dp)
private val ButtonsMainAxisSpacing = Arrangement.spacedBy(8.dp, Alignment.Start)
private val ButtonsCrossAxisSpacing = Arrangement.spacedBy(12.dp, Alignment.Top)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun TokushoDialog(
onDismissRequest: () -> Unit,
confirmButton: @Composable () -> Unit,
modifier: Modifier = Modifier,
dismissButton: @Composable (() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
shape: Shape = AlertDialogDefaults.shape,
containerColor: Color = AlertDialogDefaults.containerColor,
iconContentColor: Color = AlertDialogDefaults.iconContentColor,
titleContentColor: Color = AlertDialogDefaults.titleContentColor,
textContentColor: Color = AlertDialogDefaults.textContentColor,
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties()
) {
AlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = properties
) {
Surface(
modifier = modifier,
shape = shape,
color = containerColor,
tonalElevation = tonalElevation,
) {
Column(
modifier = Modifier.padding(DialogVerticalPadding)
) {
icon?.let {
CompositionLocalProvider(LocalContentColor provides iconContentColor) {
Box(
Modifier
.padding(IconPadding)
.padding(DialogHorizontalPadding)
.align(Alignment.CenterHorizontally)
) {
icon()
}
}
}
title?.let {
CompositionLocalProvider(LocalContentColor provides titleContentColor) {
val textStyle = MaterialTheme.typography.headlineSmall
ProvideTextStyle(textStyle) {
Box(
// Align the title to the center when an icon is present.
Modifier
.padding(TitlePadding)
.padding(DialogHorizontalPadding)
.align(
if (icon == null) {
Alignment.Start
} else {
Alignment.CenterHorizontally
}
)
) {
title()
}
}
}
}
text?.let {
CompositionLocalProvider(LocalContentColor provides textContentColor) {
val textStyle =
MaterialTheme.typography.bodyMedium
ProvideTextStyle(textStyle) {
Box(
Modifier
.weight(weight = 1f, fill = false)
.padding(TextPadding)
.align(Alignment.Start)
) {
text()
}
}
}
}
Box(
modifier = Modifier
.align(Alignment.End)
.padding(DialogHorizontalPadding)
) {
val textStyle =
MaterialTheme.typography.labelLarge
ProvideTextStyle(value = textStyle) {
FlowRow(
horizontalArrangement = ButtonsMainAxisSpacing,
verticalArrangement = ButtonsCrossAxisSpacing
) {
dismissButton?.invoke()
confirmButton()
}
}
}
}
}
}
}

@ -16,14 +16,16 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.TipsAndUpdates
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.TipsAndUpdates
import androidx.compose.material.icons.outlined.ToggleOn
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
@ -41,8 +43,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
@ -57,8 +61,10 @@ import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.tokusho.ui.theme.PreviewThemeLight
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.ui.theme.applyOpacity
import org.xtimms.tokusho.ui.theme.preferenceTitle
import org.xtimms.tokusho.utils.FileSize
private const val horizontal = 8
private const val vertical = 16
@ -516,7 +522,7 @@ fun PreferencesHintCard(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.padding(start = 8.dp, end = 24.dp)
.size(24.dp),
tint = contentColor
)
@ -544,6 +550,160 @@ fun PreferencesHintCard(
}
}
@Composable
fun PreferenceStorageHeader(
used: Long = 4L,
total: Long = 128L
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = FileSize.BYTES.formatWithoutUnits(used),
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.displayLarge
)
Text(
text = FileSize.BYTES.showUnit(LocalContext.current, used),
modifier = Modifier
.weight(1f)
.align(Alignment.Bottom)
.padding(PaddingValues(bottom = 8.dp))
)
Text(
text = FileSize.BYTES.totalFormat(LocalContext.current, total),
modifier = Modifier
.align(Alignment.Bottom)
.padding(PaddingValues(bottom = 8.dp))
)
}
LinearProgressIndicator(
progress = { (1 - ((total - used) / total.toFloat())) },
modifier = Modifier
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
.height(16.dp)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
strokeCap = StrokeCap.Round,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PreferenceStorageItem(
title: String,
used: Long? = 0L,
total: Long?,
icon: Any? = null,
leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {},
) {
Surface(
modifier = Modifier.combinedClickable(
onClick = onClick,
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal.dp, vertical.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingIcon?.invoke()
when (icon) {
is ImageVector -> {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true),
)
}
is Painter -> {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true),
)
}
}
Column(
modifier = Modifier
.weight(1f)
.then(
if (icon != null)
Modifier
.padding(start = 16.dp, end = 8.dp)
else Modifier.padding(horizontal = 8.dp)
)
) {
Row {
PreferenceItemTitle(
modifier = Modifier.weight(1f),
text = title,
enabled = true
)
Text(text = FileSize.BYTES.format(LocalContext.current, used ?: 0L))
}
if (total != null) {
LinearProgressIndicator(
progress = { (1 - ((total - used!!) / total.toFloat())) },
modifier = Modifier
.padding(PaddingValues(top = 12.dp))
.height(5.dp)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
strokeCap = StrokeCap.Round,
)
}
}
trailingIcon?.let {
VerticalDivider(
modifier = Modifier
.height(32.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
thickness = 1.dp
)
trailingIcon.invoke()
}
}
}
}
@Composable
@Preview(showBackground = true)
fun PreferenceStorageHeaderPreview() {
TokushoTheme {
PreferenceStorageHeader()
}
}
@Composable
@Preview(showBackground = true)
fun PreferenceStorageItemPreview() {
TokushoTheme {
PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0L)
}
}
@Composable
@Preview
fun PreferenceItemPreview() {

@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.DismissButton
import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.utils.system.suspendToast
@ -87,11 +88,4 @@ fun UpdateDialogImpl(
Text(releaseNote)
}
})
}
@Composable
fun DismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) {
TextButton(onClick = onClick) {
Text(text)
}
}

@ -5,7 +5,13 @@ import android.os.StatFs
import androidx.annotation.WorkerThread
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.parsers.util.mapToSet
import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.utils.system.computeSize
import java.io.File
import javax.inject.Inject
@ -28,6 +34,65 @@ class LocalStorageManager @Inject constructor(
return Cache(directory, maxSize)
}
suspend fun computeCacheSize(cache: CacheDir) = withContext(Dispatchers.IO) {
getCacheDirs(cache.dir).sumOf { it.computeSize() }
}
suspend fun computeCacheSize() = withContext(Dispatchers.IO) {
getCacheDirs().sumOf { it.computeSize() }
}
suspend fun computeAvailableSize() = runInterruptible(Dispatchers.IO) {
getAvailableStorageDirs().mapToSet { it.freeSpace }.sum()
}
fun getAvailableStorageSpace(file: File): Long {
return try {
val stat = StatFs(file.absolutePath)
stat.availableBlocksLong * stat.blockSizeLong
} catch (_: Exception) {
-1L
}
}
suspend fun clearCache(cache: CacheDir) = runInterruptible(Dispatchers.IO) {
getCacheDirs(cache.dir).forEach { it.deleteRecursively() }
}
@WorkerThread
private fun getAvailableStorageDirs(): MutableSet<File> {
val result = LinkedHashSet<File>()
result += File(context.filesDir, DIR_NAME)
context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result)
result.retainAll { it.exists() || it.mkdirs() }
return result
}
@WorkerThread
private fun getFallbackStorageDir(): File? {
return context.getExternalFilesDir(DIR_NAME) ?: File(context.filesDir, DIR_NAME).takeIf {
it.exists() || it.mkdirs()
}
}
@WorkerThread
private fun getCacheDirs(subDir: String): MutableSet<File> {
val result = LinkedHashSet<File>()
result += File(context.cacheDir, subDir)
context.externalCacheDirs.mapNotNullTo(result) {
File(it ?: return@mapNotNullTo null, subDir)
}
return result
}
@WorkerThread
private fun getCacheDirs(): MutableSet<File> {
val result = LinkedHashSet<File>()
result += context.cacheDir
context.externalCacheDirs.filterNotNullTo(result)
return result
}
private fun calculateDiskCacheSize(cacheDirectory: File): Long {
return try {
val cacheDir = StatFs(cacheDirectory.absolutePath)

@ -6,12 +6,19 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.components.SettingItem
import org.xtimms.tokusho.sections.settings.storage.StorageViewModel
import org.xtimms.tokusho.utils.FileSize
const val SETTINGS_DESTINATION = "settings"
@ -20,8 +27,14 @@ fun SettingsView(
navigateBack: () -> Unit,
navigateToAppearance: () -> Unit,
navigateToAbout: () -> Unit,
navigateToAdvanced: () -> Unit
navigateToAdvanced: () -> Unit,
navigateToStorage: () -> Unit
) {
val context = LocalContext.current
val viewModel: StorageViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ScaffoldWithTopAppBar(
title = stringResource(R.string.settings),
navigateBack = navigateBack
@ -38,6 +51,32 @@ fun SettingsView(
onClick = navigateToAppearance
)
}
item {
val allCaches = uiState.availableSpace +
uiState.httpCacheSize +
uiState.pagesCache +
uiState.thumbnailsCache
val desc = buildString {
append((uiState.availableSpace / allCaches) * 100)
append("% used")
append(" - ")
append(
FileSize.BYTES.freeFormat(
context,
uiState.availableSpace -
uiState.httpCacheSize -
uiState.pagesCache -
uiState.thumbnailsCache
)
)
}
SettingItem(
title = stringResource(id = R.string.storage),
description = desc,
icon = Icons.Outlined.Storage,
onClick = navigateToStorage
)
}
item {
SettingItem(
title = stringResource(id = R.string.advanced),

@ -0,0 +1,122 @@
package org.xtimms.tokusho.sections.settings.storage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CleaningServices
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.ConfirmButton
import org.xtimms.tokusho.core.components.DialogCheckBoxItem
import org.xtimms.tokusho.core.components.DismissButton
import org.xtimms.tokusho.core.components.TokushoDialog
import org.xtimms.tokusho.utils.FileSize
@Composable
fun CleanDialog(
onDismissRequest: () -> Unit = {},
isPagesCacheSelected: Boolean,
isThumbnailsCacheSelected: Boolean,
isNetworkCacheSelected: Boolean,
onConfirm: (isPagesCacheSelected: Boolean, isThumbnailCacheSelected: Boolean, isNetworkCacheSelected: Boolean) -> Unit = { _, _, _ -> }
) {
val viewModel: StorageViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var pagesCache by remember {
mutableStateOf(isPagesCacheSelected)
}
var thumbnailsCache by remember {
mutableStateOf(isThumbnailsCacheSelected)
}
var networkCache by remember {
mutableStateOf(isNetworkCacheSelected)
}
TokushoDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
ConfirmButton {
onConfirm(pagesCache, thumbnailsCache, networkCache)
onDismissRequest()
}
},
dismissButton = {
DismissButton {
onDismissRequest()
}
},
title = {
Text(
text = stringResource(
id = R.string.free_up_space
)
)
},
icon = { Icon(imageVector = Icons.Outlined.CleaningServices, contentDescription = null) },
text = {
Column {
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
DialogCheckBoxItem(
text = stringResource(id = R.string.pages_cache),
checked = pagesCache
) {
pagesCache = !pagesCache
}
DialogCheckBoxItem(
text = stringResource(id = R.string.thumbnails_cache),
checked = thumbnailsCache
) {
thumbnailsCache = !thumbnailsCache
}
DialogCheckBoxItem(
text = stringResource(id = R.string.network_cache),
checked = networkCache
) {
networkCache = !networkCache
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
Spacer(modifier = Modifier.height(4.dp))
val summary = StringBuilder().run {
append(FileSize.BYTES.format(LocalContext.current, uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize))
append("")
}
Text(
text = stringResource(R.string.free_up_space_summary) + " " + summary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
// style = MaterialTheme.typography.labelMedium,
)
}
})
}
@Preview
@Composable
private fun DirectoryPreferenceDialogPreview() {
CleanDialog(
onDismissRequest = {},
isPagesCacheSelected = false,
isThumbnailsCacheSelected = false,
isNetworkCacheSelected = false
)
}

@ -0,0 +1,15 @@
package org.xtimms.tokusho.sections.settings.storage
import org.xtimms.tokusho.core.base.state.UiState
data class StorageUiState(
val pagesCache: Long = -1L,
val thumbnailsCache: Long = -1L,
val availableSpace: Long = -1L,
val httpCacheSize: Long = -1L,
override val isLoading: Boolean = false,
override val message: String? = null,
) : UiState() {
override fun setLoading(value: Boolean) = copy(isLoading = value)
override fun setMessage(value: String?) = copy(message = value)
}

@ -0,0 +1,114 @@
package org.xtimms.tokusho.sections.settings.storage
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material.icons.outlined.CleaningServices
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.NetworkWifi
import androidx.compose.material.icons.outlined.SdStorage
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.core.components.PreferenceStorageHeader
import org.xtimms.tokusho.core.components.PreferenceStorageItem
import org.xtimms.tokusho.core.components.PreferencesHintCard
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
const val STORAGE_DESTINATION = "storage"
@Composable
fun StorageView(
navigateBack: () -> Unit,
) {
val viewModel: StorageViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showCleanDialog by remember { mutableStateOf(false) }
ScaffoldWithTopAppBar(
title = stringResource(R.string.storage),
navigateBack = navigateBack
) { padding ->
if (!uiState.isLoading) LazyColumn(
modifier = Modifier
.padding(padding)
) {
item {
PreferenceStorageHeader(
used = uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache,
total = uiState.availableSpace
)
}
item {
PreferencesHintCard(
title = stringResource(id = R.string.free_up_space),
description = stringResource(id = R.string.free_up_space_hint),
icon = Icons.Outlined.CleaningServices
) {
showCleanDialog = true
}
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
title = stringResource(id = R.string.saved_manga),
icon = Icons.Outlined.SdStorage
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
title = stringResource(id = R.string.pages_cache),
icon = Icons.Outlined.AutoStories,
used = uiState.pagesCache
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
title = stringResource(id = R.string.thumbnails_cache),
icon = Icons.Outlined.Image,
used = uiState.thumbnailsCache
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
title = stringResource(id = R.string.network_cache),
icon = Icons.Outlined.NetworkWifi,
used = uiState.httpCacheSize
)
}
} else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
if (showCleanDialog) {
CleanDialog(
onDismissRequest = { showCleanDialog = false },
isPagesCacheSelected = false,
isNetworkCacheSelected = false,
isThumbnailsCacheSelected = false,
onConfirm = { isPagesCacheSelected, isThumbnailCacheSelected, isNetworkCacheSelected ->
if (isPagesCacheSelected) viewModel.clearCache(CacheDir.PAGES)
if (isThumbnailCacheSelected) viewModel.clearCache(CacheDir.THUMBS)
if (isNetworkCacheSelected) viewModel.clearHttpCache()
}
)
}
}

@ -0,0 +1,85 @@
package org.xtimms.tokusho.sections.settings.storage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.data.LocalStorageManager
import java.util.EnumMap
import javax.inject.Inject
@HiltViewModel
class StorageViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
) : BaseViewModel<StorageUiState>() {
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
init {
launchJob(Dispatchers.Default) {
setLoading(true)
httpCacheSize.value = runInterruptible { httpCache.size() }
mutableUiState.update {
it.copy(
availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = httpCacheSize.value,
isLoading = false
)
}
}
}
fun clearCache(cache: CacheDir) {
launchJob(Dispatchers.Default) {
try {
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
mutableUiState.update {
it.copy(
availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = httpCacheSize.value,
isLoading = false
)
}
} catch (_: Exception) {
}
}
}
fun clearHttpCache() {
launchJob(Dispatchers.Default) {
try {
val size = runInterruptible(Dispatchers.IO) {
httpCache.evictAll()
httpCache.size()
}
httpCacheSize.value = size
mutableUiState.update {
it.copy(
availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = httpCacheSize.value,
isLoading = false
)
}
} catch (_: Exception) {
}
}
}
override val mutableUiState = MutableStateFlow(StorageUiState())
}

@ -5,8 +5,8 @@ import android.os.Build
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.utils.lang.withNonCancellableContext
import org.xtimms.tokusho.utils.lang.withUIContext
import org.xtimms.tokusho.utils.storage.getUriCompat
import org.xtimms.tokusho.utils.system.createFileInCacheDir
import org.xtimms.tokusho.utils.system.getUriCompat
import org.xtimms.tokusho.utils.system.toShareIntent
import org.xtimms.tokusho.utils.system.toast

@ -0,0 +1,108 @@
package org.xtimms.tokusho.utils
import android.content.Context
import org.xtimms.tokusho.R
import java.text.DecimalFormat
import kotlin.math.log10
import kotlin.math.pow
enum class FileSize(private val multiplier: Int) {
BYTES(1), KILOBYTES(1024), MEGABYTES(1024 * 1024);
fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier
fun freeFormat(context: Context, amount: Long): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_free).split('|')
if (bytes <= 0) {
return "0 ${units.first()}"
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
append(
DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble()),
),
)
val unit = units.getOrNull(digitGroups)
if (unit != null) {
append(' ')
append(unit)
}
}
}
fun totalFormat(context: Context, amount: Long): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_total).split('|')
if (bytes <= 0) {
return "0 ${units.first()}"
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
append(
DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble()),
),
)
val unit = units.getOrNull(digitGroups)
if (unit != null) {
append(' ')
append(unit)
}
}
}
fun showUnit(context: Context, amount: Long): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_used).split('|')
if (bytes <= 0) {
return units.first()
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
val unit = units.getOrNull(digitGroups)
if (unit != null) {
append(' ')
append(unit)
}
}
}
fun format(context: Context, amount: Long): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes).split('|')
if (bytes <= 0) {
return "0 ${units.first()}"
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
append(
DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble()),
),
)
val unit = units.getOrNull(digitGroups)
if (unit != null) {
append(' ')
append(unit)
}
}
}
fun formatWithoutUnits(amount: Long): String {
val bytes = amount * multiplier
if (bytes <= 0) {
return "0"
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
append(
DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble()),
),
)
}
}
}

@ -1,16 +0,0 @@
package org.xtimms.tokusho.utils.storage
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import org.xtimms.tokusho.BuildConfig
import java.io.File
/**
* Returns the uri of a file
*
* @param context context of application
*/
fun File.getUriCompat(context: Context): Uri {
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this)
}

@ -1,5 +1,26 @@
package org.xtimms.tokusho.utils.system
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.xtimms.tokusho.BuildConfig
import java.io.File
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.walk
fun Context.getFileProvider() = "$packageName.provider"
fun File.getUriCompat(context: Context): Uri {
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this)
}
fun Context.getFileProvider() = "$packageName.provider"
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
walkCompat().sumOf { it.length() }
}
@OptIn(ExperimentalPathApi::class)
fun File.walkCompat() =
// Use lazy loading on Android 8.0 and later
toPath().walk().map { it.toFile() }

@ -80,4 +80,19 @@
<string name="webview_version">WebView version</string>
<string name="advanced_page">Dump crash logs, debug info</string>
<string name="device_info">Device info</string>
<string name="storage">Storage</string>
<string name="free_up_space">Free up space</string>
<string name="free_up_space_hint">Choose what needs to be cleared</string>
<string name="saved_manga">Saved manga</string>
<string name="pages_cache">Pages cache</string>
<string name="other_cache">Other cache</string>
<string name="thumbnails_cache">Thumbnails cache</string>
<string name="network_cache">Network cache</string>
<string name="text_file_sizes">B|kB|MB|GB|TB</string>
<string name="computing_">Computing...</string>
<string name="text_file_sizes_total">B total|kB total|MB total|GB total|TB total</string>
<string name="text_file_sizes_used">B used|kB used|MB used|GB used|TB used</string>
<string name="text_file_sizes_free">B free|kB free|MB free|GB free|TB free</string>
<string name="confirm">Confirm</string>
<string name="free_up_space_summary">Space can be cleared:</string>
</resources>
Loading…
Cancel
Save