diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db3e91c..83f3f5b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt index 17c9312..55cb9f3 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt @@ -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, diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/Buttons.kt similarity index 70% rename from app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt rename to app/src/main/java/org/xtimms/tokusho/core/components/Buttons.kt index a83b07d..e1083c3 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/Buttons.kt @@ -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( diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/CheckBox.kt b/app/src/main/java/org/xtimms/tokusho/core/components/CheckBox.kt new file mode 100644 index 0000000..b666f1a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/CheckBox.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/Dialogs.kt b/app/src/main/java/org/xtimms/tokusho/core/components/Dialogs.kt new file mode 100644 index 0000000..50d1afa --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/Dialogs.kt @@ -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() + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt similarity index 100% rename from app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt rename to app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt index a04f420..68b2fd9 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt @@ -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() { diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt index f49fbd5..c6139d8 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt @@ -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) - } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt index fb5c134..a55acc9 100644 --- a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt +++ b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt @@ -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 { + val result = LinkedHashSet() + 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 { + val result = LinkedHashSet() + result += File(context.cacheDir, subDir) + context.externalCacheDirs.mapNotNullTo(result) { + File(it ?: return@mapNotNullTo null, subDir) + } + return result + } + + @WorkerThread + private fun getCacheDirs(): MutableSet { + val result = LinkedHashSet() + result += context.cacheDir + context.externalCacheDirs.filterNotNullTo(result) + return result + } + private fun calculateDiskCacheSize(cacheDirectory: File): Long { return try { val cacheDir = StatFs(cacheDirectory.absolutePath) diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt index cf0e1aa..03726ca 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt @@ -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), diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/CleanDialog.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/CleanDialog.kt new file mode 100644 index 0000000..d812e34 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/CleanDialog.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageUiState.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageUiState.kt new file mode 100644 index 0000000..5e7e40d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageUiState.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt new file mode 100644 index 0000000..bca7864 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt @@ -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() + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageViewModel.kt new file mode 100644 index 0000000..08447f8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageViewModel.kt @@ -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() { + + val httpCacheSize = MutableStateFlow(-1L) + val cacheSizes = EnumMap>(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()) + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt index c41d63f..a80de87 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt @@ -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 diff --git a/app/src/main/java/org/xtimms/tokusho/utils/FileSize.kt b/app/src/main/java/org/xtimms/tokusho/utils/FileSize.kt new file mode 100644 index 0000000..580a7cd --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/FileSize.kt @@ -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()), + ), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt deleted file mode 100644 index d79eec0..0000000 --- a/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt index 46b52fb..4f7d172 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt @@ -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" \ No newline at end of file +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() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20a9867..7eba9d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,4 +80,19 @@ WebView version Dump crash logs, debug info Device info + Storage + Free up space + Choose what needs to be cleared + Saved manga + Pages cache + Other cache + Thumbnails cache + Network cache + B|kB|MB|GB|TB + Computing... + B total|kB total|MB total|GB total|TB total + B used|kB used|MB used|GB used|TB used + B free|kB free|MB free|GB free|TB free + Confirm + Space can be cleared: \ No newline at end of file