From d086df7287028da7b8fd8234222f51c387a09210 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Sun, 26 May 2024 09:23:35 +0700 Subject: [PATCH 01/17] feat: Onboarding screen (partial) --- app/build.gradle.kts | 8 +- .../dev/yokai/domain/base/BasePreferences.kt | 3 + .../yokai/presentation/component/AppIcon.kt | 65 ++++ .../yokai/presentation/component/ThemeItem.kt | 332 ++++++++++++++++++ .../core/util/ModifierExtensions.kt | 7 + .../presentation/onboarding/InfoScreen.kt | 184 ++++++++++ .../onboarding/OnboardingController.kt | 37 ++ .../onboarding/OnboardingScreen.kt | 90 +++++ .../onboarding/steps/OnboardingStep.kt | 11 + .../onboarding/steps/ThemeStep.kt | 212 +++++++++++ .../dev/yokai/presentation/theme/Constants.kt | 21 ++ .../tachiyomi/core/preference/Preference.kt | 10 + .../kanade/tachiyomi/ui/main/MainActivity.kt | 4 + app/src/main/res/values/strings.xml | 4 + gradle.properties | 3 +- gradle/compose.versions.toml | 4 + gradle/libs.versions.toml | 2 +- 17 files changed, 987 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/dev/yokai/presentation/component/AppIcon.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt create mode 100644 app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/InfoScreen.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/OnboardingController.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/steps/OnboardingStep.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt create mode 100644 app/src/main/java/dev/yokai/presentation/theme/Constants.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c1b384b12..12ac014761 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,15 +147,9 @@ android { dependencies { // Compose implementation(androidx.activity.compose) - implementation(compose.foundation) - implementation(compose.animation) - implementation(compose.ui) + implementation(compose.bundles.compose) debugImplementation(compose.ui.tooling) - implementation(compose.ui.tooling.preview) - implementation(compose.material) - implementation(compose.material3) implementation(libs.compose.theme.adapter3) - implementation(compose.icons) implementation(libs.accompanist.webview) implementation(androidx.glance.appwidget) diff --git a/app/src/main/java/dev/yokai/domain/base/BasePreferences.kt b/app/src/main/java/dev/yokai/domain/base/BasePreferences.kt index 6e734222f9..1ed4e892c4 100644 --- a/app/src/main/java/dev/yokai/domain/base/BasePreferences.kt +++ b/app/src/main/java/dev/yokai/domain/base/BasePreferences.kt @@ -2,6 +2,7 @@ package dev.yokai.domain.base import androidx.annotation.StringRes import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.preference.Preference import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.preference.getEnum import eu.kanade.tachiyomi.extension.util.ExtensionInstaller @@ -17,4 +18,6 @@ class BasePreferences(private val preferenceStore: PreferenceStore) { } fun displayProfile() = preferenceStore.getString("pref_display_profile_key", "") + + fun hasShownOnboarding() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false) } diff --git a/app/src/main/java/dev/yokai/presentation/component/AppIcon.kt b/app/src/main/java/dev/yokai/presentation/component/AppIcon.kt new file mode 100644 index 0000000000..33e7c275b0 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/AppIcon.kt @@ -0,0 +1,65 @@ +package dev.yokai.presentation.component + +import android.graphics.Bitmap +import android.graphics.drawable.AdaptiveIconDrawable +import android.os.Build +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import eu.kanade.tachiyomi.R + +@Composable +fun AppIcon(size: Dp, modifier: Modifier = Modifier) { + ResourcesCompat.getDrawable( + LocalContext.current.resources, + R.mipmap.ic_launcher, + LocalContext.current.theme + ) + ?.let { drawable -> + val bitmap = + Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = android.graphics.Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = modifier.requiredSize(size) + ) + } +} + +@Composable +fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter { + val res = LocalContext.current.resources + val theme = LocalContext.current.theme + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android O supports adaptive icons, try loading this first (even though this is least + // likely to be the format). + val adaptiveIcon = ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable + if (adaptiveIcon != null) { + BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap()) + } else { + // We couldn't load the drawable as an Adaptive Icon, just use painterResource + painterResource(id) + } + } else { + // We're not on Android O or later, just use painterResource + painterResource(id) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt b/app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt new file mode 100644 index 0000000000..5821ddecf4 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt @@ -0,0 +1,332 @@ +package dev.yokai.presentation.component + +import android.content.Context +import android.content.res.Configuration +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.google.accompanist.themeadapter.material3.createMdc3Theme +import dev.yokai.presentation.theme.HalfAlpha +import dev.yokai.presentation.theme.SecondaryItemAlpha +import dev.yokai.presentation.theme.Size +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.Themes +import eu.kanade.tachiyomi.util.system.isInNightMode + +private data class ContextTheme( + val colorScheme: ColorScheme, + val isThemeMatchesApp: Boolean, + val theme: Themes, + val isDarkTheme: Boolean, +) + +private fun Context.colorSchemeFromAdapter(theme: Themes, isDarkTheme: Boolean): ContextTheme { + val configuration = Configuration(this.resources.configuration) + configuration.uiMode = + if (isDarkTheme) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + val themeContext = this.createConfigurationContext(configuration) + themeContext.setTheme(theme.styleRes) + + @Suppress("DEPRECATION") val colorScheme = + createMdc3Theme( + context = themeContext, + layoutDirection = LayoutDirection.Ltr, + setTextColors = true, + readTypography = false, + ) + .colorScheme!! + + val themeMatchesApp = + if (this.isInNightMode()) { + isDarkTheme + } else { + !isDarkTheme + } + + return ContextTheme(colorScheme, themeMatchesApp, theme, isDarkTheme) +} + +@Composable +fun ThemeItem(theme: Themes, isDarkTheme: Boolean, selected: Boolean, onClick: () -> Unit) { + val context = LocalContext.current + val contextTheme = context.colorSchemeFromAdapter(theme, isDarkTheme) + + ThemeItemNaive(contextTheme = contextTheme, selected = selected, onClick = onClick) +} + +@Composable +private fun ThemeItemNaive(contextTheme: ContextTheme, selected: Boolean, onClick: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(110.dp)) { + ThemePreviewItem( + contextTheme.colorScheme, + selected, + selectedColor = MaterialTheme.colorScheme.primary, + contextTheme.isThemeMatchesApp, + onClick + ) + + Text( + text = stringResource(id = if (contextTheme.isDarkTheme) contextTheme.theme.darkNameRes else contextTheme.theme.nameRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall + ) + } +} + +@Composable +fun ThemePreviewItem( + colorScheme: ColorScheme, + selected: Boolean, + selectedColor: Color, + themeMatchesApp: Boolean, + onClick: () -> Unit, +) { + val actualSelectedColor = + when { + themeMatchesApp && selected -> colorScheme.primary + selected -> selectedColor.copy(alpha = HalfAlpha) + else -> Color.Transparent + } + + val padding = 6 + val outer = 26 + val inner = outer - padding + OutlinedCard( + onClick = onClick, + modifier = Modifier + .height(180.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(outer.dp), + border = BorderStroke(width = Size.tiny, color = actualSelectedColor), + ) { + OutlinedCard( + modifier = Modifier + .height(180.dp) + .fillMaxWidth() + .padding(6.dp), + shape = RoundedCornerShape(inner.dp), + colors = CardDefaults.outlinedCardColors(containerColor = colorScheme.background), + border = BorderStroke(width = 1.dp, color = colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(Size.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .height(15.dp) + .weight(0.7f) + .padding(start = Size.tiny, end = Size.small) + .background( + color = colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + ), + ) + Box( + modifier = Modifier.weight(0.3f), + contentAlignment = Alignment.CenterEnd, + ) { + if (selected) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = stringResource(R.string.selected), + tint = selectedColor, + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .weight(0.6f) + .padding(horizontal = Size.small), + ) { + Box( + modifier = Modifier + .height(30.dp) + .fillMaxWidth() + .background( + color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha), + shape = RoundedCornerShape(8.dp), + ), + ) + Row( + modifier = Modifier + .height(30.dp) + .fillMaxWidth() + .padding(top = Size.small, end = Size.small), + ) { + Box( + modifier = Modifier + .height(15.dp) + .weight(0.8f) + .padding(end = Size.tiny) + .background( + color = colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + ), + ) + + Box( + modifier = Modifier + .height(15.dp) + .weight(0.3f) + .background( + color = colorScheme.secondary, + shape = RoundedCornerShape(6.dp), + ), + ) + } + Row( + modifier = Modifier + .height(15.dp) + .fillMaxWidth() + .padding(end = Size.medium), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.5f) + .padding(end = Size.tiny) + .background( + color = colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + ), + ) + + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.6f) + .background( + color = colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + ), + ) + } + } + Surface( + color = colorScheme.surfaceVariant, + tonalElevation = Size.small, + ) { + Row( + modifier = Modifier + .height(30.dp) + .fillMaxWidth() + .padding(vertical = Size.extraTiny, horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.2f), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .background( + color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha), + shape = CircleShape, + ), + ) + } + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.2f), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .background( + color = colorScheme.secondary, + shape = CircleShape, + ), + ) + } + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.2f), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .background( + color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha), + shape = CircleShape, + ), + ) + } + } + } + } + } +} + +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun ThemeItemPreviewDark() { + val contextTheme = ContextTheme( + colorScheme = darkColorScheme(), + isThemeMatchesApp = true, + theme = Themes.DEFAULT, + isDarkTheme = true, + ) + Surface { + ThemeItemNaive(contextTheme = contextTheme, selected = true) {} + } +} + +@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Composable +fun ThemeItemPreviewLight() { + val contextTheme = ContextTheme( + colorScheme = lightColorScheme(), + isThemeMatchesApp = true, + theme = Themes.DEFAULT, + isDarkTheme = false, + ) + Surface { + ThemeItemNaive(contextTheme = contextTheme, selected = false) {} + } +} diff --git a/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt b/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt new file mode 100644 index 0000000000..7516806c28 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt @@ -0,0 +1,7 @@ +package dev.yokai.presentation.core.util + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import dev.yokai.presentation.theme.SecondaryItemAlpha + +fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha) diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/InfoScreen.kt b/app/src/main/java/dev/yokai/presentation/onboarding/InfoScreen.kt new file mode 100644 index 0000000000..e887afaf6e --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/InfoScreen.kt @@ -0,0 +1,184 @@ +package dev.yokai.presentation.onboarding + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RocketLaunch +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.zIndex +import dev.yokai.presentation.core.util.secondaryItemAlpha +import dev.yokai.presentation.theme.Size + +@Composable +fun InfoScreen( + icon: ImageVector, + headingText: String, + subtitleText: String, + tint: Color = MaterialTheme.colorScheme.primary, + acceptText: String, + onAcceptClick: () -> Unit, + canAccept: Boolean, + rejectText: String? = null, + onRejectClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + InfoScreen( + icon = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(bottom = Size.small) + .size(Size.huge), + tint = tint, + ) + }, + headingText = headingText, + subtitleText = subtitleText, + acceptText = acceptText, + onAcceptClick = onAcceptClick, + canAccept = canAccept, + rejectText = rejectText, + onRejectClick = onRejectClick, + content = content, + ) +} + +@Composable +fun InfoScreen( + icon: @Composable () -> Unit, + headingText: String, + subtitleText: String, + tint: Color = MaterialTheme.colorScheme.primary, + acceptText: String, + onAcceptClick: () -> Unit, + canAccept: Boolean, + rejectText: String? = null, + onRejectClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + bottomBar = { + val strokeWidth = Dp.Hairline + val borderColor = MaterialTheme.colorScheme.outline + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .drawBehind { + drawLine( + borderColor, + Offset(0f, 0f), + Offset(size.width, 0f), + strokeWidth.value, + ) + } + .windowInsetsPadding(NavigationBarDefaults.windowInsets) + .padding( + horizontal = Size.medium, + vertical = Size.small, + ), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = canAccept, + colors = ButtonDefaults.buttonColors(containerColor = tint), + onClick = onAcceptClick, + ) { + Text(text = acceptText) + } + if (rejectText != null && onRejectClick != null) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + border = + BorderStroke( + width = Size.extraExtraTiny, + color = tint, + ), + onClick = onRejectClick, + ) { + Text(text = rejectText) + } + } + } + }, + ) { paddingValues -> + // Status bar scrim + Box( + modifier = Modifier + .zIndex(2f) + .secondaryItemAlpha() + .background(MaterialTheme.colorScheme.background) + .fillMaxWidth() + .height(paddingValues.calculateTopPadding()), + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(paddingValues) + .padding(top = Size.huge) + .padding(horizontal = Size.medium), + ) { + icon() + + Text( + text = headingText, + style = MaterialTheme.typography.headlineLarge, + ) + Text( + text = subtitleText, + modifier = Modifier + .secondaryItemAlpha() + .padding(vertical = Size.small), + style = MaterialTheme.typography.titleSmall, + ) + + content() + } + } +} + +@Preview +@Composable +private fun InfoScreenPreview() { + InfoScreen( + icon = Icons.Outlined.RocketLaunch, + headingText = "Welcome!", + subtitleText = "Subtitle", + acceptText = "Accept", + onAcceptClick = {}, + canAccept = true, + rejectText = "Reject", + onRejectClick = {}, + ) { + Text(text = "Hello World") + } +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingController.kt b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingController.kt new file mode 100644 index 0000000000..90284afc28 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingController.kt @@ -0,0 +1,37 @@ +package dev.yokai.presentation.onboarding + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import dev.yokai.domain.base.BasePreferences +import eu.kanade.tachiyomi.core.preference.collectAsState +import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController +import uy.kohesive.injekt.injectLazy + +class OnboardingController : + BaseComposeController() { + + val basePreferences by injectLazy() + + @Composable + override fun ScreenContent() { + + val hasShownOnboarding by basePreferences.hasShownOnboarding().collectAsState() + + val finishOnboarding: () -> Unit = { + basePreferences.hasShownOnboarding().set(true) + router.popCurrentController() + } + + BackHandler( + enabled = !hasShownOnboarding, + onBack = { + // Prevent exiting if onboarding hasn't been completed + }, + ) + + OnboardingScreen( + onComplete = finishOnboarding + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000000..8a4a1a25d5 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt @@ -0,0 +1,90 @@ +package dev.yokai.presentation.onboarding + +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import dev.yokai.presentation.onboarding.steps.ThemeStep +import dev.yokai.presentation.theme.Size +import eu.kanade.tachiyomi.R +import soup.compose.material.motion.animation.materialSharedAxisX +import soup.compose.material.motion.animation.rememberSlideDistance + +@SuppressLint("UnusedContentLambdaTargetStateParameter") +@Composable +fun OnboardingScreen( + onComplete: () -> Unit = {} +) { + val slideDistance = rememberSlideDistance() + + var currentStep by rememberSaveable { mutableIntStateOf(0) } + val steps = remember { + listOf( + ThemeStep(), + ThemeStep(), + ThemeStep(), + ) + } + val isLastStep = currentStep == steps.lastIndex + + BackHandler(enabled = currentStep != 0, onBack = { currentStep-- }) + + InfoScreen( + icon = Icons.Outlined.RocketLaunch, + headingText = stringResource(R.string.onboarding_heading), + subtitleText = stringResource(R.string.onboarding_description), + tint = MaterialTheme.colorScheme.primary, + acceptText = stringResource( + if (isLastStep) + R.string.onboarding_finish + else { + R.string.next + } + ), + canAccept = steps[currentStep].isComplete, + onAcceptClick = { + if (isLastStep) { + onComplete() + } else { + currentStep++ + } + }, + ) { + Box( + modifier = Modifier + .padding(vertical = Size.small) + .clip(MaterialTheme.shapes.small) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + AnimatedContent( + targetState = currentStep, + transitionSpec = { + materialSharedAxisX( + forward = targetState > initialState, + slideDistance = slideDistance, + ) + }, + label = "stepContent", + ) { + steps[currentStep].Content() + } + } + } +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/steps/OnboardingStep.kt b/app/src/main/java/dev/yokai/presentation/onboarding/steps/OnboardingStep.kt new file mode 100644 index 0000000000..0ead72e670 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/steps/OnboardingStep.kt @@ -0,0 +1,11 @@ +package dev.yokai.presentation.onboarding.steps + +import androidx.compose.runtime.Composable + +internal interface OnboardingStep { + + val isComplete: Boolean + + @Composable + fun Content() +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt b/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt new file mode 100644 index 0000000000..26f06d739e --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt @@ -0,0 +1,212 @@ +package dev.yokai.presentation.onboarding.steps + +import android.app.Activity +import android.content.Context +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import com.google.android.material.color.DynamicColors +import dev.yokai.presentation.component.ThemeItem +import dev.yokai.presentation.theme.Size +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.preference.collectAsState +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.system.Themes +import eu.kanade.tachiyomi.util.system.appDelegateNightMode +import uy.kohesive.injekt.injectLazy + +class ThemeStep : OnboardingStep { + override val isComplete: Boolean = true + + private val preferences: PreferencesHelper by injectLazy() + + @Composable + override fun Content() { + val context = LocalContext.current + + val nightModePreference = preferences.nightMode() + + val nightMode by nightModePreference.collectAsState() + + val followingSystemTheme by remember(nightMode) { + derivedStateOf { nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } + } + + val darkAppTheme by preferences.darkTheme().collectAsState() + val lightAppTheme by preferences.lightTheme().collectAsState() + val supportsDynamic = DynamicColors.isDynamicColorAvailable() + + Themes.entries + .filter { + (!it.isDarkTheme || it.followsSystem) && + (it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic) + } + .toSet() + + val lightThemes by remember { + derivedStateOf { + Themes.entries + .filter { + (!it.isDarkTheme || it.followsSystem) && + (it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic) + } + .toSet() + } + } + + val darkThemes by remember { + derivedStateOf { + Themes.entries + .filter { + (it.isDarkTheme || it.followsSystem) && + (it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic) + } + .toSet() + } + } + + Column(modifier = Modifier.padding(Size.medium)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(id = R.string.follow_system_theme)) + Switch( + checked = nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + colors = + SwitchDefaults.colors( + checkedTrackColor = MaterialTheme.colorScheme.primary + ), + onCheckedChange = { + when (it) { + true -> { + preferences + .nightMode() + .set(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + (context as? Activity)?.let { activity -> + ActivityCompat.recreate(activity) + } + } + false -> preferences.nightMode().set(context.appDelegateNightMode()) + } + } + ) + } + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(Size.medium) + ) { + lightThemes.forEach { theme -> + val isSelected = + remember(darkAppTheme, lightAppTheme, nightMode) { + isSelected(theme, false, darkAppTheme, lightAppTheme, nightMode) + } + ThemeItem( + theme = theme, + isDarkTheme = false, + selected = isSelected, + onClick = { + themeClicked( + theme, + context, + isSelected = isSelected, + followingSystemTheme = followingSystemTheme, + isDarkTheme = false + ) + } + ) + } + } + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(Size.medium) + ) { + darkThemes.forEach { theme -> + val isSelected = + remember(darkAppTheme, lightAppTheme, nightMode) { + isSelected(theme, true, darkAppTheme, lightAppTheme, nightMode) + } + ThemeItem( + theme = theme, + isDarkTheme = true, + selected = isSelected, + onClick = { + themeClicked( + theme, + context, + isSelected = isSelected, + followingSystemTheme = followingSystemTheme, + isDarkTheme = true + ) + } + ) + } + } + } + } + + private fun isSelected( + theme: Themes, + isDarkTheme: Boolean, + darkAppTheme: Themes, + lightAppTheme: Themes, + nightMode: Int + ): Boolean { + return when (nightMode) { + AppCompatDelegate.MODE_NIGHT_YES -> darkAppTheme == theme && isDarkTheme + AppCompatDelegate.MODE_NIGHT_NO -> lightAppTheme == theme && !isDarkTheme + else -> + (darkAppTheme == theme && isDarkTheme) || (lightAppTheme == theme && !isDarkTheme) + } + } + + private fun themeClicked( + theme: Themes, + context: Context, + isSelected: Boolean, + followingSystemTheme: Boolean, + isDarkTheme: Boolean + ) { + val nightMode = + when (isDarkTheme) { + true -> { + preferences.darkTheme().set(theme) + AppCompatDelegate.MODE_NIGHT_YES + } + false -> { + preferences.lightTheme().set(theme) + AppCompatDelegate.MODE_NIGHT_NO + } + } + + if (followingSystemTheme && isSelected) { + preferences.nightMode().set(nightMode) + } else if (!followingSystemTheme) { + preferences.nightMode().set(nightMode) + } + + (context as? Activity)?.let { activity -> ActivityCompat.recreate(activity) } + } +} diff --git a/app/src/main/java/dev/yokai/presentation/theme/Constants.kt b/app/src/main/java/dev/yokai/presentation/theme/Constants.kt new file mode 100644 index 0000000000..f49ac886dd --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/theme/Constants.kt @@ -0,0 +1,21 @@ +package dev.yokai.presentation.theme + +import androidx.compose.ui.unit.dp + +const val SecondaryItemAlpha = .78f +const val HalfAlpha = .5f + +object Size { + val none = 0.dp + val extraExtraTiny = 1.dp + val extraTiny = 2.dp + val tiny = 4.dp + val small = 8.dp + val smedium = 12.dp + val medium = 16.dp + val large = 24.dp + val extraLarge = 32.dp + val huge = 48.dp + val extraHuge = 56.dp + val navBarSize = 68.dp +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/core/preference/Preference.kt b/app/src/main/java/eu/kanade/tachiyomi/core/preference/Preference.kt index 2950d0db48..6addefaa11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/core/preference/Preference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/core/preference/Preference.kt @@ -1,5 +1,9 @@ package eu.kanade.tachiyomi.core.preference +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -73,3 +77,9 @@ fun Preference.toggle(): Boolean { set(!get()) return get() } + +@Composable +fun Preference.collectAsState(): State { + val flow = remember(this) { changes() } + return flow.collectAsState(initial = get()) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 1b718dc092..833fb0e49d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -76,6 +76,7 @@ import com.google.common.primitives.Ints.max import dev.yokai.domain.base.BasePreferences import dev.yokai.domain.ui.settings.ReaderPreferences import dev.yokai.presentation.extension.repo.ExtensionRepoController +import dev.yokai.presentation.onboarding.OnboardingController import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.Migrations import eu.kanade.tachiyomi.R @@ -519,6 +520,9 @@ open class MainActivity : BaseActivity() { // Set start screen if (!handleIntentAction(intent)) { goToStartingTab() + if (!basePreferences.hasShownOnboarding().get()) { + router.pushController(OnboardingController().withFadeInTransaction()) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec21a3405c..5c84c59472 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,10 @@ TachiyomiJ2K requires access to all files in Android 11 to download chapters, create automatic backups, and read local series. \n\nOn the next screen, enable \"Allow access to manage all files.\" TachiyomiJ2K requires access to all files to download chapters. Tap here, then enable \"Allow access to manage all files.\" + Welcome! + Let\'s pick some defaults. You can always change these things later in the settings. + Get started + diff --git a/gradle.properties b/gradle.properties index 146a70167a..be89a213a4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,6 +22,5 @@ android.enableJetifier=true android.useAndroidX=true org.gradle.jvmargs=-Xmx2048M org.gradle.caching=true -android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index 04dbcd4210..891b20f114 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -8,8 +8,12 @@ animation = { module = "androidx.compose.animation:animation", version.ref = "co foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } material = { module = "androidx.compose.material:material", version.ref = "compose" } material3 = { module = "androidx.compose.material3:material3", version = "1.2.1" } +material-motion = { module = "io.github.fornewid:material-motion-compose-core", version = "1.0.7" } lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" } + +[bundles] +compose = [ "animation", "foundation", "material", "material3", "material-motion", "ui", "ui-tooling-preview", "icons" ] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2de17c3d9a..a7febdfd7b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ flexbox = { module = "com.google.android.flexbox:flexbox", version = "3.0.0" } flexible-adapter-ui = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui", version.ref = "flexible-adapter" } flexible-adapter = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter", version.ref = "flexible-adapter" } google-services = { module = "com.google.gms:google-services", version = "4.4.1" } -gradle = { module = "com.android.tools.build:gradle", version = "8.1.4" } +gradle = { module = "com.android.tools.build:gradle", version = "8.2.0" } guava = { module = "com.google.guava:guava", version = "31.1-android" } image-decoder = { module = "com.github.tachiyomiorg:image-decoder", version = "e08e9be535" } injekt-core = { module = "com.github.inorichi.injekt:injekt-core", version = "65b0440" } From b4b232d46802251f4dc65aeaafd297f4f0d5eb44 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Sun, 26 May 2024 17:04:27 +0700 Subject: [PATCH 02/17] style: Fix spacing --- .../dev/yokai/presentation/onboarding/steps/ThemeStep.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt b/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt index 26f06d739e..246211a31d 100644 --- a/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt +++ b/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt @@ -85,7 +85,10 @@ class ThemeStep : OnboardingStep { } } - Column(modifier = Modifier.padding(Size.medium)) { + Column( + modifier = Modifier.padding(Size.medium), + verticalArrangement = Arrangement.spacedBy(Size.medium), + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -94,8 +97,7 @@ class ThemeStep : OnboardingStep { Text(text = stringResource(id = R.string.follow_system_theme)) Switch( checked = nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, - colors = - SwitchDefaults.colors( + colors = SwitchDefaults.colors( checkedTrackColor = MaterialTheme.colorScheme.primary ), onCheckedChange = { From f0982cfea9bb9e6f461b058e53a7616d58836977 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 05:56:45 +0700 Subject: [PATCH 03/17] fix: Onboarding animation --- .../dev/yokai/presentation/onboarding/OnboardingScreen.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt index 8a4a1a25d5..7a2f5885e1 100644 --- a/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt @@ -26,7 +26,6 @@ import eu.kanade.tachiyomi.R import soup.compose.material.motion.animation.materialSharedAxisX import soup.compose.material.motion.animation.rememberSlideDistance -@SuppressLint("UnusedContentLambdaTargetStateParameter") @Composable fun OnboardingScreen( onComplete: () -> Unit = {} @@ -82,8 +81,8 @@ fun OnboardingScreen( ) }, label = "stepContent", - ) { - steps[currentStep].Content() + ) { step -> + steps[step].Content() } } } From 4060278332e65766d904e47890b528890df3bd94 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 05:59:46 +0700 Subject: [PATCH 04/17] chore: Use material3's text --- .../yokai/presentation/extension/repo/ExtensionRepoScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoScreen.kt b/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoScreen.kt index 4c450fa4af..3894b8099a 100644 --- a/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoScreen.kt +++ b/app/src/main/java/dev/yokai/presentation/extension/repo/ExtensionRepoScreen.kt @@ -4,11 +4,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExtensionOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState From 9046b343dea92c8d12cffdd6da207d6a20460524 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 06:20:32 +0700 Subject: [PATCH 05/17] feat: Finish onboarding screen Co-Authored-By: nonproto --- .../dev/yokai/presentation/component/Gap.kt | 27 +++ .../onboarding/OnboardingScreen.kt | 6 +- .../onboarding/steps/PermissionStep.kt | 199 ++++++++++++++++++ .../onboarding/steps/StorageStep.kt | 141 +++++++++++++ .../onboarding/steps/ThemeStep.kt | 2 +- app/src/main/res/values/strings.xml | 16 ++ 6 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/dev/yokai/presentation/component/Gap.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/steps/PermissionStep.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/steps/StorageStep.kt diff --git a/app/src/main/java/dev/yokai/presentation/component/Gap.kt b/app/src/main/java/dev/yokai/presentation/component/Gap.kt new file mode 100644 index 0000000000..8d21b3cae6 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/Gap.kt @@ -0,0 +1,27 @@ +package dev.yokai.presentation.component + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +@Composable +fun RowScope.Gap(width: Dp, modifier: Modifier = Modifier) { + Spacer(modifier = modifier.width(width)) +} + +@Composable +fun ColumnScope.Gap(height: Dp, modifier: Modifier = Modifier) { + Spacer(modifier = modifier.height(height)) +} + +@Composable +fun LazyItemScope.Gap(padding: Dp, modifier: Modifier = Modifier) { + Spacer(modifier = modifier.size(padding)) +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt index 7a2f5885e1..72f40a6df4 100644 --- a/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import dev.yokai.presentation.onboarding.steps.PermissionStep +import dev.yokai.presentation.onboarding.steps.StorageStep import dev.yokai.presentation.onboarding.steps.ThemeStep import dev.yokai.presentation.theme.Size import eu.kanade.tachiyomi.R @@ -36,8 +38,8 @@ fun OnboardingScreen( val steps = remember { listOf( ThemeStep(), - ThemeStep(), - ThemeStep(), + StorageStep(), + PermissionStep(), ) } val isLastStep = currentStep == steps.lastIndex diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/steps/PermissionStep.kt b/app/src/main/java/dev/yokai/presentation/onboarding/steps/PermissionStep.kt new file mode 100644 index 0000000000..6a0da096a6 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/steps/PermissionStep.kt @@ -0,0 +1,199 @@ +package dev.yokai.presentation.onboarding.steps + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.core.content.getSystemService +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import dev.yokai.presentation.component.Gap +import dev.yokai.presentation.theme.Size +import eu.kanade.tachiyomi.R + +internal class PermissionStep : OnboardingStep { + + private var installGranted by mutableStateOf(false) + + override val isComplete: Boolean + get() = installGranted + + @Composable + override fun Content() { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + var notificationGranted by remember { + mutableStateOf( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } else { + true + } + ) + } + + var batteryGranted by remember { + mutableStateOf( + context + .getSystemService()!! + .isIgnoringBatteryOptimizations(context.packageName) + ) + } + + DisposableEffect(lifecycleOwner.lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + installGranted = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else { + @Suppress("DEPRECATION") + Settings.Secure.getInt( + context.contentResolver, + Settings.Secure.INSTALL_NON_MARKET_APPS + ) != 0 + } + batteryGranted = + context + .getSystemService()!! + .isIgnoringBatteryOptimizations(context.packageName) + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + Column( + modifier = Modifier.padding(vertical = Size.medium), + ) { + SectionHeader(stringResource(R.string.onboarding_permission_type_required)) + + PermissionItem( + title = stringResource(R.string.onboarding_permission_install_apps), + subtitle = stringResource(R.string.onboarding_permission_install_apps_description), + granted = installGranted, + onButtonClick = { + val intent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + } + } else { + Intent(Settings.ACTION_SECURITY_SETTINGS) + } + context.startActivity(intent) + }, + ) + + Gap(Size.medium) + + SectionHeader(stringResource(R.string.onboarding_permission_type_optional)) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionRequester = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { bool -> notificationGranted = bool }, + ) + PermissionItem( + title = stringResource(R.string.onboarding_permission_notifications), + subtitle = + stringResource(R.string.onboarding_permission_notifications_description), + granted = notificationGranted, + onButtonClick = { + permissionRequester.launch(Manifest.permission.POST_NOTIFICATIONS) + }, + ) + } + + PermissionItem( + title = stringResource(R.string.onboarding_permission_ignore_battery_opts), + subtitle = + stringResource(R.string.onboarding_permission_ignore_battery_opts_description), + granted = batteryGranted, + onButtonClick = { + @SuppressLint("BatteryLife") + val intent = + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + context.startActivity(intent) + }, + ) + } + } + + @Composable + private fun SectionHeader( + text: String, + modifier: Modifier = Modifier, + ) { + Text( + text = text, + style = MaterialTheme.typography.titleLarge, + modifier = modifier.padding(horizontal = Size.medium), + ) + } + + @Composable + private fun PermissionItem( + title: String, + subtitle: String, + granted: Boolean, + modifier: Modifier = Modifier, + onButtonClick: () -> Unit, + ) { + ListItem( + modifier = modifier, + headlineContent = { Text(text = title) }, + supportingContent = { Text(text = subtitle) }, + trailingContent = { + OutlinedButton( + enabled = !granted, + onClick = onButtonClick, + ) { + if (granted) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Text(stringResource(R.string.onboarding_permission_action_grant)) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/steps/StorageStep.kt b/app/src/main/java/dev/yokai/presentation/onboarding/steps/StorageStep.kt new file mode 100644 index 0000000000..69f8204838 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/steps/StorageStep.kt @@ -0,0 +1,141 @@ +package dev.yokai.presentation.onboarding.steps + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StoragePreferences +import dev.yokai.presentation.theme.Size +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.preference.Preference +import eu.kanade.tachiyomi.core.preference.collectAsState +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest +import uy.kohesive.injekt.injectLazy + +internal class StorageStep : OnboardingStep { + + private val storagePref: StoragePreferences by injectLazy() + + private var _isComplete by mutableStateOf(false) + + override val isComplete: Boolean + get() = _isComplete + + @Composable + override fun Content() { + val context = LocalContext.current + val handler = LocalUriHandler.current + + val pickStorageLocation = storageLocationPicker(storagePref.baseStorageDirectory()) + + Column( + modifier = Modifier.padding(Size.medium).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Size.small), + ) { + Text( + stringResource( + R.string.onboarding_storage_info, + stringResource(R.string.app_name), + storageLocationText(storagePref.baseStorageDirectory()), + ), + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + try { + pickStorageLocation.launch(null) + } catch (e: ActivityNotFoundException) { + context.toast(R.string.file_picker_error) + } + }, + ) { + Text(stringResource(R.string.onboarding_storage_action_select)) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = Size.small), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text(stringResource(R.string.onboarding_storage_help_info)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + handler.openUri( + "https://mihon.app/docs/faq/storage#migrating-from-tachiyomi-v0-14-x-or-earlier" + ) + }, + ) { + Text(stringResource(R.string.onboarding_storage_help_action)) + } + } + + LaunchedEffect(Unit) { + storagePref.baseStorageDirectory().changes().collectLatest { + _isComplete = storagePref.baseStorageDirectory().isSet() + } + } + } +} + +@Composable +fun storageLocationPicker( + storageDirPref: Preference, +): ManagedActivityResultLauncher { + val context = LocalContext.current + + return rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> + if (uri != null) { + val flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + + UniFile.fromUri(context, uri)?.let { storageDirPref.set(it.uri.toString()) } + } + } +} + +@Composable +fun storageLocationText( + storageDirPref: Preference, +): String { + val context = LocalContext.current + val storageDir by storageDirPref.collectAsState() + + if (storageDir == storageDirPref.defaultValue()) { + return stringResource(R.string.no_location_set) + } + + return remember(storageDir) { + val file = UniFile.fromUri(context, storageDir.toUri()) + file?.filePath + } ?: stringResource(R.string.invalid_location, storageDir) +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt b/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt index 246211a31d..1e2cde5eec 100644 --- a/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt +++ b/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt @@ -35,7 +35,7 @@ import eu.kanade.tachiyomi.util.system.Themes import eu.kanade.tachiyomi.util.system.appDelegateNightMode import uy.kohesive.injekt.injectLazy -class ThemeStep : OnboardingStep { +internal class ThemeStep : OnboardingStep { override val isComplete: Boolean = true private val preferences: PreferencesHelper by injectLazy() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c84c59472..0f8d618482 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,22 @@ Welcome! Let\'s pick some defaults. You can always change these things later in the settings. Get started + Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s + Select a folder + Updating from an older version and not sure what to select? Refer to the Tachiyomi upgrade section on the Mihon storage guide for more information. + Storage guide + Required + Optional but recommended + Install apps permission + To install the app on updates. + Notification permission + Get notified for library updates and more. + Background battery usage + Avoid interruptions to long-running library updates, downloads, and backup restores. + Grant + + No storage location set + Invalid location: %s From 6887d779efd87b5711508eb44d35643135776a64 Mon Sep 17 00:00:00 2001 From: Tachiyomi Maintainer Date: Mon, 27 May 2024 07:24:13 +0700 Subject: [PATCH 06/17] feat: Composable preference widgets --- .../presentation/component/LabeledCheckbox.kt | 51 +++++ .../presentation/component/TrackLogoIcon.kt | 49 +++++ .../component/preference/Preference.kt | 180 +++++++++++++++++ .../component/preference/PreferenceItem.kt | 182 ++++++++++++++++++ .../preference/widget/BasePreferenceWidget.kt | 125 ++++++++++++ .../widget/EditTextPreferenceWidget.kt | 97 ++++++++++ .../component/preference/widget/InfoWidget.kt | 36 ++++ .../preference/widget/ListPreferenceWidget.kt | 110 +++++++++++ .../widget/MultiListPreferenceWidget.kt | 82 ++++++++ .../widget/PreferenceGroupHeader.kt | 28 +++ .../widget/SwitchPreferenceWidget.kt | 32 +++ .../preference/widget/TextPreferenceWidget.kt | 54 ++++++ .../widget/TrackingPreferenceWidget.kt | 63 ++++++ .../preference/widget/TriStateListDialog.kt | 142 ++++++++++++++ .../core/util/ModifierExtensions.kt | 13 ++ 15 files changed, 1244 insertions(+) create mode 100644 app/src/main/java/dev/yokai/presentation/component/LabeledCheckbox.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/TrackLogoIcon.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/Preference.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/PreferenceItem.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/BasePreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/EditTextPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/InfoWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/ListPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/MultiListPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/PreferenceGroupHeader.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/SwitchPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/TextPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/TrackingPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/TriStateListDialog.kt diff --git a/app/src/main/java/dev/yokai/presentation/component/LabeledCheckbox.kt b/app/src/main/java/dev/yokai/presentation/component/LabeledCheckbox.kt new file mode 100644 index 0000000000..6ab18fe06d --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/LabeledCheckbox.kt @@ -0,0 +1,51 @@ +package dev.yokai.presentation.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import dev.yokai.presentation.theme.Size + +@Composable +fun LabeledCheckbox( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + modifier = modifier + .clip(MaterialTheme.shapes.small) + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable( + role = Role.Checkbox, + onClick = { + if (enabled) { + onCheckedChange(!checked) + } + }, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size.small), + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + + Text(text = label) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/TrackLogoIcon.kt b/app/src/main/java/dev/yokai/presentation/component/TrackLogoIcon.kt new file mode 100644 index 0000000000..cc18fac295 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/TrackLogoIcon.kt @@ -0,0 +1,49 @@ +package dev.yokai.presentation.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.yokai.presentation.core.util.clickableNoIndication +import eu.kanade.tachiyomi.data.track.TrackService + +@Composable +fun TrackLogoIcon( + tracker: TrackService, + onClick: (() -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + + val modifier = if (onClick != null) { + Modifier.clickableNoIndication( + interactionSource = interactionSource, + onClick = onClick + ) + } else { + Modifier + } + + Box( + modifier = modifier + .size(48.dp) + .background(color = Color(tracker.getLogoColor()), shape = MaterialTheme.shapes.medium) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(tracker.getLogo()), + contentDescription = stringResource(id = tracker.nameRes()), + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/Preference.kt b/app/src/main/java/dev/yokai/presentation/component/preference/Preference.kt new file mode 100644 index 0000000000..f997787b73 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/Preference.kt @@ -0,0 +1,180 @@ +package dev.yokai.presentation.component.preference + +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 eu.kanade.tachiyomi.core.preference.Preference as PreferenceData + +sealed class Preference { + abstract val title: String + abstract val enabled: Boolean + + sealed class PreferenceItem : Preference() { + abstract val subtitle: String? + abstract val icon: ImageVector? + abstract val onValueChanged: suspend (newValue: T) -> Boolean + + /** + * A basic [PreferenceItem] that only displays texts. + */ + data class TextPreference( + override val title: String, + override val subtitle: String? = null, + override val icon: ImageVector? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (newValue: String) -> Boolean = { true }, + + val onClick: (() -> Unit)? = null, + ) : PreferenceItem() + + /** + * A [PreferenceItem] that provides a two-state toggleable option. + */ + data class SwitchPreference( + val pref: PreferenceData, + override val title: String, + override val subtitle: String? = null, + override val icon: ImageVector? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (newValue: Boolean) -> Boolean = { true }, + ) : PreferenceItem() + + /** + * A [PreferenceItem] that provides a slider to select an integer number. + */ + data class SliderPreference( + val value: Int, + val min: Int = 0, + val max: Int, + override val title: String = "", + override val subtitle: String? = null, + override val icon: ImageVector? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (newValue: Int) -> Boolean = { true }, + ) : PreferenceItem() + + /** + * A [PreferenceItem] that displays a list of entries as a dialog. + */ + @Suppress("UNCHECKED_CAST") + data class ListPreference( + val pref: PreferenceData, + override val title: String, + override val subtitle: String? = "%s", + val subtitleProvider: @Composable (value: T, entries: ImmutableMap) -> String? = + { v, e -> subtitle?.format(e[v]) }, + override val icon: ImageVector? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (newValue: T) -> Boolean = { true }, + + val entries: ImmutableMap, + ) : PreferenceItem() { + internal fun internalSet(newValue: Any) = pref.set(newValue as T) + internal suspend fun internalOnValueChanged(newValue: Any) = onValueChanged(newValue as T) + + @Composable + internal fun internalSubtitleProvider(value: Any?, entries: ImmutableMap) = + subtitleProvider(value as T, entries as ImmutableMap) + } + + /** + * [ListPreference] but with no connection to a [PreferenceData] + */ + data class BasicListPreference( + val value: String, + override val title: String, + override val subtitle: String? = "%s", + val subtitleProvider: @Composable (value: String, entries: ImmutableMap) -> String? = + { v, e -> subtitle?.format(e[v]) }, + override val icon: ImageVector? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (newValue: String) -> Boolean = { true }, + + val entries: ImmutableMap, + ) : PreferenceItem() + + /** + * A [PreferenceItem] that displays a list of entries as a dialog. + * Multiple entries can be selected at the same time. + */ + data class MultiSelectListPreference( + val pref: PreferenceData>, + override val title: String, + override val subtitle: String? = "%s", + val subtitleProvider: @Composable ( + value: Set, + entries: ImmutableMap, + ) -> String? = { v, e -> + val combined = remember(v) { + v.map { e[it] } + .takeIf { it.isNotEmpty() } + ?.joinToString() + } ?: stringResource(R.string.none) + subtitle?.format(combined) + }, + override val icon: ImageVector? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (newValue: Set) -> Boolean = { true }, + + val entries: ImmutableMap, + ) : PreferenceItem>() + + /** + * A [PreferenceItem] that shows a EditText in the dialog. + */ + data class EditTextPreference( + val pref: PreferenceData, + override val title: String, + override val subtitle: String? = "%s", + override val icon: ImageVector? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (newValue: String) -> Boolean = { true }, + ) : PreferenceItem() + + /** + * A [PreferenceItem] for individual tracker. + */ + data class TrackerPreference( + val tracker: TrackService, + override val title: String, + val login: () -> Unit, + val logout: () -> Unit, + ) : PreferenceItem() { + override val enabled: Boolean = true + override val subtitle: String? = null + override val icon: ImageVector? = null + override val onValueChanged: suspend (newValue: String) -> Boolean = { true } + } + + data class InfoPreference( + override val title: String, + ) : PreferenceItem() { + override val enabled: Boolean = true + override val subtitle: String? = null + override val icon: ImageVector? = null + override val onValueChanged: suspend (newValue: String) -> Boolean = { true } + } + + data class CustomPreference( + override val title: String, + val content: @Composable (PreferenceItem) -> Unit, + ) : PreferenceItem() { + override val enabled: Boolean = true + override val subtitle: String? = null + override val icon: ImageVector? = null + override val onValueChanged: suspend (newValue: String) -> Boolean = { true } + } + } + + data class PreferenceGroup( + override val title: String, + override val enabled: Boolean = true, + + val preferenceItems: ImmutableList>, + ) : Preference() +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/PreferenceItem.kt b/app/src/main/java/dev/yokai/presentation/component/preference/PreferenceItem.kt new file mode 100644 index 0000000000..ab94e5eb90 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/PreferenceItem.kt @@ -0,0 +1,182 @@ +package dev.yokai.presentation.component.preference + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.unit.dp +import androidx.glance.text.Text +import dev.yokai.presentation.component.preference.widget.EditTextPreferenceWidget +import dev.yokai.presentation.component.preference.widget.InfoWidget +import dev.yokai.presentation.component.preference.widget.ListPreferenceWidget +import dev.yokai.presentation.component.preference.widget.MultiSelectListPreferenceWidget +import dev.yokai.presentation.component.preference.widget.SwitchPreferenceWidget +import dev.yokai.presentation.component.preference.widget.TextPreferenceWidget +import dev.yokai.presentation.component.preference.widget.TrackingPreferenceWidget +import eu.kanade.tachiyomi.core.preference.collectAsState +import eu.kanade.tachiyomi.data.track.TrackPreferences +import kotlinx.coroutines.launch +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +val LocalPreferenceHighlighted = compositionLocalOf(structuralEqualityPolicy()) { false } +val LocalPreferenceMinHeight = compositionLocalOf(structuralEqualityPolicy()) { 56.dp } + +@Composable +fun StatusWrapper( + item: Preference.PreferenceItem<*>, + highlightKey: String?, + content: @Composable () -> Unit, +) { + val enabled = item.enabled + val highlighted = item.title == highlightKey + AnimatedVisibility( + visible = enabled, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + content = { + CompositionLocalProvider( + LocalPreferenceHighlighted provides highlighted, + content = content, + ) + }, + ) +} + +@Composable +internal fun PreferenceItem( + item: Preference.PreferenceItem<*>, + highlightKey: String?, +) { + val scope = rememberCoroutineScope() + StatusWrapper( + item = item, + highlightKey = highlightKey, + ) { + when (item) { + is Preference.PreferenceItem.SwitchPreference -> { + val value by item.pref.collectAsState() + SwitchPreferenceWidget( + title = item.title, + subtitle = item.subtitle, + icon = item.icon, + checked = value, + onCheckedChanged = { newValue -> + scope.launch { + if (item.onValueChanged(newValue)) { + item.pref.set(newValue) + } + } + }, + ) + } + is Preference.PreferenceItem.SliderPreference -> { + // TODO: use different composable? + // FIXME: Add the actual thing + Text(text = "Hello World") + /* + SliderItem( + label = item.title, + min = item.min, + max = item.max, + value = item.value, + valueText = item.subtitle.takeUnless { it.isNullOrEmpty() } ?: item.value.toString(), + onChange = { + scope.launch { + item.onValueChanged(it) + } + }, + ) + */ + } + is Preference.PreferenceItem.ListPreference<*> -> { + val value by item.pref.collectAsState() + ListPreferenceWidget( + value = value, + title = item.title, + subtitle = item.internalSubtitleProvider(value, item.entries), + icon = item.icon, + entries = item.entries, + onValueChange = { newValue -> + scope.launch { + if (item.internalOnValueChanged(newValue!!)) { + item.internalSet(newValue) + } + } + }, + ) + } + is Preference.PreferenceItem.BasicListPreference -> { + ListPreferenceWidget( + value = item.value, + title = item.title, + subtitle = item.subtitleProvider(item.value, item.entries), + icon = item.icon, + entries = item.entries, + onValueChange = { scope.launch { item.onValueChanged(it) } }, + ) + } + is Preference.PreferenceItem.MultiSelectListPreference -> { + val values by item.pref.collectAsState() + MultiSelectListPreferenceWidget( + preference = item, + values = values, + onValuesChange = { newValues -> + scope.launch { + if (item.onValueChanged(newValues)) { + item.pref.set(newValues.toMutableSet()) + } + } + }, + ) + } + is Preference.PreferenceItem.TextPreference -> { + TextPreferenceWidget( + title = item.title, + subtitle = item.subtitle, + icon = item.icon, + onPreferenceClick = item.onClick, + ) + } + is Preference.PreferenceItem.EditTextPreference -> { + val values by item.pref.collectAsState() + EditTextPreferenceWidget( + title = item.title, + subtitle = item.subtitle, + icon = item.icon, + value = values, + onConfirm = { + val accepted = item.onValueChanged(it) + if (accepted) item.pref.set(it) + accepted + }, + ) + } + is Preference.PreferenceItem.TrackerPreference -> { + val uName by Injekt.get() + .trackUsername(item.tracker) + .collectAsState() + item.tracker.run { + TrackingPreferenceWidget( + tracker = this, + checked = uName.isNotEmpty(), + onClick = { if (isLogged) item.logout() else item.login() }, + ) + } + } + is Preference.PreferenceItem.InfoPreference -> { + InfoWidget(text = item.title) + } + is Preference.PreferenceItem.CustomPreference -> { + item.content(item) + } + } + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/BasePreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/BasePreferenceWidget.kt new file mode 100644 index 0000000000..62c9bda989 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/BasePreferenceWidget.kt @@ -0,0 +1,125 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.StartOffsetType +import androidx.compose.animation.core.repeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.composed +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.yokai.presentation.component.preference.LocalPreferenceHighlighted +import dev.yokai.presentation.component.preference.LocalPreferenceMinHeight +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun BasePreferenceWidget( + modifier: Modifier = Modifier, + title: String? = null, + subcomponent: @Composable (ColumnScope.() -> Unit)? = null, + icon: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, + widget: @Composable (() -> Unit)? = null, +) { + val highlighted = LocalPreferenceHighlighted.current + val minHeight = LocalPreferenceMinHeight.current + Row( + modifier = modifier + .highlightBackground(highlighted) + .sizeIn(minHeight = minHeight) + .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Box( + modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp), + content = { icon() }, + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = PrefsVerticalPadding), + ) { + if (!title.isNullOrBlank()) { + Text( + modifier = Modifier.padding(horizontal = PrefsHorizontalPadding), + text = title, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = MaterialTheme.typography.titleLarge, + fontSize = TitleFontSize, + ) + } + subcomponent?.invoke(this) + } + if (widget != null) { + Box( + modifier = Modifier.padding(end = PrefsHorizontalPadding), + content = { widget() }, + ) + } + } +} + +internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = composed { + var highlightFlag by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (highlighted) { + highlightFlag = true + delay(3.seconds) + highlightFlag = false + } + } + val highlight by animateColorAsState( + targetValue = if (highlightFlag) { + MaterialTheme.colorScheme.surfaceTint.copy(alpha = .12f) + } else { + Color.Transparent + }, + animationSpec = if (highlightFlag) { + repeatable( + iterations = 5, + animation = tween(durationMillis = 200), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset( + offsetMillis = 600, + offsetType = StartOffsetType.Delay, + ), + ) + } else { + tween(200) + }, + label = "highlight", + ) + this.background(color = highlight) +} + +internal val TrailingWidgetBuffer = 16.dp +internal val PrefsHorizontalPadding = 16.dp +internal val PrefsVerticalPadding = 16.dp +internal val TitleFontSize = 16.sp diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/EditTextPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/EditTextPreferenceWidget.kt new file mode 100644 index 0000000000..a2472d23f2 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/EditTextPreferenceWidget.kt @@ -0,0 +1,97 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.window.DialogProperties +import eu.kanade.tachiyomi.R +import kotlinx.coroutines.launch + + +@Composable +fun EditTextPreferenceWidget( + title: String, + subtitle: String?, + icon: ImageVector?, + value: String, + onConfirm: suspend (String) -> Boolean, +) { + var isDialogShown by remember { mutableStateOf(false) } + + TextPreferenceWidget( + title = title, + subtitle = subtitle?.format(value), + icon = icon, + onPreferenceClick = { isDialogShown = true }, + ) + + if (isDialogShown) { + val scope = rememberCoroutineScope() + val onDismissRequest = { isDialogShown = false } + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(value)) + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = { + OutlinedTextField( + value = textFieldValue, + onValueChange = { textFieldValue = it }, + trailingIcon = { + if (textFieldValue.text.isBlank()) { + Icon(imageVector = Icons.Filled.Error, contentDescription = null) + } else { + IconButton(onClick = { textFieldValue = TextFieldValue("") }) { + Icon(imageVector = Icons.Filled.Cancel, contentDescription = null) + } + } + }, + isError = textFieldValue.text.isBlank(), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmButton = { + TextButton( + enabled = textFieldValue.text != value && textFieldValue.text.isNotBlank(), + onClick = { + scope.launch { + if (onConfirm(textFieldValue.text)) { + onDismissRequest() + } + } + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/InfoWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/InfoWidget.kt new file mode 100644 index 0000000000..f09b27483c --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/InfoWidget.kt @@ -0,0 +1,36 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.yokai.presentation.core.util.secondaryItemAlpha +import dev.yokai.presentation.theme.Size + +@Composable +internal fun InfoWidget(text: String) { + Column( + modifier = Modifier + .padding( + horizontal = PrefsHorizontalPadding, + vertical = Size.medium, + ) + .secondaryItemAlpha(), + verticalArrangement = Arrangement.spacedBy(Size.medium), + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/ListPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/ListPreferenceWidget.kt new file mode 100644 index 0000000000..9524bc4c29 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/ListPreferenceWidget.kt @@ -0,0 +1,110 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + + +@Composable +fun ListPreferenceWidget( + value: T, + title: String, + subtitle: String?, + icon: ImageVector?, + entries: Map, + onValueChange: (T) -> Unit, +) { + var isDialogShown by remember { mutableStateOf(false) } + + TextPreferenceWidget( + title = title, + subtitle = subtitle, + icon = icon, + onPreferenceClick = { isDialogShown = true }, + ) + + if (isDialogShown) { + AlertDialog( + onDismissRequest = { isDialogShown = false }, + title = { Text(text = title) }, + text = { + Box { + val state = rememberLazyListState() + LazyColumn(state = state) { + entries.forEach { current -> + val isSelected = value == current.key + item { + DialogRow( + label = current.value, + isSelected = isSelected, + onSelected = { + onValueChange(current.key!!) + isDialogShown = false + }, + ) + } + } + } + if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) + if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) + } + }, + confirmButton = { + TextButton(onClick = { isDialogShown = false }) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun DialogRow( + label: String, + isSelected: Boolean, + onSelected: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .selectable( + selected = isSelected, + onClick = { if (!isSelected) onSelected() }, + ) + .fillMaxWidth() + .minimumInteractiveComponentSize(), + ) { + RadioButton( + selected = isSelected, + onClick = null, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge.merge(), + modifier = Modifier.padding(start = 24.dp), + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/MultiListPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/MultiListPreferenceWidget.kt new file mode 100644 index 0000000000..d5b23960dd --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/MultiListPreferenceWidget.kt @@ -0,0 +1,82 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.material.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.runtime.toMutableStateList +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.DialogProperties +import androidx.glance.appwidget.lazy.LazyColumn +import dev.yokai.presentation.component.LabeledCheckbox +import dev.yokai.presentation.component.preference.Preference + +@Composable +fun MultiSelectListPreferenceWidget( + preference: Preference.PreferenceItem.MultiSelectListPreference, + values: Set, + onValuesChange: (Set) -> Unit, +) { + var isDialogShown by remember { mutableStateOf(false) } + + TextPreferenceWidget( + title = preference.title, + subtitle = preference.subtitleProvider(values, preference.entries), + icon = preference.icon, + onPreferenceClick = { isDialogShown = true }, + ) + + if (isDialogShown) { + val selected = remember { + preference.entries.keys + .filter { values.contains(it) } + .toMutableStateList() + } + AlertDialog( + onDismissRequest = { isDialogShown = false }, + title = { Text(text = preference.title) }, + text = { + LazyColumn { + preference.entries.forEach { current -> + item { + val isSelected = selected.contains(current.key) + LabeledCheckbox( + label = current.value, + checked = isSelected, + onCheckedChange = { + if (it) { + selected.add(current.key) + } else { + selected.remove(current.key) + } + }, + ) + } + } + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmButton = { + TextButton( + onClick = { + onValuesChange(selected.toMutableSet()) + isDialogShown = false + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { isDialogShown = false }) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/PreferenceGroupHeader.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/PreferenceGroupHeader.kt new file mode 100644 index 0000000000..f78b0b1cde --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/PreferenceGroupHeader.kt @@ -0,0 +1,28 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.unit.dp + +@Composable +fun PreferenceGroupHeader(title: String) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, top = 14.dp), + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(horizontal = PrefsHorizontalPadding), + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/SwitchPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/SwitchPreferenceWidget.kt new file mode 100644 index 0000000000..8a66912363 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/SwitchPreferenceWidget.kt @@ -0,0 +1,32 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector + +@Composable +fun SwitchPreferenceWidget( + modifier: Modifier = Modifier, + title: String, + subtitle: String? = null, + icon: ImageVector? = null, + checked: Boolean = false, + onCheckedChanged: (Boolean) -> Unit, +) { + TextPreferenceWidget( + modifier = modifier, + title = title, + subtitle = subtitle, + icon = icon, + widget = { + Switch( + checked = checked, + onCheckedChange = null, + modifier = Modifier.padding(start = TrailingWidgetBuffer), + ) + }, + onPreferenceClick = { onCheckedChanged(!checked) }, + ) +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/TextPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TextPreferenceWidget.kt new file mode 100644 index 0000000000..cf5662c482 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TextPreferenceWidget.kt @@ -0,0 +1,54 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import dev.yokai.presentation.core.util.secondaryItemAlpha + +@Composable +fun TextPreferenceWidget( + modifier: Modifier = Modifier, + title: String? = null, + subtitle: String? = null, + icon: ImageVector? = null, + iconTint: Color = MaterialTheme.colorScheme.primary, + widget: @Composable (() -> Unit)? = null, + onPreferenceClick: (() -> Unit)? = null, +) { + BasePreferenceWidget( + modifier = modifier, + title = title, + subcomponent = if (!subtitle.isNullOrBlank()) { + { + Text( + text = subtitle, + modifier = Modifier + .padding(horizontal = PrefsHorizontalPadding) + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodySmall, + maxLines = 10, + ) + } + } else { + null + }, + icon = if (icon != null) { + { + Icon( + imageVector = icon, + tint = iconTint, + contentDescription = null, + ) + } + } else { + null + }, + onClick = onPreferenceClick, + widget = widget, + ) +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/TrackingPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TrackingPreferenceWidget.kt new file mode 100644 index 0000000000..76db0502ba --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TrackingPreferenceWidget.kt @@ -0,0 +1,63 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.unit.dp +import dev.yokai.presentation.component.TrackLogoIcon +import dev.yokai.presentation.component.preference.LocalPreferenceHighlighted +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.appwidget.util.stringResource +import eu.kanade.tachiyomi.data.track.TrackService + +@Composable +fun TrackingPreferenceWidget( + modifier: Modifier = Modifier, + tracker: TrackService, + checked: Boolean, + onClick: (() -> Unit)? = null, +) { + val highlighted = LocalPreferenceHighlighted.current + Box(modifier = Modifier.highlightBackground(highlighted)) { + Row( + modifier = modifier + .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) + .fillMaxWidth() + .padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TrackLogoIcon(tracker) + Text( + text = stringResource(id = tracker.nameRes()), + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + maxLines = 1, + style = MaterialTheme.typography.titleLarge, + fontSize = TitleFontSize, + ) + if (checked) { + Icon( + imageVector = Icons.Outlined.Done, + modifier = Modifier + .padding(4.dp) + .size(32.dp), + tint = Color(0xFF4CAF50), + contentDescription = stringResource(R.string.successfully_logged_in), + ) + } + } + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/TriStateListDialog.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TriStateListDialog.kt new file mode 100644 index 0000000000..c7579ed825 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TriStateListDialog.kt @@ -0,0 +1,142 @@ +package dev.yokai.presentation.component.preference.widget + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckBox +import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank +import androidx.compose.material.icons.rounded.DisabledByDefault +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.R + +private enum class State { + CHECKED, INVERSED, UNCHECKED +} + +@Composable +fun TriStateListDialog( + title: String, + message: String? = null, + items: List, + initialChecked: List, + initialInversed: List, + itemLabel: @Composable (T) -> String, + onDismissRequest: () -> Unit, + onValueChanged: (newIncluded: List, newExcluded: List) -> Unit, +) { + val selected = remember { + items + .map { + when (it) { + in initialChecked -> State.CHECKED + in initialInversed -> State.INVERSED + else -> State.UNCHECKED + } + } + .toMutableStateList() + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = { + Column { + if (message != null) { + Text( + text = message, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + + Box { + val listState = rememberLazyListState() + LazyColumn(state = listState) { + itemsIndexed(items = items) { index, item -> + val state = selected[index] + Row( + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .clickable { + selected[index] = when (state) { + State.UNCHECKED -> State.CHECKED + State.CHECKED -> State.INVERSED + State.INVERSED -> State.UNCHECKED + } + } + .defaultMinSize(minHeight = 48.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.padding(end = 20.dp), + imageVector = when (state) { + State.UNCHECKED -> Icons.Rounded.CheckBoxOutlineBlank + State.CHECKED -> Icons.Rounded.CheckBox + State.INVERSED -> Icons.Rounded.DisabledByDefault + }, + tint = if (state == State.UNCHECKED) { + LocalContentColor.current + } else { + MaterialTheme.colorScheme.primary + }, + contentDescription = stringResource( + when (state) { + State.UNCHECKED -> R.string.not_selected + State.CHECKED -> R.string.selected + State.INVERSED -> R.string.disabled + }, + ), + ) + Text(text = itemLabel(item)) + } + } + } + + if (listState.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) + if (listState.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + val included = items.mapIndexedNotNull { index, category -> + if (selected[index] == State.CHECKED) category else null + } + val excluded = items.mapIndexedNotNull { index, category -> + if (selected[index] == State.INVERSED) category else null + } + onValueChanged(included, excluded) + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) +} diff --git a/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt b/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt index 7516806c28..81c98b2887 100644 --- a/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt +++ b/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt @@ -1,7 +1,20 @@ package dev.yokai.presentation.core.util +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import dev.yokai.presentation.theme.SecondaryItemAlpha fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha) + +fun Modifier.clickableNoIndication( + interactionSource: MutableInteractionSource, + onLongClick: (() -> Unit)? = null, + onClick: () -> Unit, +) = this.combinedClickable( + interactionSource = interactionSource, + indication = null, + onLongClick = onLongClick, + onClick = onClick, +) From 64d687989339c0a99bc9e7d5d230d1b735fbf77e Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 09:28:31 +0700 Subject: [PATCH 07/17] feat: The actual unified storage --- app/src/main/AndroidManifest.xml | 7 - .../yokai/domain/storage/StorageManager.kt | 12 ++ .../tachiyomi/data/backup/BackupCreator.kt | 6 +- .../tachiyomi/data/backup/BackupCreatorJob.kt | 8 +- .../tachiyomi/data/download/DownloadCache.kt | 22 +-- .../data/download/DownloadProvider.kt | 16 +- .../data/preference/PreferencesHelper.kt | 26 ---- .../eu/kanade/tachiyomi/source/LocalSource.kt | 140 +++++++++--------- .../tachiyomi/ui/library/LibraryPresenter.kt | 2 +- .../tachiyomi/ui/manga/EditMangaDialog.kt | 2 +- .../ui/manga/MangaDetailsController.kt | 5 +- .../ui/manga/MangaDetailsPresenter.kt | 44 +++--- .../stats/details/StatsDetailsPresenter.kt | 2 +- .../tachiyomi/ui/reader/ReaderViewModel.kt | 39 +++-- .../ui/reader/loader/DownloadPageLoader.kt | 3 +- .../tachiyomi/ui/recents/RecentsController.kt | 2 - ...ontroller.kt => SettingsDataController.kt} | 96 ++++++------ .../ui/setting/SettingsDownloadController.kt | 88 +---------- .../ui/setting/SettingsMainController.kt | 4 +- .../ui/setting/search/SettingsSearchHelper.kt | 4 +- .../tachiyomi/ui/source/BrowseController.kt | 2 - .../source/browse/BrowseSourceController.kt | 2 - .../util/system/UniFileExtensions.kt | 45 ++++++ .../util/view/ControllerExtensions.kt | 55 ------- app/src/main/res/values/strings.xml | 4 + 25 files changed, 256 insertions(+), 380 deletions(-) rename app/src/main/java/eu/kanade/tachiyomi/ui/setting/{SettingsBackupController.kt => SettingsDataController.kt} (82%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08fd78b53a..f2493be00e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,11 +7,6 @@ - - - - @@ -37,12 +32,10 @@ android:name=".App" android:allowBackup="true" android:fullBackupContent="@xml/backup_rules" - android:preserveLegacyExternalStorage="true" android:hardwareAccelerated="true" android:usesCleartextTraffic="true" android:enableOnBackInvokedCallback="true" android:icon="@mipmap/ic_launcher" - android:requestLegacyExternalStorage="true" android:supportsRtl="true" android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" diff --git a/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt b/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt index dc4a9d35e7..39d09bba92 100644 --- a/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt +++ b/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt @@ -40,6 +40,8 @@ class StorageManager( parent.createDirectory(DOWNLOADS_PATH).also { DiskUtil.createNoMediaFile(it, context) } + parent.createDirectory(COVERS_PATH) + parent.createDirectory(PAGES_PATH) } _changes.send(Unit) } @@ -66,9 +68,19 @@ class StorageManager( fun getLocalSourceDirectory(): UniFile? { return baseDir?.createDirectory(LOCAL_SOURCE_PATH) } + + fun getCoversDirectory(): UniFile? { + return baseDir?.createDirectory(COVERS_PATH) + } + + fun getPagesDirectory(): UniFile? { + return baseDir?.createDirectory(PAGES_PATH) + } } private const val BACKUPS_PATH = "autobackup" private const val AUTOMATIC_BACKUPS_PATH = "autobackup" private const val DOWNLOADS_PATH = "downloads" private const val LOCAL_SOURCE_PATH = "local" +private const val COVERS_PATH = "covers" +private const val PAGES_PATH = "pages" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt index eaa4a772d9..73ff7694c3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.backup import android.content.Context import android.net.Uri import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK @@ -54,6 +55,7 @@ import okio.sink import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.io.FileOutputStream class BackupCreator(val context: Context) { @@ -64,6 +66,7 @@ class BackupCreator(val context: Context) { private val sourceManager: SourceManager = Injekt.get() private val preferences: PreferencesHelper = Injekt.get() private val customMangaManager: CustomMangaManager = Injekt.get() + internal val storageManager: StorageManager by injectLazy() /** * Create backup Json file from database @@ -98,8 +101,7 @@ class BackupCreator(val context: Context) { file = ( if (isAutoBackup) { // Get dir of file and create - // TODO: Unified Storage - val dir = UniFile.fromUri(context, uri)!!.createDirectory("automatic")!! + val dir = storageManager.getAutomaticBackupsDirectory()!! // Delete older backups val numberOfBackups = preferences.numberOfBackups().get() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index d74448657d..7fc4f133f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -13,6 +13,8 @@ import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager +import dev.yokai.domain.storage.StoragePreferences import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.util.system.localeContext @@ -20,16 +22,16 @@ import eu.kanade.tachiyomi.util.system.notificationManager import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.util.concurrent.TimeUnit class BackupCreatorJob(private val context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { override fun doWork(): Result { - val preferences = Injekt.get() + val storageManager: StorageManager by injectLazy() val notifier = BackupNotifier(context.localeContext) - val uri = inputData.getString(LOCATION_URI_KEY)?.let { Uri.parse(it) } - ?: preferences.backupsDirectory().get().toUri() + val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() ?: storageManager.getAutomaticBackupsDirectory()?.uri!! val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL) val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index ae8cbe7a96..501243147c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -1,18 +1,16 @@ package eu.kanade.tachiyomi.data.download import android.content.Context -import androidx.core.net.toUri import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.storage.DiskUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.Injekt @@ -35,7 +33,7 @@ class DownloadCache( private val context: Context, private val provider: DownloadProvider, private val sourceManager: SourceManager, - private val preferences: PreferencesHelper = Injekt.get(), + private val storageManager: StorageManager = Injekt.get(), ) { /** @@ -54,21 +52,11 @@ class DownloadCache( val scope = CoroutineScope(Job() + Dispatchers.IO) init { - preferences.downloadsDirectory().changes() - .drop(1) - .onEach { lastRenew = 0L } // invalidate cache + storageManager.changes + .onEach { forceRenewCache() } // invalidate cache .launchIn(scope) } - /** - * Returns the downloads directory from the user's preferences. - */ - private fun getDirectoryFromPreference(): UniFile { - // TODO: Unified Storage - val dir = preferences.downloadsDirectory().get() - return UniFile.fromUri(context, dir.toUri())!! - } - /** * Returns true if the chapter is downloaded. * @@ -138,7 +126,7 @@ class DownloadCache( private fun renew() { val onlineSources = sourceManager.getOnlineSources() - val sourceDirs = getDirectoryFromPreference().listFiles().orEmpty() + val sourceDirs = storageManager.getDownloadsDirectory()!!.listFiles().orEmpty() .associate { it.name to SourceDirectory(it) }.mapNotNullKeys { entry -> onlineSources.find { provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) }?.id } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index 4af95d0306..36d06880de 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download import android.content.Context import androidx.core.net.toUri import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter @@ -31,6 +32,7 @@ class DownloadProvider(private val context: Context) { * Preferences helper. */ private val preferences: PreferencesHelper by injectLazy() + private val storageManager: StorageManager by injectLazy() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -38,15 +40,11 @@ class DownloadProvider(private val context: Context) { * The root directory for downloads. */ // TODO: Unified Storage - private var downloadsDir = preferences.downloadsDirectory().get().let { - val dir = UniFile.fromUri(context, it.toUri()) - DiskUtil.createNoMediaFile(dir, context) - dir!! - } + private var downloadsDir = storageManager.getDownloadsDirectory() init { - preferences.downloadsDirectory().changes().drop(1).onEach { - downloadsDir = UniFile.fromUri(context, it.toUri())!! + storageManager.changes.onEach { + downloadsDir = storageManager.getDownloadsDirectory() }.launchIn(scope) } @@ -58,7 +56,7 @@ class DownloadProvider(private val context: Context) { */ internal fun getMangaDir(manga: Manga, source: Source): UniFile { try { - return downloadsDir.createDirectory(getSourceDirName(source))!! + return downloadsDir!!.createDirectory(getSourceDirName(source))!! .createDirectory(getMangaDirName(manga))!! } catch (e: NullPointerException) { throw Exception(context.getString(R.string.invalid_download_location)) @@ -71,7 +69,7 @@ class DownloadProvider(private val context: Context) { * @param source the source to query. */ fun findSourceDir(source: Source): UniFile? { - return downloadsDir.findFile(getSourceDirName(source), true) + return downloadsDir!!.findFile(getSourceDirName(source), true) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 2b54e9ea75..02e6693495 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -1,20 +1,16 @@ package eu.kanade.tachiyomi.data.preference import android.content.Context -import android.net.Uri -import android.os.Environment import androidx.appcompat.app.AppCompatDelegate import androidx.preference.PreferenceManager import com.google.android.material.color.DynamicColors import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.core.preference.Preference import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.preference.getEnum import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder -import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet @@ -23,14 +19,12 @@ import eu.kanade.tachiyomi.ui.reader.settings.PageLayout import eu.kanade.tachiyomi.ui.reader.settings.ReaderBottomButton import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation -import eu.kanade.tachiyomi.ui.recents.RecentMangaAdapter import eu.kanade.tachiyomi.ui.recents.RecentsPresenter import eu.kanade.tachiyomi.util.system.Themes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import java.io.File import java.text.DateFormat import java.text.SimpleDateFormat import java.util.* @@ -66,22 +60,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto private val prefs = PreferenceManager.getDefaultSharedPreferences(context) - private val defaultDownloadsDir = Uri.fromFile( - File( - Environment.getExternalStorageDirectory().absolutePath + File.separator + - context.getString(R.string.app_normalized_name), - "downloads", - ), - ) - - private val defaultBackupDir = Uri.fromFile( - File( - Environment.getExternalStorageDirectory().absolutePath + File.separator + - context.getString(R.string.app_normalized_name), - "backup", - ), - ) - fun getInt(key: String, default: Int) = preferenceStore.getInt(key, default) fun getStringPref(key: String, default: String = "") = preferenceStore.getString(key, default) fun getStringSet(key: String, default: Set) = preferenceStore.getStringSet(key, default) @@ -215,8 +193,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto fun anilistScoreType() = preferenceStore.getString("anilist_score_type", "POINT_10") - fun backupsDirectory() = preferenceStore.getString(Keys.backupDirectory, defaultBackupDir.toString()) - fun dateFormat(format: String = preferenceStore.getString(Keys.dateFormat, "").get()): DateFormat = when (format) { "" -> DateFormat.getDateInstance(DateFormat.SHORT) else -> SimpleDateFormat(format, Locale.getDefault()) @@ -224,8 +200,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto fun appLanguage() = preferenceStore.getString("app_language", "") - fun downloadsDirectory() = preferenceStore.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString()) - fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) fun folderPerManga() = preferenceStore.getBoolean("create_folder_per_manga", false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 4ab186787d..5b10772e75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -1,7 +1,10 @@ package eu.kanade.tachiyomi.source import android.content.Context +import androidx.core.net.toFile import com.github.junrar.Archive +import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList @@ -10,9 +13,11 @@ import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder -import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.extension +import eu.kanade.tachiyomi.util.system.nameWithoutExtension +import eu.kanade.tachiyomi.util.system.writeText import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -20,7 +25,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import timber.log.Timber import uy.kohesive.injekt.injectLazy -import java.io.File import java.io.FileInputStream import java.io.InputStream import java.util.concurrent.TimeUnit @@ -35,16 +39,16 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) private val langMap = hashMapOf() - fun getMangaLang(manga: SManga, context: Context): String { + fun getMangaLang(manga: SManga): String { return langMap.getOrPut(manga.url) { - val localDetails = getBaseDirectories(context) + val localDetails = getBaseDirectories() .asSequence() - .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } .flatten() .firstOrNull { it.extension.equals("json", ignoreCase = true) } return if (localDetails != null) { - val obj = Json.decodeFromStream(localDetails.inputStream()) + val obj = Json.decodeFromStream(localDetails.openInputStream()) obj.lang ?: "other" } else { "other" @@ -52,49 +56,39 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } } - fun updateCover(context: Context, manga: SManga, input: InputStream): File? { - val dir = getBaseDirectories(context).firstOrNull() + fun updateCover(manga: SManga, input: InputStream): UniFile? { + val dir = getBaseDirectories().firstOrNull() if (dir == null) { input.close() return null } - var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}")) + var cover = getCoverFile(dir.findFile(manga.url)) if (cover == null) { - cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) + cover = dir.findFile(manga.url)?.findFile(COVER_NAME)!! } // It might not exist if using the external SD card - cover.parentFile?.mkdirs() + cover.parentFile?.parentFile?.createDirectory(cover.parentFile?.name) input.use { - cover.outputStream().use { + cover.openOutputStream().use { input.copyTo(it) } } - manga.thumbnail_url = cover.absolutePath + manga.thumbnail_url = cover.filePath return cover } /** * Returns valid cover file inside [parent] directory. */ - private fun getCoverFile(parent: File): File? { - return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { - it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } + private fun getCoverFile(parent: UniFile?): UniFile? { + return parent?.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { + it.isFile && ImageUtil.isImage(it.name.orEmpty()) { it.openInputStream() } } } - private fun getBaseDirectories(context: Context): List { - val library = context.getString(R.string.app_short_name) + File.separator + "local" - val normalized = context.getString(R.string.app_normalized_name) + File.separator + "local" - val j2k = "TachiyomiJ2K" + File.separator + "local" - val tachi = "Tachiyomi" + File.separator + "local" - return DiskUtil.getExternalStorages(context).map { - listOf( - File(it.absolutePath, library), - File(it.absolutePath, normalized), - File(it.absolutePath, j2k), - File(it.absolutePath, tachi), - ) - }.flatten() + private fun getBaseDirectories(): List { + val storageManager: StorageManager by injectLazy() + return listOf(storageManager.getLocalSourceDirectory()!!) } } @@ -114,7 +108,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour query: String, filters: FilterList, ): MangasPage { - val baseDirs = getBaseDirectories(context) + val baseDirs = getBaseDirectories() val time = if (filters === latestFilters) System.currentTimeMillis() - LATEST_THRESHOLD else 0L @@ -123,38 +117,38 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour .mapNotNull { it.listFiles()?.toList() } .flatten() .filter { it.isDirectory } - .filterNot { it.name.startsWith('.') } - .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + .filterNot { it.name.orEmpty().startsWith('.') } + .filter { if (time == 0L) it.name.orEmpty().contains(query, ignoreCase = true) else it.lastModified() >= time } .distinctBy { it.name } val state = ((if (filters.isEmpty()) popularFilters else filters)[0] as OrderBy).state when (state?.index) { 0 -> { mangaDirs = if (state.ascending) { - mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() }) } else { - mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }) + mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty()}) } } 1 -> { mangaDirs = if (state.ascending) { - mangaDirs.sortedBy(File::lastModified) + mangaDirs.sortedBy(UniFile::lastModified) } else { - mangaDirs.sortedByDescending(File::lastModified) + mangaDirs.sortedByDescending(UniFile::lastModified) } } } val mangas = mangaDirs.map { mangaDir -> SManga.create().apply { - title = mangaDir.name - url = mangaDir.name + title = mangaDir.name.orEmpty() + url = mangaDir.name.orEmpty() // Try to find the cover for (dir in baseDirs) { - val cover = getCoverFile(File("${dir.absolutePath}/$url")) + val cover = getCoverFile(mangaDir.findFile(url)) if (cover != null && cover.exists()) { - thumbnail_url = cover.absolutePath + thumbnail_url = cover.filePath break } } @@ -166,7 +160,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour val chapter = chapters.last() val format = getFormat(chapter) if (format is Format.Epub) { - EpubFile(format.file).use { epub -> + EpubFile(format.file.uri.toFile()).use { epub -> epub.fillMangaMetadata(manga) } } @@ -175,7 +169,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour if (thumbnail_url == null) { try { val dest = updateCover(chapter, manga) - thumbnail_url = dest?.absolutePath + thumbnail_url = dest?.filePath } catch (e: Exception) { Timber.e(e) } @@ -191,14 +185,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters) override suspend fun getMangaDetails(manga: SManga): SManga { - val localDetails = getBaseDirectories(context) + val localDetails = getBaseDirectories() .asSequence() - .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } .flatten() .firstOrNull { it.extension.equals("json", ignoreCase = true) } return if (localDetails != null) { - val obj = json.decodeFromStream(localDetails.inputStream()) + val obj = json.decodeFromStream(localDetails.openInputStream()) obj.lang?.let { langMap[manga.url] = it } SManga.create().apply { @@ -215,13 +209,13 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } fun updateMangaInfo(manga: SManga, lang: String?) { - val directory = getBaseDirectories(context).map { File(it, manga.url) }.find { - it.exists() + val directory = getBaseDirectories().map { it.findFile(manga.url) }.find { + it?.exists() == true } ?: return lang?.let { langMap[manga.url] = it } val json = Json { prettyPrint = true } - val existingFileName = directory.listFiles()?.find { it.extension == "json" }?.name - val file = File(directory, existingFileName ?: "info.json") + val existingFileName = directory.listFiles()?.find { it.name.orEmpty().endsWith("json", true) }?.name + val file = directory.findFile(existingFileName ?: "info.json")!! file.writeText(json.encodeToString(manga.toJson(lang))) } @@ -256,24 +250,24 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } override suspend fun getChapterList(manga: SManga): List { - val chapters = getBaseDirectories(context) + val chapters = getBaseDirectories() .asSequence() - .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } .flatten() - .filter { it.isDirectory || isSupportedFile(it.extension) } + .filter { it.isDirectory || isSupportedFile(it.extension.orEmpty()) } .map { chapterFile -> SChapter.create().apply { url = "${manga.url}/${chapterFile.name}" name = if (chapterFile.isDirectory) { - chapterFile.name + chapterFile.name.orEmpty() } else { - chapterFile.nameWithoutExtension + chapterFile.nameWithoutExtension.orEmpty() } date_upload = chapterFile.lastModified() val format = getFormat(chapterFile) if (format is Format.Epub) { - EpubFile(format.file).use { epub -> + EpubFile(format.file.uri.toFile()).use { epub -> epub.fillChapterMetadata(this) } } @@ -297,18 +291,18 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } fun getFormat(chapter: SChapter): Format { - val baseDirs = getBaseDirectories(context) + val baseDirs = getBaseDirectories() for (dir in baseDirs) { - val chapFile = File(dir, chapter.url) - if (!chapFile.exists()) continue + val chapFile = dir.findFile(chapter.url) + if (chapFile == null || !chapFile.exists()) continue return getFormat(chapFile) } throw Exception(context.getString(R.string.chapter_not_found)) } - private fun getFormat(file: File) = with(file) { + private fun getFormat(file: UniFile) = with(file) { when { isDirectory -> Format.Directory(this) extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this) @@ -318,41 +312,41 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } } - private fun updateCover(chapter: SChapter, manga: SManga): File? { + private fun updateCover(chapter: SChapter, manga: SManga): UniFile? { return try { when (val format = getFormat(chapter)) { is Format.Directory -> { val entry = format.file.listFiles() - ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + ?.sortedWith { f1, f2 -> f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(f2.name.orEmpty()) } + ?.find { !it.isDirectory && ImageUtil.isImage(it.name.orEmpty()) { FileInputStream(it.uri.toFile()) } } - entry?.let { updateCover(context, manga, it.inputStream()) } + entry?.let { updateCover(manga, it.openInputStream()) } } is Format.Zip -> { - ZipFile(format.file).use { zip -> + ZipFile(format.file.uri.toFile()).use { zip -> val entry = zip.entries().toList() .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } - entry?.let { updateCover(context, manga, zip.getInputStream(it)) } + entry?.let { updateCover(manga, zip.getInputStream(it)) } } } is Format.Rar -> { - Archive(format.file).use { archive -> + Archive(format.file.uri.toFile()).use { archive -> val entry = archive.fileHeaders .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } - entry?.let { updateCover(context, manga, archive.getInputStream(it)) } + entry?.let { updateCover(manga, archive.getInputStream(it)) } } } is Format.Epub -> { - EpubFile(format.file).use { epub -> + EpubFile(format.file.uri.toFile()).use { epub -> val entry = epub.getImagesFromPages() .firstOrNull() ?.let { epub.getEntry(it) } - entry?.let { updateCover(context, manga, epub.getInputStream(it)) } + entry?.let { updateCover(manga, epub.getInputStream(it)) } } } } @@ -374,10 +368,10 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour ) sealed class Format { - data class Directory(val file: File) : Format() - data class Zip(val file: File) : Format() - data class Rar(val file: File) : Format() - data class Epub(val file: File) : Format() + data class Directory(val file: UniFile) : Format() + data class Zip(val file: UniFile) : Format() + data class Rar(val file: UniFile) : Format() + data class Epub(val file: UniFile) : Format() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index e7a331d7f8..d60988de82 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -578,7 +578,7 @@ class LibraryPresenter( private fun getLanguage(manga: Manga): String? { return if (manga.isLocal()) { - LocalSource.getMangaLang(manga, context) + LocalSource.getMangaLang(manga) } else { sourceManager.get(manga.source)?.lang } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt index 7c70e0cf93..497c5438fe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/EditMangaDialog.kt @@ -124,7 +124,7 @@ class EditMangaDialog : DialogController { }, ) binding.mangaLang.setSelection( - languages.indexOf(LocalSource.getMangaLang(manga, binding.root.context)) + languages.indexOf(LocalSource.getMangaLang(manga)) .takeIf { it > -1 } ?: 0, ) } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 2968e09510..ec79622821 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -32,6 +32,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.ColorUtils +import androidx.core.net.toFile import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isVisible @@ -118,7 +119,6 @@ import eu.kanade.tachiyomi.util.view.findChild import eu.kanade.tachiyomi.util.view.getText import eu.kanade.tachiyomi.util.view.isControllerVisible import eu.kanade.tachiyomi.util.view.previousController -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setStyle @@ -249,7 +249,6 @@ class MangaDetailsController : if (presenter.preferences.themeMangaDetails()) { setItemColors() } - requestFilePermissionsSafe(301, presenter.preferences, presenter.manga.isLocal()) } private fun setAccentColorValue(colorToUse: Int? = null) { @@ -1193,7 +1192,7 @@ class MangaDetailsController : fun shareCover() { val cover = presenter.shareCover() if (cover != null) { - val stream = cover.getUriCompat(activity!!) + val stream = cover.toFile().getUriCompat(activity!!) val intent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, stream) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index dcaa7293e2..b4789ada10 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -3,12 +3,14 @@ package eu.kanade.tachiyomi.ui.manga import android.app.Application import android.graphics.Bitmap import android.net.Uri -import android.os.Environment +import androidx.core.net.toFile import coil3.imageLoader import coil3.memory.MemoryCache import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.SuccessResult +import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper @@ -81,6 +83,7 @@ class MangaDetailsPresenter( val db: DatabaseHelper = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), chapterFilter: ChapterFilter = Injekt.get(), + internal val storageManager: StorageManager = Injekt.get(), ) : BaseCoroutinePresenter(), DownloadQueue.DownloadListener { private val customMangaManager: CustomMangaManager by injectLazy() @@ -719,14 +722,13 @@ class MangaDetailsPresenter( fun shareManga() { val context = Injekt.get() - val destDir = File(context.cacheDir, "shared_image") + val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!! presenterScope.launchIO { - destDir.deleteRecursively() try { - val file = saveCover(destDir) + val uri = saveCover(destDir) withUIContext { - view?.shareManga(file) + view?.shareManga(uri.toFile()) } } catch (_: java.lang.Exception) { } @@ -831,7 +833,7 @@ class MangaDetailsPresenter( val inputStream = downloadManager.context.contentResolver.openInputStream(uri) ?: return false if (manga.isLocal()) { - LocalSource.updateCover(downloadManager.context, manga, inputStream) + LocalSource.updateCover(manga, inputStream) view?.setPaletteColor() return true } @@ -844,9 +846,9 @@ class MangaDetailsPresenter( return false } - fun shareCover(): File? { + fun shareCover(): Uri? { return try { - val destDir = File(coverCache.context.cacheDir, "shared_image") + val destDir = UniFile.fromFile(coverCache.context.cacheDir)!!.createDirectory("shared_image")!! val file = saveCover(destDir) file } catch (e: Exception) { @@ -857,43 +859,33 @@ class MangaDetailsPresenter( fun saveCover(): Boolean { return try { val directory = if (preferences.folderPerManga().get()) { - val baseDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + preferences.context.getString(R.string.app_normalized_name) - - File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title)) + storageManager.getCoversDirectory()!!.createDirectory(DiskUtil.buildValidFilename(manga.title))!! } else { - File( - Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + preferences.context.getString(R.string.app_normalized_name), - ) + storageManager.getCoversDirectory()!! } - val file = saveCover(directory) - DiskUtil.scanMedia(preferences.context, file) + val uri = saveCover(directory) + DiskUtil.scanMedia(preferences.context, uri.toFile()) true } catch (e: Exception) { false } } - private fun saveCover(directory: File): File { + private fun saveCover(directory: UniFile): Uri { val cover = coverCache.getCustomCoverFile(manga).takeIf { it.exists() } ?: coverCache.getCoverFile(manga) val type = ImageUtil.findImageType(cover.inputStream()) ?: throw Exception("Not an image") - directory.mkdirs() - // Build destination file. val filename = DiskUtil.buildValidFilename("${manga.title}.${type.extension}") - val destFile = File(directory, filename) + val destFile = directory.createFile(filename)!! cover.inputStream().use { input -> - destFile.outputStream().use { output -> + destFile.openOutputStream().use { output -> input.copyTo(output) } } - return destFile + return destFile.uri } fun isTracked(): Boolean = diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/stats/details/StatsDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/stats/details/StatsDetailsPresenter.kt index 890ca3a51f..170f7798cc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/stats/details/StatsDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/stats/details/StatsDetailsPresenter.kt @@ -483,7 +483,7 @@ class StatsDetailsPresenter( */ private fun LibraryManga.getLanguage(): String { val code = if (isLocal()) { - LocalSource.getMangaLang(this, context) + LocalSource.getMangaLang(this) } else { sourceManager.get(source)?.lang } ?: return context.getString(R.string.unknown) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 4f75464b7e..2de437585e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -5,9 +5,12 @@ import android.graphics.BitmapFactory import android.net.Uri import android.os.Environment import androidx.annotation.ColorInt +import androidx.core.net.toFile import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.hippo.unifile.UniFile +import dev.yokai.domain.storage.StorageManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper @@ -86,6 +89,7 @@ class ReaderViewModel( private val coverCache: CoverCache = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(), + private val storageManager: StorageManager = Injekt.get(), ) : ViewModel() { private val mutableState = MutableStateFlow(State()) @@ -743,13 +747,11 @@ class ReaderViewModel( /** * Saves the image of this [page] in the given [directory] and returns the file location. */ - private fun saveImage(page: ReaderPage, directory: File, manga: Manga): File { + private fun saveImage(page: ReaderPage, directory: UniFile, manga: Manga): Uri { val stream = page.stream!! val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image") val context = Injekt.get() - directory.mkdirs() - val chapter = page.chapter.chapter // Build destination file. @@ -757,13 +759,13 @@ class ReaderViewModel( "${manga.title} - ${chapter.preferredChapterName(context, manga, preferences)}".take(225), ) + " - ${page.number}.${type.extension}" - val destFile = File(directory, filename) + val destFile = directory.createFile(filename)!! stream().use { input -> - destFile.outputStream().use { output -> + destFile.openOutputStream().use { output -> input.copyTo(output) } } - return destFile + return destFile.uri } /** @@ -814,22 +816,20 @@ class ReaderViewModel( notifier.onClear() // Pictures directory. - val baseDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + context.getString(R.string.app_normalized_name) + val baseDir = storageManager.getPagesDirectory()!! val destDir = if (preferences.folderPerManga().get()) { - File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title)) + baseDir.createDirectory(DiskUtil.buildValidFilename(manga.title))!! } else { - File(baseDir) + baseDir } // Copy file in background. viewModelScope.launchNonCancellable { try { - val file = saveImage(page, destDir, manga) - DiskUtil.scanMedia(context, file) - notifier.onComplete(file) - eventChannel.send(Event.SavedImage(SaveImageResult.Success(file))) + val uri = saveImage(page, destDir, manga) + DiskUtil.scanMedia(context, uri.toFile()) + notifier.onComplete(uri.toFile()) + eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri.toFile()))) } catch (e: Exception) { notifier.onError(e.message) eventChannel.send(Event.SavedImage(SaveImageResult.Error(e))) @@ -880,12 +880,11 @@ class ReaderViewModel( val manga = manga ?: return val context = Injekt.get() - val destDir = File(context.cacheDir, "shared_image") + val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!! viewModelScope.launchNonCancellable { - destDir.deleteRecursively() // Keep only the last shared file - val file = saveImage(page, destDir, manga) - eventChannel.send(Event.ShareImage(file, page)) + val uri = saveImage(page, destDir, manga) + eventChannel.send(Event.ShareImage(uri.toFile(), page)) } } @@ -919,7 +918,7 @@ class ReaderViewModel( if (manga.isLocal()) { val context = Injekt.get() coverCache.deleteFromCache(manga) - LocalSource.updateCover(context, manga, stream()) + LocalSource.updateCover(manga, stream()) R.string.cover_updated SetAsCoverResult.Success } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index c7c06bb780..692356a069 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import eu.kanade.tachiyomi.util.system.toTempFile import uy.kohesive.injekt.injectLazy import java.io.File @@ -48,7 +49,7 @@ class DownloadPageLoader( } private suspend fun getPagesFromArchive(chapterPath: UniFile): List { - val loader = ZipPageLoader(File(chapterPath.filePath!!)).also { zipPageLoader = it } + val loader = ZipPageLoader(chapterPath.toTempFile(context)).also { zipPageLoader = it } return loader.getPages() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt index 0f2e4e352b..841531ca59 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt @@ -80,7 +80,6 @@ import eu.kanade.tachiyomi.util.view.isExpanded import eu.kanade.tachiyomi.util.view.isHidden import eu.kanade.tachiyomi.util.view.moveRecyclerViewUp import eu.kanade.tachiyomi.util.view.onAnimationsFinished -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setStyle @@ -421,7 +420,6 @@ class RecentsController(bundle: Bundle? = null) : binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.expand() } setPadding(binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable == true) - requestFilePermissionsSafe(301, presenter.preferences) binding.downloadBottomSheet.root.sheetBehavior?.isGestureInsetBottomIgnored = true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt index e48774d0d0..bdb2c73251 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt @@ -4,16 +4,16 @@ import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri -import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri 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.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreatorJob @@ -26,23 +26,43 @@ import eu.kanade.tachiyomi.util.system.disableItems 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.view.requestFilePermissionsSafe import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import uy.kohesive.injekt.injectLazy -class SettingsBackupController : SettingsController() { +class SettingsDataController : SettingsController() { /** * Flags containing information of what to backup. */ private var backupFlags = 0 - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - requestFilePermissionsSafe(500, preferences) - } + internal val storagePreferences: StoragePreferences by injectLazy() + internal val storageManager: StorageManager by injectLazy() override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { - titleRes = R.string.backup_and_restore + titleRes = R.string.data_and_storage + + preference { + 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) + } + } + .launchIn(viewScope) + } preference { key = "pref_create_backup" @@ -75,7 +95,7 @@ class SettingsBackupController : SettingsController() { (activity as? MainActivity)?.getExtensionUpdates(true) val intent = Intent(Intent.ACTION_GET_CONTENT) intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "*/*" + intent.setDataAndType(storageManager.getBackupsDirectory()!!.uri, "*/*") val title = resources?.getString(R.string.select_backup_file) val chooser = Intent.createChooser(intent, title) startActivityForResult(chooser, CODE_BACKUP_RESTORE) @@ -107,29 +127,6 @@ class SettingsBackupController : SettingsController() { true } } - preference { - bindTo(preferences.backupsDirectory()) - titleRes = R.string.backup_location - - onClick { - try { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - startActivityForResult(intent, CODE_BACKUP_DIR) - } catch (e: ActivityNotFoundException) { - activity?.toast(R.string.file_picker_error) - } - } - - visibleIf(preferences.backupInterval()) { it > 0 } - - preferences.backupsDirectory().changes() - .onEach { path -> - val dir = UniFile.fromUri(context, path.toUri())!! - val filePath = dir.filePath - summary = if (filePath != null) "$filePath/automatic" else "Invalid directory: ${dir.uri}" - } - .launchIn(viewScope) - } intListPreference(activity) { bindTo(preferences.numberOfBackups()) titleRes = R.string.max_auto_backups @@ -165,22 +162,18 @@ class SettingsBackupController : SettingsController() { } when (requestCode) { - CODE_BACKUP_DIR -> { + 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.contentResolver.takePersistableUriPermission(uri, flags) - preferences.backupsDirectory().set(uri.toString()) + val file = UniFile.fromUri(activity, uri)!! + storagePreferences.baseStorageDirectory().set(file.uri.toString()) } CODE_BACKUP_CREATE -> { - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - activity.contentResolver.takePersistableUriPermission(uri, flags) - activity.toast(R.string.creating_backup) - BackupCreatorJob.startNow(activity, uri, backupFlags) + doBackup(backupFlags, uri) } CODE_BACKUP_RESTORE -> { @@ -191,8 +184,25 @@ class SettingsBackupController : SettingsController() { } } - fun createBackup(flags: Int) { + private fun doBackup(flags: Int, uri: Uri) { + val activity = activity ?: return + + val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + activity.contentResolver.takePersistableUriPermission(uri, intentFlags) + activity.toast(R.string.creating_backup) + BackupCreatorJob.startNow(activity, uri, flags) + } + + fun createBackup(flags: Int, picker: Boolean = false) { backupFlags = flags + + if (!picker) { + doBackup(backupFlags, storageManager.getBackupsDirectory()!!.uri) + return + } + try { // Use Android's built-in file creator val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) @@ -299,7 +309,7 @@ class SettingsBackupController : SettingsController() { } } -private const val CODE_BACKUP_DIR = 503 +private const val CODE_DATA_DIR = 104 private const val CODE_BACKUP_CREATE = 504 private const val CODE_BACKUP_RESTORE = 505 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt index 6d2cb4ca08..f2e61f63f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt @@ -1,24 +1,14 @@ package eu.kanade.tachiyomi.ui.setting -import android.app.Activity import android.content.Intent -import android.net.Uri -import android.os.Environment -import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.preference.PreferenceScreen -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.hippo.unifile.UniFile +import com.bluelinelabs.conductor.Controller import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.changesIn -import eu.kanade.tachiyomi.util.system.withOriginalWidth -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get +import eu.kanade.tachiyomi.util.view.withFadeTransaction import uy.kohesive.injekt.injectLazy -import java.io.File import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys class SettingsDownloadController : SettingsController() { @@ -31,14 +21,9 @@ class SettingsDownloadController : SettingsController() { preference { key = Keys.downloadsDirectory titleRes = R.string.download_location - onClick { - DownloadDirectoriesDialog(this@SettingsDownloadController).show() - } + onClick { navigateTo(SettingsDataController()) } - preferences.downloadsDirectory().changesIn(viewScope) { path -> - val dir = UniFile.fromUri(context, path.toUri())!! - summary = dir.filePath ?: path - } + summary = "Moved to Data and Storage!" } switchPreference { key = Keys.downloadOnlyOverWifi @@ -150,70 +135,9 @@ class SettingsDownloadController : SettingsController() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - DOWNLOAD_DIR -> if (data != null && resultCode == Activity.RESULT_OK) { - val context = applicationContext ?: return - val uri = data.data - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - if (uri != null) { - @Suppress("NewApi") - context.contentResolver.takePersistableUriPermission(uri, flags) - } - - val file = UniFile.fromUri(context, uri)!! - preferences.downloadsDirectory().set(file.uri.toString()) - } - } } - fun predefinedDirectorySelected(selectedDir: String) { - val path = Uri.fromFile(File(selectedDir)) - preferences.downloadsDirectory().set(path.toString()) - } - - fun customDirectorySelected() { - startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE), DOWNLOAD_DIR) - } - - class DownloadDirectoriesDialog(val controller: SettingsDownloadController) : - MaterialAlertDialogBuilder(controller.activity!!.withOriginalWidth()) { - - private val preferences: PreferencesHelper = Injekt.get() - - val activity = controller.activity!! - - init { - val currentDir = preferences.downloadsDirectory().get() - val externalDirs = - getExternalDirs() + File(activity.getString(R.string.custom_location)) - val selectedIndex = externalDirs.map(File::toString).indexOfFirst { it in currentDir } - val items = externalDirs.map { it.path } - - setTitle(R.string.download_location) - setSingleChoiceItems(items.toTypedArray(), selectedIndex) { dialog, position -> - if (position == externalDirs.lastIndex) { - controller.customDirectorySelected() - } else { - controller.predefinedDirectorySelected(items[position]) - } - dialog.dismiss() - } - setNegativeButton(android.R.string.cancel, null) - } - - private fun getExternalDirs(): List { - val defaultDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + activity.resources?.getString(R.string.app_normalized_name) + - File.separator + "downloads" - - return mutableListOf(File(defaultDir)) + - ContextCompat.getExternalFilesDirs(activity, "").filterNotNull() - } - } - - private companion object { - const val DOWNLOAD_DIR = 104 + private fun navigateTo(controller: Controller) { + router.pushController(controller.withFadeTransaction()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index d5a8c6b29c..705e5b0b65 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -75,8 +75,8 @@ class SettingsMainController : SettingsController(), FloatingSearchInterface { preference { iconRes = R.drawable.ic_backup_restore_24dp iconTint = tintColor - titleRes = R.string.backup_and_restore - onClick { navigateTo(SettingsBackupController()) } + titleRes = R.string.data_and_storage + onClick { navigateTo(SettingsDataController()) } } preference { iconRes = R.drawable.ic_security_24dp diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt index 5e368a60d5..b7a52630fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt @@ -9,7 +9,7 @@ import androidx.preference.PreferenceGroup import androidx.preference.PreferenceManager import eu.kanade.tachiyomi.ui.setting.SettingsAdvancedController import eu.kanade.tachiyomi.ui.setting.SettingsAppearanceController -import eu.kanade.tachiyomi.ui.setting.SettingsBackupController +import eu.kanade.tachiyomi.ui.setting.SettingsDataController import eu.kanade.tachiyomi.ui.setting.SettingsBrowseController import eu.kanade.tachiyomi.ui.setting.SettingsController import eu.kanade.tachiyomi.ui.setting.SettingsDownloadController @@ -31,7 +31,7 @@ object SettingsSearchHelper { */ private val settingControllersList: List> = listOf( SettingsAdvancedController::class, - SettingsBackupController::class, + SettingsDataController::class, SettingsBrowseController::class, SettingsDownloadController::class, SettingsGeneralController::class, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt index a018b53f0e..7b3d28ee05 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt @@ -60,7 +60,6 @@ import eu.kanade.tachiyomi.util.view.isCollapsed import eu.kanade.tachiyomi.util.view.isCompose import eu.kanade.tachiyomi.util.view.isControllerVisible import eu.kanade.tachiyomi.util.view.onAnimationsFinished -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.snack @@ -182,7 +181,6 @@ class BrowseController : updateTitleAndMenu() } - requestFilePermissionsSafe(301, preferences) binding.bottomSheet.root.onCreate(this) basePreferences.extensionInstaller().changes() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt index 5c376147ed..f242ab704a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt @@ -48,7 +48,6 @@ import eu.kanade.tachiyomi.util.view.applyBottomAnimatedInsets import eu.kanade.tachiyomi.util.view.fullAppBarHeight import eu.kanade.tachiyomi.util.view.inflate import eu.kanade.tachiyomi.util.view.isControllerVisible -import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.snack @@ -182,7 +181,6 @@ open class BrowseSourceController(bundle: Bundle) : } else { binding.progress.isVisible = true } - requestFilePermissionsSafe(301, preferences, presenter.source is LocalSource) } override fun onDestroyView(view: View) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt new file mode 100644 index 0000000000..6eba4738ea --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.util.system + +import android.content.Context +import android.os.Build +import android.os.FileUtils +import com.hippo.unifile.UniFile +import java.io.BufferedOutputStream +import java.io.File + +val UniFile.nameWithoutExtension: String? + get() = name?.substringBeforeLast('.') + +val UniFile.extension: String? + get() = name?.replace(nameWithoutExtension.orEmpty(), "") + +fun UniFile.toTempFile(context: Context): File { + val inputStream = context.contentResolver.openInputStream(uri)!! + val tempFile = + File.createTempFile( + nameWithoutExtension.orEmpty(), + null, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + FileUtils.copy(inputStream, tempFile.outputStream()) + } else { + BufferedOutputStream(tempFile.outputStream()).use { tmpOut -> + inputStream.use { input -> + val buffer = ByteArray(8192) + var count: Int + while (input.read(buffer).also { count = it } > 0) { + tmpOut.write(buffer, 0, count) + } + } + } + } + + return tempFile +} + +fun UniFile.writeText(string: String) { + this.openOutputStream().use { + it.write(string.toByteArray()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt index e1b1415753..31dac56831 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.util.view -import android.Manifest import android.animation.Animator import android.animation.ValueAnimator import android.app.ActivityManager @@ -8,12 +7,9 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.content.res.ColorStateList import android.graphics.Color import android.os.Build -import android.os.Environment -import android.provider.Settings import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -26,7 +22,6 @@ import androidx.annotation.CallSuper import androidx.annotation.MainThread import androidx.appcompat.widget.SearchView import androidx.cardview.widget.CardView -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.graphics.ColorUtils import androidx.core.math.MathUtils @@ -53,7 +48,6 @@ import com.bluelinelabs.conductor.Router import com.bluelinelabs.conductor.RouterTransaction import com.google.android.material.snackbar.Snackbar import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface @@ -73,7 +67,6 @@ import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.ignoredSystemInsets -import eu.kanade.tachiyomi.util.system.materialAlertDialog import eu.kanade.tachiyomi.util.system.rootWindowInsetsCompat import eu.kanade.tachiyomi.util.system.toInt import eu.kanade.tachiyomi.util.system.toast @@ -780,54 +773,6 @@ fun Controller.setAppBarBG(value: Float, includeTabView: Boolean = false) { } } -fun Controller.requestFilePermissionsSafe( - requestCode: Int, - preferences: PreferencesHelper, - showA11PermissionAnyway: Boolean = false, -) { - val activity = activity ?: return - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - val permissions = mutableListOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - permissions.forEach { permission -> - if (ContextCompat.checkSelfPermission( - activity, - permission, - ) != PackageManager.PERMISSION_GRANTED - ) { - requestPermissions(arrayOf(permission), requestCode) - } - } - } - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - !Environment.isExternalStorageManager() && - (!preferences.hasDeniedA11FilePermission().get() || showA11PermissionAnyway) - ) { - preferences.hasDeniedA11FilePermission().set(true) - activity.materialAlertDialog() - .setTitle(R.string.all_files_permission_required) - .setMessage(R.string.external_storage_permission_notice) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> - val intent = Intent( - Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - "package:${activity.packageName}".toUri(), - ) - try { - activity.startActivity(intent) - } catch (_: Exception) { - val intent2 = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) - activity.startActivity(intent2) - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } else if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()) && !preferences.backupInterval().isSet()) { - preferences.backupInterval().set(24) - BackupCreatorJob.setupTask(activity, 24) - } -} - fun Controller.withFadeTransaction(): RouterTransaction { return RouterTransaction.with(this) .pushChangeHandler(fadeTransactionHandler()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f8d618482..d6269888b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -793,6 +793,10 @@ Series shortcuts opens new chapters When there\'s no new chapters, the series\' details will open instead + + Data and storage + Storage location + Backup Backup and restore From de3323ec762386c75950f66ee3f578c6dd9976bd Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 09:43:34 +0700 Subject: [PATCH 08/17] fix: UniFile vs File moment I'll refactor these eventually to use UniFile, but that's out of the scope of this PR --- .../kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 12f053699b..064ab37495 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.reader.loader import android.content.Context +import androidx.core.net.toFile import com.github.junrar.exception.UnsupportedRarV5Exception import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga @@ -79,14 +80,14 @@ class ChapterLoader( source is HttpSource -> HttpPageLoader(chapter, source) source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { - is LocalSource.Format.Directory -> DirectoryPageLoader(format.file) - is LocalSource.Format.Zip -> ZipPageLoader(format.file) + is LocalSource.Format.Directory -> DirectoryPageLoader(format.file.uri.toFile()) + is LocalSource.Format.Zip -> ZipPageLoader(format.file.uri.toFile()) is LocalSource.Format.Rar -> try { - RarPageLoader(format.file) + RarPageLoader(format.file.uri.toFile()) } catch (e: UnsupportedRarV5Exception) { error(context.getString(R.string.loader_rar5_error)) } - is LocalSource.Format.Epub -> EpubPageLoader(format.file) + is LocalSource.Format.Epub -> EpubPageLoader(format.file.uri.toFile()) } } else -> error(context.getString(R.string.source_not_installed)) From 1ca453b966c6d31260311b63e763f6556714f7b8 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 09:52:57 +0700 Subject: [PATCH 09/17] fix: Wrong name for backup dir --- app/src/main/java/dev/yokai/domain/storage/StorageManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt b/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt index 39d09bba92..c5ab262c68 100644 --- a/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt +++ b/app/src/main/java/dev/yokai/domain/storage/StorageManager.kt @@ -78,7 +78,7 @@ class StorageManager( } } -private const val BACKUPS_PATH = "autobackup" +private const val BACKUPS_PATH = "backup" private const val AUTOMATIC_BACKUPS_PATH = "autobackup" private const val DOWNLOADS_PATH = "downloads" private const val LOCAL_SOURCE_PATH = "local" From ad7a45b933e58bb6ef1947d492170ac642619181 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 10:07:00 +0700 Subject: [PATCH 10/17] fix: Invalid uri formatting --- .../tachiyomi/ui/manga/MangaDetailsPresenter.kt | 14 ++++++++------ .../eu/kanade/tachiyomi/util/storage/DiskUtil.kt | 9 ++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index b4789ada10..ebcd43b15a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -11,6 +11,7 @@ import coil3.request.ImageRequest import coil3.request.SuccessResult import com.hippo.unifile.UniFile import dev.yokai.domain.storage.StorageManager +import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper @@ -728,7 +729,7 @@ class MangaDetailsPresenter( try { val uri = saveCover(destDir) withUIContext { - view?.shareManga(uri.toFile()) + view?.shareManga(uri.uri.toFile()) } } catch (_: java.lang.Exception) { } @@ -850,7 +851,7 @@ class MangaDetailsPresenter( return try { val destDir = UniFile.fromFile(coverCache.context.cacheDir)!!.createDirectory("shared_image")!! val file = saveCover(destDir) - file + file.uri } catch (e: Exception) { null } @@ -863,15 +864,16 @@ class MangaDetailsPresenter( } else { storageManager.getCoversDirectory()!! } - val uri = saveCover(directory) - DiskUtil.scanMedia(preferences.context, uri.toFile()) + val file = saveCover(directory) + DiskUtil.scanMedia(preferences.context, file) true } catch (e: Exception) { + if (BuildConfig.DEBUG) e.printStackTrace() false } } - private fun saveCover(directory: UniFile): Uri { + private fun saveCover(directory: UniFile): UniFile { val cover = coverCache.getCustomCoverFile(manga).takeIf { it.exists() } ?: coverCache.getCoverFile(manga) val type = ImageUtil.findImageType(cover.inputStream()) ?: throw Exception("Not an image") @@ -885,7 +887,7 @@ class MangaDetailsPresenter( input.copyTo(output) } } - return destFile.uri + return destFile } fun isTracked(): Boolean = diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index 138f388ac0..b921e5ed26 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt @@ -68,11 +68,18 @@ object DiskUtil { val nomedia = dir.findFile(".nomedia") if (nomedia == null) { dir.createFile(".nomedia") - context?.let { scanMedia(it, dir.filePath) } + context?.let { scanMedia(it, dir) } } } } + /** + * Scans the given file so that it can be shown in gallery apps, for example. + */ + fun scanMedia(context: Context, file: UniFile) { + scanMedia(context, file.filePath) + } + /** * Scans the given file so that it can be shown in gallery apps, for example. */ From 1b5fb6cd57d19350749950212f8bebd2e1b25508 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 10:07:32 +0700 Subject: [PATCH 11/17] fix: Persist is for open document --- .../tachiyomi/ui/setting/SettingsDataController.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt index bdb2c73251..1f21a9639c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt @@ -173,7 +173,7 @@ class SettingsDataController : SettingsController() { } CODE_BACKUP_CREATE -> { - doBackup(backupFlags, uri) + doBackup(backupFlags, uri, true) } CODE_BACKUP_RESTORE -> { @@ -184,13 +184,15 @@ class SettingsDataController : SettingsController() { } } - private fun doBackup(flags: Int, uri: Uri) { + private fun doBackup(flags: Int, uri: Uri, requestPersist: Boolean = false) { val activity = activity ?: return - val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + if (requestPersist) { + val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION - activity.contentResolver.takePersistableUriPermission(uri, intentFlags) + activity.contentResolver.takePersistableUriPermission(uri, intentFlags) + } activity.toast(R.string.creating_backup) BackupCreatorJob.startNow(activity, uri, flags) } From 1f0b122478d45e947abb6522f3e0a27bef772ef2 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 10:11:42 +0700 Subject: [PATCH 12/17] fix: Another invalid uri --- .../data/notification/NotificationHandler.kt | 6 ++- .../tachiyomi/ui/reader/ReaderActivity.kt | 5 ++- .../tachiyomi/ui/reader/ReaderViewModel.kt | 41 ++++++++----------- .../tachiyomi/ui/reader/SaveImageNotifier.kt | 11 ++--- .../kanade/tachiyomi/util/storage/DiskUtil.kt | 10 ++++- 5 files changed, 40 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt index ceb2fd2111..7cacddb8d5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt @@ -4,6 +4,8 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri +import androidx.core.net.toFile +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.util.storage.getUriCompat import java.io.File @@ -30,9 +32,9 @@ object NotificationHandler { * @param context context of application * @param file file containing image */ - internal fun openImagePendingActivity(context: Context, file: File): PendingIntent { + internal fun openImagePendingActivity(context: Context, file: UniFile): PendingIntent { val intent = Intent(Intent.ACTION_VIEW).apply { - val uri = file.getUriCompat(context) + val uri = file.uri.toFile().getUriCompat(context) setDataAndType(uri, "image/*") flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index d0582cee44..e3f6fbc9b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -42,6 +42,7 @@ import androidx.activity.viewModels import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils +import androidx.core.net.toFile import androidx.core.net.toUri import androidx.core.text.buildSpannedString import androidx.core.text.inSpans @@ -1670,7 +1671,7 @@ class ReaderActivity : BaseActivity() { * Called from the view model when a page is ready to be shared. It shows Android's default * sharing tool. */ - private fun onShareImageResult(file: File, page: ReaderPage, secondPage: ReaderPage? = null) { + private fun onShareImageResult(file: UniFile, page: ReaderPage, secondPage: ReaderPage? = null) { val manga = viewModel.manga ?: return val chapter = page.chapter.chapter @@ -1689,7 +1690,7 @@ class ReaderActivity : BaseActivity() { } }, $pageNumber" - val stream = file.getUriCompat(this) + val stream = file.uri.toFile().getUriCompat(this) val intent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, text) putExtra(Intent.EXTRA_STREAM, stream) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 2de437585e..7a8b717b0d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -289,7 +289,6 @@ class ReaderViewModel( return delegatedSource.pageNumber(url)?.minus(1) } - @Suppress("DEPRECATION") suspend fun loadChapterURL(url: Uri) { val host = url.host ?: return val context = Injekt.get() @@ -747,7 +746,7 @@ class ReaderViewModel( /** * Saves the image of this [page] in the given [directory] and returns the file location. */ - private fun saveImage(page: ReaderPage, directory: UniFile, manga: Manga): Uri { + private fun saveImage(page: ReaderPage, directory: UniFile, manga: Manga): UniFile { val stream = page.stream!! val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image") val context = Injekt.get() @@ -765,13 +764,13 @@ class ReaderViewModel( input.copyTo(output) } } - return destFile.uri + return destFile } /** * Saves the image of [page1] and [page2] in the given [directory] and returns the file location. */ - private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: File, manga: Manga): File { + private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: UniFile, manga: Manga): UniFile { val stream1 = page1.stream!! ImageUtil.findImageType(stream1) ?: throw Exception("Not an image") val stream2 = page2.stream!! @@ -783,7 +782,6 @@ class ReaderViewModel( val imageBitmap2 = BitmapFactory.decodeByteArray(imageBytes2, 0, imageBytes2.size) val stream = ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg) - directory.mkdirs() val chapter = page1.chapter.chapter val context = Injekt.get() @@ -793,9 +791,9 @@ class ReaderViewModel( "${manga.title} - ${chapter.preferredChapterName(context, manga, preferences)}".take(225), ) + " - ${page1.number}-${page2.number}.jpg" - val destFile = File(directory, filename) + val destFile = directory.findFile(filename)!! stream.use { input -> - destFile.outputStream().use { output -> + destFile.openOutputStream().use { output -> input.copyTo(output) } } @@ -826,10 +824,10 @@ class ReaderViewModel( // Copy file in background. viewModelScope.launchNonCancellable { try { - val uri = saveImage(page, destDir, manga) - DiskUtil.scanMedia(context, uri.toFile()) - notifier.onComplete(uri.toFile()) - eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri.toFile()))) + val file = saveImage(page, destDir, manga) + DiskUtil.scanMedia(context, file) + notifier.onComplete(file) + eventChannel.send(Event.SavedImage(SaveImageResult.Success(file))) } catch (e: Exception) { notifier.onError(e.message) eventChannel.send(Event.SavedImage(SaveImageResult.Error(e))) @@ -848,14 +846,12 @@ class ReaderViewModel( notifier.onClear() // Pictures directory. - val baseDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + context.getString(R.string.app_normalized_name) + val baseDir = storageManager.getPagesDirectory()!! val destDir = if (preferences.folderPerManga().get()) { - File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title)) + baseDir.findFile(DiskUtil.buildValidFilename(manga.title)) } else { - File(baseDir) - } + baseDir + }!! try { val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga) @@ -883,8 +879,8 @@ class ReaderViewModel( val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!! viewModelScope.launchNonCancellable { - val uri = saveImage(page, destDir, manga) - eventChannel.send(Event.ShareImage(uri.toFile(), page)) + val file = saveImage(page, destDir, manga) + eventChannel.send(Event.ShareImage(file, page)) } } @@ -895,9 +891,8 @@ class ReaderViewModel( val manga = manga ?: return@launch val context = Injekt.get() - val destDir = File(context.cacheDir, "shared_image") - destDir.deleteRecursively() try { + val destDir = UniFile.fromFile(context.cacheDir)!!.findFile("shared_image")!! val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga) eventChannel.send(Event.ShareImage(file, firstPage, secondPage)) } catch (_: Exception) { @@ -947,7 +942,7 @@ class ReaderViewModel( * Results of the save image feature. */ sealed class SaveImageResult { - class Success(val file: File) : SaveImageResult() + class Success(val file: UniFile) : SaveImageResult() class Error(val error: Throwable) : SaveImageResult() } @@ -1009,7 +1004,7 @@ class ReaderViewModel( data class SetCoverResult(val result: SetAsCoverResult) : Event() data class SavedImage(val result: SaveImageResult) : Event() - data class ShareImage(val file: File, val page: ReaderPage, val extraPage: ReaderPage? = null) : Event() + data class ShareImage(val file: UniFile, val page: ReaderPage, val extraPage: ReaderPage? = null) : Event() data class ShareTrackingError(val errors: List>) : Event() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt index cf56551c9f..9be9a25b03 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt @@ -8,6 +8,7 @@ import androidx.core.content.ContextCompat import coil3.imageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationReceiver @@ -37,9 +38,9 @@ class SaveImageNotifier(private val context: Context) { * * @param file image file containing downloaded page image. */ - fun onComplete(file: File) { + fun onComplete(file: UniFile) { val request = ImageRequest.Builder(context).memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED) - .data(file) + .data(file.uri) .size(720, 1280) .target( onSuccess = { @@ -54,7 +55,7 @@ class SaveImageNotifier(private val context: Context) { context.imageLoader.enqueue(request) } - private fun showCompleteNotification(file: File, image: Bitmap) { + private fun showCompleteNotification(file: UniFile, image: Bitmap) { with(notificationBuilder) { setContentTitle(context.getString(R.string.picture_saved)) setSmallIcon(R.drawable.ic_photo_24dp) @@ -70,13 +71,13 @@ class SaveImageNotifier(private val context: Context) { addAction( R.drawable.ic_share_24dp, context.getString(R.string.share), - NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId), + NotificationReceiver.shareImagePendingBroadcast(context, file.filePath!!, notificationId), ) // Delete action addAction( R.drawable.ic_delete_24dp, context.getString(R.string.delete), - NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId), + NotificationReceiver.deleteImagePendingBroadcast(context, file.filePath!!, notificationId), ) updateNotification() diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index b921e5ed26..fedf2f7a6f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util.storage import android.content.Context import android.media.MediaScannerConnection +import android.net.Uri import android.os.Environment import android.os.StatFs import androidx.core.content.ContextCompat @@ -77,7 +78,14 @@ object DiskUtil { * Scans the given file so that it can be shown in gallery apps, for example. */ fun scanMedia(context: Context, file: UniFile) { - scanMedia(context, file.filePath) + scanMedia(context, file.uri) + } + + /** + * Scans the given file so that it can be shown in gallery apps, for example. + */ + fun scanMedia(context: Context, uri: Uri) { + scanMedia(context, uri.path) } /** From deb6920b6a5057faed8ebdceaeefdaa0c3efe974 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 10:57:23 +0700 Subject: [PATCH 13/17] fix: App trying to backup as folder --- .../ui/setting/SettingsDataController.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt index 1f21a9639c..6063a0a416 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDataController.kt @@ -9,6 +9,7 @@ import android.view.MenuInflater import android.view.MenuItem import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.core.net.toFile import androidx.core.net.toUri import androidx.preference.PreferenceScreen import com.hippo.unifile.UniFile @@ -29,6 +30,7 @@ import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.injectLazy +import java.io.File class SettingsDataController : SettingsController() { @@ -184,17 +186,21 @@ class SettingsDataController : SettingsController() { } } - private fun doBackup(flags: Int, uri: Uri, requestPersist: Boolean = false) { + private fun doBackup(flags: Int, uri: Uri, requirePersist: Boolean = false) { val activity = activity ?: return - if (requestPersist) { - val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val actualUri = + if (requirePersist) { + val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION - activity.contentResolver.takePersistableUriPermission(uri, intentFlags) - } + activity.contentResolver.takePersistableUriPermission(uri, intentFlags) + uri + } else { + UniFile.fromUri(activity, uri)?.createFile(Backup.getBackupFilename())?.uri + } ?: return activity.toast(R.string.creating_backup) - BackupCreatorJob.startNow(activity, uri, flags) + BackupCreatorJob.startNow(activity, actualUri, flags) } fun createBackup(flags: Int, picker: Boolean = false) { From 85c30027cc8bcc3d3b17cce658c2d62c8ca2db82 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 11:46:47 +0700 Subject: [PATCH 14/17] fix(LocalSource): Can't find chapters --- .../eu/kanade/tachiyomi/source/LocalSource.kt | 77 ++++++++----------- 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 5b10772e75..6ee7f1e580 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -41,10 +41,8 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour fun getMangaLang(manga: SManga): String { return langMap.getOrPut(manga.url) { - val localDetails = getBaseDirectories() - .asSequence() - .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } - .flatten() + val localDetails = getBaseDirectory().findFile(manga.url)?.listFiles().orEmpty() + .filter { !it.isDirectory } .firstOrNull { it.extension.equals("json", ignoreCase = true) } return if (localDetails != null) { @@ -57,11 +55,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } fun updateCover(manga: SManga, input: InputStream): UniFile? { - val dir = getBaseDirectories().firstOrNull() - if (dir == null) { - input.close() - return null - } + val dir = getBaseDirectory() var cover = getCoverFile(dir.findFile(manga.url)) if (cover == null) { cover = dir.findFile(manga.url)?.findFile(COVER_NAME)!! @@ -86,9 +80,9 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } } - private fun getBaseDirectories(): List { + private fun getBaseDirectory(): UniFile { val storageManager: StorageManager by injectLazy() - return listOf(storageManager.getLocalSourceDirectory()!!) + return storageManager.getLocalSourceDirectory()!! } } @@ -108,18 +102,18 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour query: String, filters: FilterList, ): MangasPage { - val baseDirs = getBaseDirectories() - val time = if (filters === latestFilters) System.currentTimeMillis() - LATEST_THRESHOLD else 0L - var mangaDirs = baseDirs - .asSequence() - .mapNotNull { it.listFiles()?.toList() } - .flatten() - .filter { it.isDirectory } - .filterNot { it.name.orEmpty().startsWith('.') } - .filter { if (time == 0L) it.name.orEmpty().contains(query, ignoreCase = true) else it.lastModified() >= time } + + var mangaDirs = getBaseDirectory().listFiles().orEmpty() + .filter { it.isDirectory || !it.name.orEmpty().startsWith('.') } .distinctBy { it.name } + .filter { + if (time == 0L) + it.name.orEmpty().contains(query, ignoreCase = true) + else + it.lastModified() >= time + } val state = ((if (filters.isEmpty()) popularFilters else filters)[0] as OrderBy).state when (state?.index) { @@ -145,12 +139,9 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour url = mangaDir.name.orEmpty() // Try to find the cover - for (dir in baseDirs) { - val cover = getCoverFile(mangaDir.findFile(url)) - if (cover != null && cover.exists()) { - thumbnail_url = cover.filePath - break - } + val cover = getCoverFile(mangaDir) + if (cover != null && cover.exists()) { + thumbnail_url = cover.filePath } val manga = this @@ -185,10 +176,8 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters) override suspend fun getMangaDetails(manga: SManga): SManga { - val localDetails = getBaseDirectories() - .asSequence() - .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } - .flatten() + val localDetails = getBaseDirectory().findFile(manga.url)?.listFiles().orEmpty() + .filter { !it.isDirectory } .firstOrNull { it.extension.equals("json", ignoreCase = true) } return if (localDetails != null) { @@ -209,12 +198,12 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } fun updateMangaInfo(manga: SManga, lang: String?) { - val directory = getBaseDirectories().map { it.findFile(manga.url) }.find { - it?.exists() == true - } ?: return + val directory = getBaseDirectory().findFile(manga.url) ?: return + if (!directory.exists()) return + lang?.let { langMap[manga.url] = it } val json = Json { prettyPrint = true } - val existingFileName = directory.listFiles()?.find { it.name.orEmpty().endsWith("json", true) }?.name + val existingFileName = directory.listFiles()?.find { it.extension.equals("json", ignoreCase = true) }?.name val file = directory.findFile(existingFileName ?: "info.json")!! file.writeText(json.encodeToString(manga.toJson(lang))) } @@ -250,10 +239,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } override suspend fun getChapterList(manga: SManga): List { - val chapters = getBaseDirectories() - .asSequence() - .mapNotNull { it.findFile(manga.url)?.listFiles()?.toList() } - .flatten() + val chapters = getBaseDirectory().findFile(manga.url)?.listFiles().orEmpty() .filter { it.isDirectory || isSupportedFile(it.extension.orEmpty()) } .map { chapterFile -> SChapter.create().apply { @@ -291,15 +277,16 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } fun getFormat(chapter: SChapter): Format { - val baseDirs = getBaseDirectories() + val dir = getBaseDirectory() - for (dir in baseDirs) { - val chapFile = dir.findFile(chapter.url) - if (chapFile == null || !chapFile.exists()) continue + val (mangaDirName, chapterName) = chapter.url.split('/', limit = 2) + val chapFile = dir + .findFile(mangaDirName) + ?.findFile(chapterName) + if (chapFile == null || !chapFile.exists()) + throw Exception(context.getString(R.string.chapter_not_found)) - return getFormat(chapFile) - } - throw Exception(context.getString(R.string.chapter_not_found)) + return getFormat(chapFile) } private fun getFormat(file: UniFile) = with(file) { From 2393ddf9ce91ade419905b93467c551ab8a7325f Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 12:20:48 +0700 Subject: [PATCH 15/17] fix(LocalSource): Updating local entry crashes the app --- app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 6ee7f1e580..83f9bcce8f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -58,7 +58,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour val dir = getBaseDirectory() var cover = getCoverFile(dir.findFile(manga.url)) if (cover == null) { - cover = dir.findFile(manga.url)?.findFile(COVER_NAME)!! + cover = dir.findFile(manga.url)?.createFile(COVER_NAME)!! } // It might not exist if using the external SD card cover.parentFile?.parentFile?.createDirectory(cover.parentFile?.name) @@ -204,7 +204,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour lang?.let { langMap[manga.url] = it } val json = Json { prettyPrint = true } val existingFileName = directory.listFiles()?.find { it.extension.equals("json", ignoreCase = true) }?.name - val file = directory.findFile(existingFileName ?: "info.json")!! + val file = directory.createFile(existingFileName ?: "info.json")!! file.writeText(json.encodeToString(manga.toJson(lang))) } From a98ba32b43825286b8cf9ddf42b2ba69445408dc Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 12:26:31 +0700 Subject: [PATCH 16/17] fix(LocalSource): Local entry cover won't load --- .../tachiyomi/data/coil/MangaCoverFetcher.kt | 19 ++++++++++++++++++- .../eu/kanade/tachiyomi/source/LocalSource.kt | 6 +++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt index 11b872d4aa..2d9e9202a0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.coil import android.webkit.MimeTypeMap +import androidx.core.net.toUri import coil3.Extras import coil3.ImageLoader import coil3.decode.DataSource @@ -11,6 +12,7 @@ import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult import coil3.getOrDefault import coil3.request.Options +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.network.await @@ -31,6 +33,7 @@ import okio.Path.Companion.toOkioPath import okio.Source import okio.buffer import okio.sink +import okio.source import timber.log.Timber import uy.kohesive.injekt.injectLazy import java.io.File @@ -61,6 +64,7 @@ class MangaCoverFetcher( setRatioAndColorsInScope(manga, File(url.substringAfter("file://"))) fileLoader(File(url.substringAfter("file://"))) } + Type.URI -> fileUriLoader(url) null -> error("Invalid image") } } @@ -304,11 +308,24 @@ class MangaCoverFetcher( ) } + private fun fileUriLoader(uri: String): FetchResult { + val source = UniFile.fromUri(options.context, uri.toUri())!! + .openInputStream() + .source() + .buffer() + return SourceFetchResult( + source = ImageSource(source = source, fileSystem = FileSystem.SYSTEM), + mimeType = "image/*", + dataSource = DataSource.DISK, + ) + } + private fun getResourceType(cover: String?): Type? { return when { cover.isNullOrEmpty() -> null cover.startsWith("http") || cover.startsWith("Custom-", true) -> Type.URL cover.startsWith("/") || cover.startsWith("file://") -> Type.File + cover.startsWith("content") -> Type.URI else -> null } } @@ -328,7 +345,7 @@ class MangaCoverFetcher( } private enum class Type { - File, URL; + File, URL, URI; } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 83f9bcce8f..70a91c1197 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -54,7 +54,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } } - fun updateCover(manga: SManga, input: InputStream): UniFile? { + fun updateCover(manga: SManga, input: InputStream): UniFile { val dir = getBaseDirectory() var cover = getCoverFile(dir.findFile(manga.url)) if (cover == null) { @@ -67,7 +67,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour input.copyTo(it) } } - manga.thumbnail_url = cover.filePath + manga.thumbnail_url = cover.uri.toString() return cover } @@ -141,7 +141,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour // Try to find the cover val cover = getCoverFile(mangaDir) if (cover != null && cover.exists()) { - thumbnail_url = cover.filePath + thumbnail_url = cover.uri.toString() } val manga = this From 6ee44ee57f893a1df8163a7614a88b4e9cfe6dbf Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 27 May 2024 12:51:21 +0700 Subject: [PATCH 17/17] style: Change data and storage's icon --- .../eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt | 2 +- app/src/main/res/drawable/ic_storage_24dp.xml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/ic_storage_24dp.xml diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index 705e5b0b65..0479f37e9b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -73,7 +73,7 @@ class SettingsMainController : SettingsController(), FloatingSearchInterface { onClick { navigateTo(SettingsTrackingController()) } } preference { - iconRes = R.drawable.ic_backup_restore_24dp + iconRes = R.drawable.ic_storage_24dp iconTint = tintColor titleRes = R.string.data_and_storage onClick { navigateTo(SettingsDataController()) } diff --git a/app/src/main/res/drawable/ic_storage_24dp.xml b/app/src/main/res/drawable/ic_storage_24dp.xml new file mode 100644 index 0000000000..c8faeb20ca --- /dev/null +++ b/app/src/main/res/drawable/ic_storage_24dp.xml @@ -0,0 +1,5 @@ + + +