diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3920b2ce5..1527fc0e3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - versionCode = 113 + versionCode = 114 versionName = "0.14.7" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 778b7645c..5bcaf48e6 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -11,8 +11,11 @@ import eu.kanade.domain.manga.interactor.GetExcludedScanlators import eu.kanade.domain.manga.interactor.SetExcludedScanlators import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.source.interactor.CreateSourceRepo +import eu.kanade.domain.source.interactor.DeleteSourceRepo import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetLanguagesWithSources +import eu.kanade.domain.source.interactor.GetSourceRepos import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount import eu.kanade.domain.source.interactor.SetMigrateSorting import eu.kanade.domain.source.interactor.ToggleLanguage @@ -167,5 +170,9 @@ class DomainModule : InjektModule { addFactory { ToggleLanguage(get()) } addFactory { ToggleSource(get()) } addFactory { ToggleSourcePin(get()) } + + addFactory { CreateSourceRepo(get()) } + addFactory { DeleteSourceRepo(get()) } + addFactory { GetSourceRepos(get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt b/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt new file mode 100644 index 000000000..e22d8980f --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt @@ -0,0 +1,26 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import tachiyomi.core.preference.plusAssign + +class CreateSourceRepo(private val preferences: SourcePreferences) { + + fun await(name: String): Result { + // Do not allow invalid formats + if (!name.matches(repoRegex) || name.startsWith(OFFICIAL_REPO_BASE_URL)) { + return Result.InvalidUrl + } + + preferences.extensionRepos() += name.substringBeforeLast("/index.min.json") + + return Result.Success + } + + sealed interface Result { + data object InvalidUrl : Result + data object Success : Result + } +} + +const val OFFICIAL_REPO_BASE_URL = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo" +private val repoRegex = """^https://.*/index\.min\.json$""".toRegex() diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt new file mode 100644 index 000000000..1bf109895 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt @@ -0,0 +1,11 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import tachiyomi.core.preference.minusAssign + +class DeleteSourceRepo(private val preferences: SourcePreferences) { + + fun await(repo: String) { + preferences.extensionRepos() -= repo + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt new file mode 100644 index 000000000..fdebe8147 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt @@ -0,0 +1,13 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetSourceRepos(private val preferences: SourcePreferences) { + + fun subscribe(): Flow> { + return preferences.extensionRepos().changes() + .map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) } + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index 0fe4ce23f..ea00bfc69 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -38,6 +38,8 @@ class SourcePreferences( SetMigrateSorting.Direction.ASCENDING, ) + fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet()) + fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0) fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet()) diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 65da03142..3c4b8e2ca 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog @@ -38,6 +37,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -67,7 +67,6 @@ fun ExtensionDetailsScreen( state: ExtensionDetailsScreenModel.State, onClickSourcePreferences: (sourceId: Long) -> Unit, onClickWhatsNew: () -> Unit, - onClickReadme: () -> Unit, onClickEnableAll: () -> Unit, onClickDisableAll: () -> Unit, onClickClearCookies: () -> Unit, @@ -91,13 +90,6 @@ fun ExtensionDetailsScreen( onClick = onClickWhatsNew, ), ) - add( - AppBar.Action( - title = stringResource(MR.strings.action_faq_and_guides), - icon = Icons.AutoMirrored.Outlined.HelpOutline, - onClick = onClickReadme, - ), - ) } addAll( listOf( @@ -125,7 +117,7 @@ fun ExtensionDetailsScreen( ) { paddingValues -> if (state.extension == null) { EmptyScreen( - stringRes = MR.strings.empty_screen, + MR.strings.empty_screen, modifier = Modifier.padding(paddingValues), ) return@Scaffold @@ -158,6 +150,21 @@ private fun ExtensionDetails( contentPadding = contentPadding, ) { when { + extension.isRepoSource -> + item { + val uriHandler = LocalUriHandler.current + WarningBanner( + MR.strings.repo_extension_message, + modifier = Modifier.clickable { + extension.repoUrl ?: return@clickable + uriHandler.openUri( + extension.repoUrl + .replace("https://raw.githubusercontent.com", "https://github.com") + .removeSuffix("/repo/"), + ) + }, + ) + } extension.isUnofficial -> item { WarningBanner(MR.strings.unofficial_extension_message) diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt index d7a484c6d..fd6396b95 100644 --- a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.focus.focusRequester import eu.kanade.core.preference.asToggleableState import eu.kanade.presentation.category.visualName import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import tachiyomi.core.preference.CheckboxState import tachiyomi.domain.category.model.Category @@ -40,12 +41,12 @@ import kotlin.time.Duration.Companion.seconds fun CategoryCreateDialog( onDismissRequest: () -> Unit, onCreate: (String) -> Unit, - categories: ImmutableList, + categories: ImmutableList, ) { var name by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } - val nameAlreadyExists = remember(name) { categories.anyWithName(name) } + val nameAlreadyExists = remember(name) { categories.contains(name) } AlertDialog( onDismissRequest = onDismissRequest, @@ -70,10 +71,13 @@ fun CategoryCreateDialog( }, text = { OutlinedTextField( - modifier = Modifier.focusRequester(focusRequester), + modifier = Modifier + .focusRequester(focusRequester), value = name, onValueChange = { name = it }, - label = { Text(text = stringResource(MR.strings.name)) }, + label = { + Text(text = stringResource(MR.strings.name)) + }, supportingText = { val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { MR.strings.error_category_exists @@ -99,14 +103,14 @@ fun CategoryCreateDialog( fun CategoryRenameDialog( onDismissRequest: () -> Unit, onRename: (String) -> Unit, - categories: ImmutableList, - category: Category, + categories: ImmutableList, + category: String, ) { - var name by remember { mutableStateOf(category.name) } + var name by remember { mutableStateOf(category) } var valueHasChanged by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } - val nameAlreadyExists = remember(name) { categories.anyWithName(name) } + val nameAlreadyExists = remember(name) { categories.contains(name) } AlertDialog( onDismissRequest = onDismissRequest, @@ -163,7 +167,7 @@ fun CategoryRenameDialog( fun CategoryDeleteDialog( onDismissRequest: () -> Unit, onDelete: () -> Unit, - category: Category, + category: String, ) { AlertDialog( onDismissRequest = onDismissRequest, @@ -184,7 +188,7 @@ fun CategoryDeleteDialog( Text(text = stringResource(MR.strings.delete_category)) }, text = { - Text(text = stringResource(MR.strings.delete_category_confirmation, category.name)) + Text(text = stringResource(MR.strings.delete_category_confirmation, category)) }, ) } @@ -220,7 +224,7 @@ fun CategorySortAlphabeticallyDialog( @Composable fun ChangeCategoryDialog( - initialSelection: List>, + initialSelection: ImmutableList>, onDismissRequest: () -> Unit, onEditCategories: () -> Unit, onConfirm: (List, List) -> Unit, @@ -292,7 +296,7 @@ fun ChangeCategoryDialog( if (index != -1) { val mutableList = selection.toMutableList() mutableList[index] = it.next() - selection = mutableList.toList() + selection = mutableList.toList().toImmutableList() } } Row( @@ -326,7 +330,3 @@ fun ChangeCategoryDialog( }, ) } - -private fun List.anyWithName(name: String): Boolean { - return any { name == it.name } -} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt index e2d5c4261..5c387e542 100644 --- a/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt @@ -49,7 +49,7 @@ fun CategoryListItem( ), verticalAlignment = Alignment.CenterVertically, ) { - Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "") + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) Text( text = category.name, modifier = Modifier @@ -61,13 +61,13 @@ fun CategoryListItem( onClick = { onMoveUp(category) }, enabled = canMoveUp, ) { - Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = "") + Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null) } IconButton( onClick = { onMoveDown(category) }, enabled = canMoveDown, ) { - Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "") + Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null) } Spacer(modifier = Modifier.weight(1f)) IconButton(onClick = onRename) { diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 7a6d8d50b..9a6e3f6fd 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -78,12 +78,13 @@ import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.isScrolledToEnd import tachiyomi.presentation.core.util.isScrollingUp import tachiyomi.source.local.isLocal +import java.time.Instant @Composable fun MangaScreen( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - fetchInterval: Int?, + nextUpdate: Instant?, isTabletUi: Boolean, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, @@ -138,7 +139,7 @@ fun MangaScreen( MangaScreenSmallImpl( state = state, snackbarHostState = snackbarHostState, - fetchInterval = fetchInterval, + nextUpdate = nextUpdate, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, onBackClicked = onBackClicked, @@ -175,7 +176,7 @@ fun MangaScreen( snackbarHostState = snackbarHostState, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, - fetchInterval = fetchInterval, + nextUpdate = nextUpdate, onBackClicked = onBackClicked, onChapterClicked = onChapterClicked, onDownloadChapter = onDownloadChapter, @@ -211,7 +212,7 @@ fun MangaScreen( private fun MangaScreenSmallImpl( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - fetchInterval: Int?, + nextUpdate: Instant?, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onBackClicked: () -> Unit, @@ -272,10 +273,7 @@ private fun MangaScreenSmallImpl( onBackClicked() } } - BackHandler( - enabled = isAnySelected, - onBack = { onAllChapterSelected(false) }, - ) + BackHandler(onBack = internalOnBackPressed) Scaffold( topBar = { @@ -402,7 +400,7 @@ private fun MangaScreenSmallImpl( MangaActionRow( favorite = state.manga.favorite, trackingCount = state.trackingCount, - fetchInterval = fetchInterval, + nextUpdate = nextUpdate, isUserIntervalMode = state.manga.fetchInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, @@ -462,7 +460,7 @@ private fun MangaScreenSmallImpl( fun MangaScreenLargeImpl( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, - fetchInterval: Int?, + nextUpdate: Instant?, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onBackClicked: () -> Unit, @@ -529,10 +527,7 @@ fun MangaScreenLargeImpl( onBackClicked() } } - BackHandler( - enabled = isAnySelected, - onBack = { onAllChapterSelected(false) }, - ) + BackHandler(onBack = internalOnBackPressed) Scaffold( topBar = { @@ -641,7 +636,7 @@ fun MangaScreenLargeImpl( MangaActionRow( favorite = state.manga.favorite, trackingCount = state.trackingCount, - fetchInterval = fetchInterval, + nextUpdate = nextUpdate, isUserIntervalMode = state.manga.fetchInterval < 0, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index 3b5d7e558..b3c06a979 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -3,10 +3,6 @@ package eu.kanade.presentation.manga.components import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build -import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -29,18 +25,15 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -55,13 +48,11 @@ import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import kotlinx.collections.immutable.persistentListOf -import soup.compose.material.motion.MotionConstants import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.clickableNoIndication -import kotlin.coroutines.cancellation.CancellationException @Composable fun MangaCoverDialog( @@ -160,32 +151,10 @@ fun MangaCoverDialog( val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } - var scale by remember { mutableFloatStateOf(1f) } - PredictiveBackHandler { progress -> - try { - progress.collect { backEvent -> - scale = lerp(1f, 0.8f, LinearOutSlowInEasing.transform(backEvent.progress)) - } - onDismissRequest() - } catch (e: CancellationException) { - animate( - initialValue = scale, - targetValue = 1f, - animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration), - ) { value, _ -> - scale = value - } - } - } - Box( modifier = Modifier .fillMaxSize() - .clickableNoIndication(onClick = onDismissRequest) - .graphicsLayer { - scaleX = scale - scaleY = scale - }, + .clickableNoIndication(onClick = onDismissRequest), ) { AndroidView( factory = { diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt index f59b4574a..655601a0a 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaDialogs.kt @@ -2,8 +2,11 @@ package eu.kanade.presentation.manga.components import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -16,10 +19,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.util.system.isDevFlavor +import eu.kanade.tachiyomi.util.system.isPreviewBuildType import kotlinx.collections.immutable.toImmutableList import tachiyomi.domain.manga.interactor.FetchInterval import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.WheelTextPicker +import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.stringResource import java.time.Instant @@ -59,57 +65,71 @@ fun DeleteChaptersDialog( @Composable fun SetIntervalDialog( interval: Int, - nextUpdate: Long, + nextUpdate: Instant?, onDismissRequest: () -> Unit, - onValueChanged: (Int) -> Unit, + onValueChanged: ((Int) -> Unit)? = null, ) { var selectedInterval by rememberSaveable { mutableIntStateOf(if (interval < 0) -interval else 0) } val nextUpdateDays = remember(nextUpdate) { - val now = Instant.now() - val nextUpdateInstant = Instant.ofEpochMilli(nextUpdate) - - now.until(nextUpdateInstant, ChronoUnit.DAYS) + return@remember if (nextUpdate != null) { + val now = Instant.now() + now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0) + } else { + null + } } AlertDialog( onDismissRequest = onDismissRequest, - title = { Text(stringResource(MR.strings.manga_modify_calculated_interval_title)) }, + title = { Text(stringResource(MR.strings.pref_library_update_smart_update)) }, text = { Column { - if (nextUpdateDays >= 0) { + if (nextUpdateDays != null && nextUpdateDays >= 0) { Text( stringResource( MR.strings.manga_interval_expected_update, pluralStringResource( MR.plurals.day, - count = nextUpdateDays.toInt(), + count = nextUpdateDays, nextUpdateDays, ), + pluralStringResource( + MR.plurals.day, + count = interval, + interval, + ), ), ) + + Spacer(Modifier.height(MaterialTheme.padding.small)) } - BoxWithConstraints( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - val size = DpSize(width = maxWidth / 2, height = 128.dp) - val items = (0..FetchInterval.MAX_INTERVAL) - .map { - if (it == 0) { - stringResource(MR.strings.label_default) - } else { - it.toString() + // TODO: selecting "1" then doesn't allow for future changes unless defaulting first? + if (onValueChanged != null && (isDevFlavor || isPreviewBuildType)) { + Text(stringResource(MR.strings.manga_interval_custom_amount)) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val size = DpSize(width = maxWidth / 2, height = 128.dp) + val items = (0..FetchInterval.MAX_INTERVAL) + .map { + if (it == 0) { + stringResource(MR.strings.label_default) + } else { + it.toString() + } } - } - .toImmutableList() - WheelTextPicker( - items = items, - size = size, - startIndex = selectedInterval, - onSelectionChanged = { selectedInterval = it }, - ) + .toImmutableList() + WheelTextPicker( + items = items, + size = size, + startIndex = selectedInterval, + onSelectionChanged = { selectedInterval = it }, + ) + } } } }, @@ -120,7 +140,7 @@ fun SetIntervalDialog( }, confirmButton = { TextButton(onClick = { - onValueChanged(selectedInterval) + onValueChanged?.invoke(selectedInterval) onDismissRequest() }) { Text(text = stringResource(MR.strings.action_ok)) diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt index 7b5de2467..f1d228534 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt @@ -86,7 +86,8 @@ import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.clickableNoIndication import tachiyomi.presentation.core.util.secondaryItemAlpha -import kotlin.math.absoluteValue +import java.time.Instant +import java.time.temporal.ChronoUnit import kotlin.math.roundToInt private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)) @@ -165,7 +166,7 @@ fun MangaInfoBox( fun MangaActionRow( favorite: Boolean, trackingCount: Int, - fetchInterval: Int?, + nextUpdate: Instant?, isUserIntervalMode: Boolean, onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, @@ -177,6 +178,16 @@ fun MangaActionRow( ) { val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) + // TODO: show something better when using custom interval + val nextUpdateDays = remember(nextUpdate) { + return@remember if (nextUpdate != null) { + val now = Instant.now() + now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0) + } else { + null + } + } + Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) { MangaActionButton( title = if (favorite) { @@ -189,18 +200,20 @@ fun MangaActionRow( onClick = onAddToLibraryClicked, onLongClick = onEditCategory, ) - if (onEditIntervalClicked != null && fetchInterval != null) { - MangaActionButton( - title = pluralStringResource( + MangaActionButton( + title = if (nextUpdateDays != null) { + pluralStringResource( MR.plurals.day, - count = fetchInterval.absoluteValue, - fetchInterval.absoluteValue, - ), - icon = Icons.Default.HourglassEmpty, - color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor, - onClick = onEditIntervalClicked, - ) - } + count = nextUpdateDays, + nextUpdateDays, + ) + } else { + stringResource(MR.strings.not_applicable) + }, + icon = Icons.Default.HourglassEmpty, + color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor, + onClick = { onEditIntervalClicked?.invoke() }, + ) MangaActionButton( title = if (trackingCount == 0) { stringResource(MR.strings.manga_tracking_tab) diff --git a/app/src/main/java/eu/kanade/presentation/more/NewUpdateScreen.kt b/app/src/main/java/eu/kanade/presentation/more/NewUpdateScreen.kt index f3c8a433d..96fd421dd 100644 --- a/app/src/main/java/eu/kanade/presentation/more/NewUpdateScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/NewUpdateScreen.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.ui.RichTextStyle -import com.halilibo.richtext.ui.material3.Material3RichText +import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.string.RichTextStringStyle import eu.kanade.presentation.theme.TachiyomiTheme import tachiyomi.i18n.MR @@ -42,7 +42,7 @@ fun NewUpdateScreen( rejectText = stringResource(MR.strings.action_not_now), onRejectClick = onRejectUpdate, ) { - Material3RichText( + RichText( modifier = Modifier .fillMaxWidth() .padding(vertical = MaterialTheme.padding.large), diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt index 61c1db21e..57508ecbd 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt @@ -2,16 +2,22 @@ package eu.kanade.presentation.more.settings.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import kotlinx.collections.immutable.persistentListOf import tachiyomi.core.i18n.stringResource import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.collectAsState import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -24,7 +30,11 @@ object SettingsBrowseScreen : SearchableSettings { @Composable override fun getPreferences(): List { val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val sourcePreferences = remember { Injekt.get() } + val reposCount by sourcePreferences.extensionRepos().collectAsState() + return listOf( Preference.PreferenceGroup( title = stringResource(MR.strings.label_sources), @@ -33,6 +43,13 @@ object SettingsBrowseScreen : SearchableSettings { pref = sourcePreferences.hideInLibraryItems(), title = stringResource(MR.strings.pref_hide_in_library_items), ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.label_extension_repos), + subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size), + onClick = { + navigator.push(ExtensionReposScreen()) + }, + ), ), ), Preference.PreferenceGroup( diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index 1ad7410be..346a60b86 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -198,7 +198,7 @@ object SettingsLibraryScreen : SearchableSettings { ), Preference.PreferenceItem.MultiSelectListPreference( pref = libraryPreferences.autoUpdateMangaRestrictions(), - title = stringResource(MR.strings.pref_library_update_manga_restriction), + title = stringResource(MR.strings.pref_library_update_smart_update), entries = persistentMapOf( MANGA_HAS_UNREAD to stringResource(MR.strings.pref_update_only_completely_read), MANGA_NON_READ to stringResource(MR.strings.pref_update_only_started), diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt new file mode 100644 index 000000000..0cf4f027d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt @@ -0,0 +1,69 @@ +package eu.kanade.presentation.more.settings.screen.browse + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest +import tachiyomi.presentation.core.screens.LoadingScreen + +class ExtensionReposScreen : Screen() { + + @Composable + override fun Content() { + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val screenModel = rememberScreenModel { ExtensionReposScreenModel() } + + val state by screenModel.state.collectAsState() + + if (state is RepoScreenState.Loading) { + LoadingScreen() + return + } + + val successState = state as RepoScreenState.Success + + ExtensionReposScreen( + state = successState, + onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, + onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, + navigateUp = navigator::pop, + ) + + when (val dialog = successState.dialog) { + null -> {} + RepoDialog.Create -> { + ExtensionRepoCreateDialog( + onDismissRequest = screenModel::dismissDialog, + onCreate = { screenModel.createRepo(it) }, + categories = successState.repos, + ) + } + is RepoDialog.Delete -> { + ExtensionRepoDeleteDialog( + onDismissRequest = screenModel::dismissDialog, + onDelete = { screenModel.deleteRepo(dialog.repo) }, + repo = dialog.repo, + ) + } + } + + LaunchedEffect(Unit) { + screenModel.events.collectLatest { event -> + if (event is RepoEvent.LocalizedMessage) { + context.toast(event.stringRes) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt new file mode 100644 index 000000000..fc1d3893c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt @@ -0,0 +1,111 @@ +package eu.kanade.presentation.more.settings.screen.browse + +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import dev.icerock.moko.resources.StringResource +import eu.kanade.domain.source.interactor.CreateSourceRepo +import eu.kanade.domain.source.interactor.DeleteSourceRepo +import eu.kanade.domain.source.interactor.GetSourceRepos +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import tachiyomi.core.util.lang.launchIO +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class ExtensionReposScreenModel( + private val getSourceRepos: GetSourceRepos = Injekt.get(), + private val createSourceRepo: CreateSourceRepo = Injekt.get(), + private val deleteSourceRepo: DeleteSourceRepo = Injekt.get(), +) : StateScreenModel(RepoScreenState.Loading) { + + private val _events: Channel = Channel(Int.MAX_VALUE) + val events = _events.receiveAsFlow() + + init { + screenModelScope.launchIO { + getSourceRepos.subscribe() + .collectLatest { repos -> + mutableState.update { + RepoScreenState.Success( + repos = repos.toImmutableList(), + ) + } + } + } + } + + /** + * Creates and adds a new repo to the database. + * + * @param name The name of the repo to create. + */ + fun createRepo(name: String) { + screenModelScope.launchIO { + when (createSourceRepo.await(name)) { + is CreateSourceRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl) + else -> {} + } + } + } + + /** + * Deletes the given repo from the database. + * + * @param repo The repo to delete. + */ + fun deleteRepo(repo: String) { + screenModelScope.launchIO { + deleteSourceRepo.await(repo) + } + } + + fun showDialog(dialog: RepoDialog) { + mutableState.update { + when (it) { + RepoScreenState.Loading -> it + is RepoScreenState.Success -> it.copy(dialog = dialog) + } + } + } + + fun dismissDialog() { + mutableState.update { + when (it) { + RepoScreenState.Loading -> it + is RepoScreenState.Success -> it.copy(dialog = null) + } + } + } +} + +sealed class RepoEvent { + sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent() + data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name) +} + +sealed class RepoDialog { + data object Create : RepoDialog() + data class Delete(val repo: String) : RepoDialog() +} + +sealed class RepoScreenState { + + @Immutable + data object Loading : RepoScreenState() + + @Immutable + data class Success( + val repos: ImmutableList, + val dialog: RepoDialog? = null, + ) : RepoScreenState() { + + val isEmpty: Boolean + get() = repos.isEmpty() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt new file mode 100644 index 000000000..8281f5874 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt @@ -0,0 +1,81 @@ +package eu.kanade.presentation.more.settings.screen.browse.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +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.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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 kotlinx.collections.immutable.ImmutableList +import tachiyomi.presentation.core.components.material.padding + +@Composable +fun ExtensionReposContent( + repos: ImmutableList, + lazyListState: LazyListState, + paddingValues: PaddingValues, + onClickDelete: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + state = lazyListState, + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + modifier = modifier, + ) { + items(repos) { repo -> + ExtensionRepoListItem( + modifier = Modifier.animateItemPlacement(), + repo = repo, + onDelete = { onClickDelete(repo) }, + ) + } + } +} + +@Composable +private fun ExtensionRepoListItem( + repo: String, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = MaterialTheme.padding.medium, + top = MaterialTheme.padding.medium, + end = MaterialTheme.padding.medium, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) + Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + IconButton(onClick = onDelete) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = null) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt new file mode 100644 index 000000000..9f20b196d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt @@ -0,0 +1,117 @@ +package eu.kanade.presentation.more.settings.screen.browse.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import kotlin.time.Duration.Companion.seconds + +@Composable +fun ExtensionRepoCreateDialog( + onDismissRequest: () -> Unit, + onCreate: (String) -> Unit, + categories: ImmutableList, +) { + var name by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val nameAlreadyExists = remember(name) { categories.contains(name) } + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + enabled = name.isNotEmpty() && !nameAlreadyExists, + onClick = { + onCreate(name) + onDismissRequest() + }, + ) { + Text(text = stringResource(MR.strings.action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_cancel)) + } + }, + title = { + Text(text = stringResource(MR.strings.action_add_repo)) + }, + text = { + Column { + Text(text = stringResource(MR.strings.action_add_repo_message)) + + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = name, + onValueChange = { name = it }, + label = { + Text(text = stringResource(MR.strings.label_add_repo_input)) + }, + supportingText = { + val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { + MR.strings.error_repo_exists + } else { + MR.strings.information_required_plain + } + Text(text = stringResource(msgRes)) + }, + isError = name.isNotEmpty() && nameAlreadyExists, + singleLine = true, + ) + } + }, + ) + + LaunchedEffect(focusRequester) { + // TODO: https://issuetracker.google.com/issues/204502668 + delay(0.1.seconds) + focusRequester.requestFocus() + } +} + +@Composable +fun ExtensionRepoDeleteDialog( + onDismissRequest: () -> Unit, + onDelete: () -> Unit, + repo: String, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = { + onDelete() + onDismissRequest() + }) { + Text(text = stringResource(MR.strings.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_cancel)) + } + }, + title = { + Text(text = stringResource(MR.strings.action_delete_repo)) + }, + text = { + Text(text = stringResource(MR.strings.delete_repo_confirmation, repo)) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt new file mode 100644 index 000000000..1bd680d06 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt @@ -0,0 +1,61 @@ +@file:JvmName("ExtensionReposScreenKt") + +package eu.kanade.presentation.more.settings.screen.browse.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import eu.kanade.presentation.category.components.CategoryFloatingActionButton +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.components.material.topSmallPaddingValues +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.util.plus + +@Composable +fun ExtensionReposScreen( + state: RepoScreenState.Success, + onClickCreate: () -> Unit, + onClickDelete: (String) -> Unit, + navigateUp: () -> Unit, +) { + val lazyListState = rememberLazyListState() + Scaffold( + topBar = { scrollBehavior -> + AppBar( + navigateUp = navigateUp, + title = stringResource(MR.strings.label_extension_repos), + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + CategoryFloatingActionButton( + lazyListState = lazyListState, + onCreate = onClickCreate, + ) + }, + ) { paddingValues -> + if (state.isEmpty) { + EmptyScreen( + MR.strings.information_empty_repos, + modifier = Modifier.padding(paddingValues), + ) + return@Scaffold + } + + ExtensionReposContent( + repos = state.repos, + lazyListState = lazyListState, + paddingValues = paddingValues + topSmallPaddingValues + + PaddingValues(horizontal = MaterialTheme.padding.medium), + onClickDelete = onClickDelete, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt index 8083bc0af..86311d8e0 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt @@ -1,54 +1,13 @@ package eu.kanade.presentation.util import android.annotation.SuppressLint -import androidx.activity.BackEventCompat -import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlurEffect -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.toSize -import androidx.compose.ui.util.lerp -import androidx.compose.ui.zIndex import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModelStore import cafe.adriel.voyager.core.screen.Screen @@ -57,25 +16,14 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.ScreenTransitionContent -import eu.kanade.tachiyomi.util.view.getWindowRadius import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import soup.compose.material.motion.MotionConstants import soup.compose.material.motion.animation.materialSharedAxisX import soup.compose.material.motion.animation.rememberSlideDistance -import kotlin.coroutines.cancellation.CancellationException -import kotlin.math.PI -import kotlin.math.sin /** * For invoking back press to the parent activity @@ -109,299 +57,17 @@ interface AssistContentScreen { } @Composable -fun DefaultNavigatorScreenTransition( - navigator: Navigator, - modifier: Modifier = Modifier, -) { - val scope = rememberCoroutineScope() - val view = LocalView.current - val handler = remember { - OnBackHandler( - scope = scope, - windowCornerRadius = view.getWindowRadius(), - onBackPressed = navigator::pop, - ) - } - PredictiveBackHandler(enabled = navigator.canPop) { progress -> - progress - .onStart { handler.reset() } - .onCompletion { e -> - if (e == null) { - handler.onBackConfirmed() - } else { - handler.onBackCancelled() - } - } - .collect(handler::onBackEvent) - } - - Box(modifier = modifier.onSizeChanged { handler.updateContainerSize(it.toSize()) }) { - val currentSceneEntry = navigator.lastItem - val showPrev by remember { - derivedStateOf { handler.scale < 1f || handler.translationY != 0f } - } - val visibleItems = remember(currentSceneEntry, showPrev) { - if (showPrev) { - val prevSceneEntry = navigator.items.getOrNull(navigator.size - 2) - listOfNotNull(currentSceneEntry, prevSceneEntry) - } else { - listOfNotNull(currentSceneEntry) - } - } - - val slideDistance = rememberSlideDistance() - - val screenContent = remember { - movableContentOf { screen -> - navigator.saveableState("transition", screen) { - screen.Content() - } - } - } - - visibleItems.forEachIndexed { index, backStackEntry -> - val isPrev = index == 1 && visibleItems.size > 1 - if (!isPrev) { - AnimatedContent( - targetState = backStackEntry, - transitionSpec = { - val forward = navigator.lastEvent != StackEvent.Pop - if (!forward && !handler.isReady) { - // Pop screen without animation when predictive back is in use - EnterTransition.None togetherWith ExitTransition.None - } else { - materialSharedAxisX( - forward = forward, - slideDistance = slideDistance, - ) - } - }, - modifier = Modifier - .zIndex(1f) - .graphicsLayer { - this.alpha = handler.alpha - this.transformOrigin = TransformOrigin( - pivotFractionX = if (handler.swipeEdge == BackEventCompat.EDGE_LEFT) 0.8f else 0.2f, - pivotFractionY = 0.5f, - ) - this.scaleX = handler.scale - this.scaleY = handler.scale - this.translationY = handler.translationY - this.clip = true - this.shape = if (showPrev) { - RoundedCornerShape(handler.windowCornerRadius.toFloat()) - } else { - RectangleShape - } - } - .then( - if (showPrev) { - Modifier.pointerInput(Unit) { - // Animated content should not be interactive - } - } else { - Modifier - }, - ), - content = { - if (visibleItems.size == 2 && visibleItems.getOrNull(1) == it) { - // Avoid drawing previous screen - return@AnimatedContent - } - screenContent(it) - }, - ) - } else { - Box( - modifier = Modifier - .zIndex(0f) - .drawWithCache { - val bounds = Rect(Offset.Zero, size) - val matrix = ColorMatrix().apply { - // Reduce saturation and brightness - setToSaturation(lerp(1f, 0.95f, handler.alpha)) - set(0, 4, lerp(0f, -25f, handler.alpha)) - set(1, 4, lerp(0f, -25f, handler.alpha)) - set(2, 4, lerp(0f, -25f, handler.alpha)) - } - val paint = Paint().apply { colorFilter = ColorFilter.colorMatrix(matrix) } - onDrawWithContent { - drawIntoCanvas { - it.saveLayer(bounds, paint) - drawContent() - it.restore() - } - } - } - .graphicsLayer { - val blurRadius = 5.dp.toPx() * handler.alpha - renderEffect = if (blurRadius > 0f) { - BlurEffect(blurRadius, blurRadius) - } else { - null - } - } - .pointerInput(Unit) { - // bg content should not be interactive - }, - content = { screenContent(backStackEntry) }, - ) - } - } - - LaunchedEffect(currentSceneEntry) { - // Reset *after* the screen is popped successfully - // so that the correct transition is applied - handler.setReady() - } - } -} - -@Stable -private class OnBackHandler( - private val scope: CoroutineScope, - val windowCornerRadius: Int, - private val onBackPressed: () -> Unit, -) { - - var isReady = true - private set - - var alpha by mutableFloatStateOf(1f) - private set - - var scale by mutableFloatStateOf(1f) - private set - - var translationY by mutableFloatStateOf(0f) - private set - - var swipeEdge by mutableIntStateOf(BackEventCompat.EDGE_LEFT) - private set - - private var containerSize = Size.Zero - private var startPointY = Float.NaN - - var isPredictiveBack by mutableStateOf(false) - private set - - private var animationJob: Job? = null - set(value) { - isReady = false - field = value - } - - fun updateContainerSize(size: Size) { - containerSize = size - } - - fun setReady() { - reset() - animationJob?.cancel() - animationJob = null - isReady = true - isPredictiveBack = false - } - - fun reset() { - startPointY = Float.NaN - } - - fun onBackEvent(backEvent: BackEventCompat) { - if (!isReady) return - isPredictiveBack = true - swipeEdge = backEvent.swipeEdge - - val progress = LinearOutSlowInEasing.transform(backEvent.progress) - scale = lerp(1f, 0.85f, progress) - - if (startPointY.isNaN()) { - startPointY = backEvent.touchY - } - val deltaYRatio = (backEvent.touchY - startPointY) / containerSize.height - val translateYDistance = containerSize.height / 20 - translationY = sin(deltaYRatio * PI * 0.5).toFloat() * translateYDistance * progress - } - - fun onBackConfirmed() { - if (!isReady) return - if (isPredictiveBack) { - // Continue predictive animation and pop the screen - val animationSpec = tween( - durationMillis = MotionConstants.DefaultMotionDuration, - easing = FastOutSlowInEasing, +fun DefaultNavigatorScreenTransition(navigator: Navigator) { + val slideDistance = rememberSlideDistance() + ScreenTransition( + navigator = navigator, + transition = { + materialSharedAxisX( + forward = navigator.lastEvent != StackEvent.Pop, + slideDistance = slideDistance, ) - animationJob = scope.launch { - try { - listOf( - async { - animate( - initialValue = alpha, - targetValue = 0f, - animationSpec = animationSpec, - ) { value, _ -> - alpha = value - } - }, - async { - animate( - initialValue = scale, - targetValue = scale - 0.05f, - animationSpec = animationSpec, - ) { value, _ -> - scale = value - } - }, - ).awaitAll() - } catch (e: CancellationException) { - // no-op - } finally { - onBackPressed() - alpha = 1f - translationY = 0f - scale = 1f - } - } - } else { - // Pop right away and use default transition - onBackPressed() - } - } - - fun onBackCancelled() { - // Reset states - isPredictiveBack = false - animationJob = scope.launch { - listOf( - async { - animate( - initialValue = scale, - targetValue = 1f, - ) { value, _ -> - scale = value - } - }, - async { - animate( - initialValue = alpha, - targetValue = 1f, - ) { value, _ -> - alpha = value - } - }, - async { - animate( - initialValue = translationY, - targetValue = 0f, - ) { value, _ -> - translationY = value - } - }, - ).awaitAll() - - isReady = true - } - } + }, + ) } @Composable diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 775bfe78d..46f7e3812 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -405,6 +405,11 @@ object Migrations { // Deleting old download cache index files, but might as well clear it all out context.cacheDir.deleteRecursively() } + if (oldVersion < 114) { + sourcePreferences.extensionRepos().getAndSet { + it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet() + } + } return true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt index 77676004d..0f24c2df1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt @@ -28,6 +28,7 @@ import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.serialization.XML import tachiyomi.core.storage.AndroidStorageFolderProvider +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.data.AndroidDatabaseHandler import tachiyomi.data.Database import tachiyomi.data.DatabaseHandler @@ -112,6 +113,8 @@ class AppModule(val app: Application) : InjektModule { ProtoBuf } + addSingletonFactory { UniFileTempFileManager(app) } + addSingletonFactory { ChapterCache(app, get()) } addSingletonFactory { CoverCache(app) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 58ffe1111..4c342dd3d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.extension import android.content.Context import android.graphics.drawable.Drawable import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.api.ExtensionApi import eu.kanade.tachiyomi.extension.api.ExtensionUpdateNotifier import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep @@ -49,7 +49,7 @@ class ExtensionManager( /** * API where all the available extensions can be found. */ - private val api = ExtensionGithubApi() + private val api = ExtensionApi() /** * The installer which installs, updates and uninstalls the extensions. @@ -258,7 +258,6 @@ class ExtensionManager( val untrustedSignatures = _untrustedExtensionsFlow.value.map { it.signatureHash }.toSet() if (signature !in untrustedSignatures) return - ExtensionLoader.trustedSignatures += signature preferences.trustedSignatures() += signature val nowTrustedExtensions = _untrustedExtensionsFlow.value.filter { it.signatureHash == signature } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt similarity index 74% rename from app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt rename to app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt index e9421fed9..e885652b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context +import eu.kanade.domain.source.interactor.OFFICIAL_REPO_BASE_URL +import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult @@ -20,10 +22,11 @@ import uy.kohesive.injekt.injectLazy import java.time.Instant import kotlin.time.Duration.Companion.days -internal class ExtensionGithubApi { +internal class ExtensionApi { private val networkService: NetworkHelper by injectLazy() private val preferenceStore: PreferenceStore by injectLazy() + private val sourcePreferences: SourcePreferences by injectLazy() private val extensionManager: ExtensionManager by injectLazy() private val json: Json by injectLazy() @@ -31,39 +34,16 @@ internal class ExtensionGithubApi { preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0) } - private var requiresFallbackSource = false - suspend fun findExtensions(): List { return withIOContext { - val githubResponse = if (requiresFallbackSource) { - null - } else { - try { - networkService.client - .newCall(GET("${REPO_URL_PREFIX}index.min.json")) - .awaitSuccess() - } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } - requiresFallbackSource = true - null - } - } - - val response = githubResponse ?: run { - networkService.client - .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")) - .awaitSuccess() - } - - val extensions = with(json) { - response - .parseAs>() - .toExtensions() + val extensions = buildList { + addAll(getExtensions(OFFICIAL_REPO_BASE_URL, true)) + sourcePreferences.extensionRepos().get().map { addAll(getExtensions(it, false)) } } // Sanity check - a small number of extensions probably means something broke // with the repo generator - if (extensions.size < 100) { + if (extensions.size < 50) { throw Exception() } @@ -71,6 +51,26 @@ internal class ExtensionGithubApi { } } + private suspend fun getExtensions( + repoBaseUrl: String, + isOfficialRepo: Boolean, + ): List { + return try { + val response = networkService.client + .newCall(GET("$repoBaseUrl/index.min.json")) + .awaitSuccess() + + with(json) { + response + .parseAs>() + .toExtensions(repoBaseUrl, isRepoSource = !isOfficialRepo) + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" } + emptyList() + } + } + suspend fun checkForUpdates( context: Context, fromAvailableExtensionList: Boolean = false, @@ -111,7 +111,10 @@ internal class ExtensionGithubApi { return extensionsWithUpdate } - private fun List.toExtensions(): List { + private fun List.toExtensions( + repoUrl: String, + isRepoSource: Boolean, + ): List { return this .filter { val libVersion = it.extractLibVersion() @@ -126,25 +129,17 @@ internal class ExtensionGithubApi { libVersion = it.extractLibVersion(), lang = it.lang, isNsfw = it.nsfw == 1, - hasReadme = it.hasReadme == 1, - hasChangelog = it.hasChangelog == 1, sources = it.sources?.map(extensionSourceMapper).orEmpty(), apkName = it.apk, - iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png", + iconUrl = "$repoUrl/icon/${it.pkg}.png", + repoUrl = repoUrl, + isRepoSource = isRepoSource, ) } } fun getApkUrl(extension: Extension.Available): String { - return "${getUrlPrefix()}apk/${extension.apkName}" - } - - private fun getUrlPrefix(): String { - return if (requiresFallbackSource) { - FALLBACK_REPO_URL_PREFIX - } else { - REPO_URL_PREFIX - } + return "${extension.repoUrl}/apk/${extension.apkName}" } private fun ExtensionJsonObject.extractLibVersion(): Double { @@ -152,9 +147,6 @@ internal class ExtensionGithubApi { } } -private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/" -private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/" - @Serializable private data class ExtensionJsonObject( val name: String, @@ -164,8 +156,6 @@ private data class ExtensionJsonObject( val code: Long, val version: String, val nsfw: Int, - val hasReadme: Int = 0, - val hasChangelog: Int = 0, val sources: List?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index e7eab29b6..4dbf09a26 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -13,8 +13,6 @@ sealed class Extension { abstract val libVersion: Double abstract val lang: String? abstract val isNsfw: Boolean - abstract val hasReadme: Boolean - abstract val hasChangelog: Boolean data class Installed( override val name: String, @@ -24,8 +22,6 @@ sealed class Extension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, - override val hasReadme: Boolean, - override val hasChangelog: Boolean, val pkgFactory: String?, val sources: List, val icon: Drawable?, @@ -33,6 +29,8 @@ sealed class Extension { val isObsolete: Boolean = false, val isUnofficial: Boolean = false, val isShared: Boolean, + val repoUrl: String? = null, + val isRepoSource: Boolean = false, ) : Extension() data class Available( @@ -43,11 +41,11 @@ sealed class Extension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, - override val hasReadme: Boolean, - override val hasChangelog: Boolean, val sources: List, val apkName: String, val iconUrl: String, + val repoUrl: String, + val isRepoSource: Boolean, ) : Extension() { data class Source( @@ -75,7 +73,5 @@ sealed class Extension { val signatureHash: String, override val lang: String? = null, override val isNsfw: Boolean = false, - override val hasReadme: Boolean = false, - override val hasChangelog: Boolean = false, ) : Extension() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index eccf66cd6..a01ee5cb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo +import eu.kanade.tachiyomi.util.system.isDevFlavor import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking @@ -62,11 +63,6 @@ internal object ExtensionLoader { // inorichi's key private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" - /** - * List of the trusted signatures. - */ - var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get() - private const val PRIVATE_EXTENSION_EXTENSION = "ext" private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts") @@ -123,6 +119,12 @@ internal object ExtensionLoader { * @param context The application context. */ fun loadExtensions(context: Context): List { + // Always make users trust unknown extensions on cold starts in non-dev builds + // due to inherent security risks + if (!isDevFlavor) { + preferences.trustedSignatures().delete() + } + val pkgManager = context.packageManager val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -329,8 +331,6 @@ internal object ExtensionLoader { libVersion = libVersion, lang = lang, isNsfw = isNsfw, - hasReadme = hasReadme, - hasChangelog = hasChangelog, sources = sources, pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), isUnofficial = !isOfficiallySigned(signatures), @@ -394,6 +394,11 @@ internal object ExtensionLoader { } private fun hasTrustedSignature(signatures: List): Boolean { + if (officialSignature in signatures) { + return true + } + + val trustedSignatures = preferences.trustedSignatures().get() return trustedSignatures.any { signatures.contains(it) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt index 1797dc89d..79af92328 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt @@ -37,7 +37,6 @@ data class ExtensionDetailsScreen( state = state, onClickSourcePreferences = { navigator.push(SourcePreferencesScreen(it)) }, onClickWhatsNew = { uriHandler.openUri(screenModel.getChangelogUrl()) }, - onClickReadme = { uriHandler.openUri(screenModel.getReadmeUrl()) }, onClickEnableAll = { screenModel.toggleSources(true) }, onClickDisableAll = { screenModel.toggleSources(false) }, onClickClearCookies = screenModel::clearCookies, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt index a0ed5495b..c6e821bbd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt @@ -31,8 +31,6 @@ import uy.kohesive.injekt.api.get private const val URL_EXTENSION_COMMITS = "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master" -private const val URL_EXTENSION_BLOB = - "https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master" class ExtensionDetailsScreenModel( pkgName: String, @@ -93,26 +91,11 @@ class ExtensionDetailsScreenModel( val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") val pkgFactory = extension.pkgFactory - if (extension.hasChangelog) { - return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md") - } // Falling back on GitHub commit history because there is no explicit changelog in extension return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory) } - fun getReadmeUrl(): String { - val extension = state.value.extension ?: return "" - - if (!extension.hasReadme) { - return "https://tachiyomi.org/docs/faq/browse/extensions" - } - - val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") - val pkgFactory = extension.pkgFactory - return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md") - } - fun clearCookies() { val extension = state.value.extension ?: return diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index 4d0c49404..4b0835850 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -24,6 +24,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -265,7 +267,10 @@ class BrowseSourceScreenModel( else -> { val preselectedIds = getCategories.await(manga.id).map { it.id } setDialog( - Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }), + Dialog.ChangeMangaCategory( + manga, + categories.mapAsCheckboxState { it.id in preselectedIds }.toImmutableList(), + ), ) } } @@ -338,7 +343,7 @@ class BrowseSourceScreenModel( data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog data class ChangeMangaCategory( val manga: Manga, - val initialSelection: List>, + val initialSelection: ImmutableList>, ) : Dialog data class Migrate(val newManga: Manga) : Dialog } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt index bc9fbd4a5..20ebdba0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.util.fastMap import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -15,6 +16,7 @@ import eu.kanade.presentation.category.components.CategoryRenameDialog import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.util.system.toast +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.collectLatest import tachiyomi.presentation.core.screens.LoadingScreen @@ -52,22 +54,22 @@ class CategoryScreen : Screen() { CategoryCreateDialog( onDismissRequest = screenModel::dismissDialog, onCreate = screenModel::createCategory, - categories = successState.categories, + categories = successState.categories.fastMap { it.name }.toImmutableList(), ) } is CategoryDialog.Rename -> { CategoryRenameDialog( onDismissRequest = screenModel::dismissDialog, onRename = { screenModel.renameCategory(dialog.category, it) }, - categories = successState.categories, - category = dialog.category, + categories = successState.categories.fastMap { it.name }.toImmutableList(), + category = dialog.category.name, ) } is CategoryDialog.Delete -> { CategoryDeleteDialog( onDismissRequest = screenModel::dismissDialog, onDelete = { screenModel.deleteCategory(dialog.category.id) }, - category = dialog.category, + category = dialog.category.name, ) } is CategoryDialog.SortAlphabetically -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt index 7e61985d5..ff2cb7075 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt @@ -1,11 +1,8 @@ package eu.kanade.tachiyomi.ui.home -import androidx.activity.compose.PredictiveBackHandler +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith @@ -26,20 +23,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.util.lerp import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.LocalTabNavigator @@ -59,7 +49,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import soup.compose.material.motion.MotionConstants import soup.compose.material.motion.animation.materialFadeThroughIn import soup.compose.material.motion.animation.materialFadeThroughOut import tachiyomi.domain.library.service.LibraryPreferences @@ -70,7 +59,6 @@ import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.pluralStringResource import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import kotlin.coroutines.cancellation.CancellationException object HomeScreen : Screen() { @@ -92,8 +80,6 @@ object HomeScreen : Screen() { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow - var scale by remember { mutableFloatStateOf(1f) } - TabNavigator( tab = LibraryTab, key = TabNavigatorKey, @@ -132,11 +118,6 @@ object HomeScreen : Screen() { ) { contentPadding -> Box( modifier = Modifier - .graphicsLayer { - scaleX = scale - scaleY = scale - transformOrigin = TransformOrigin(0.5f, 1f) - } .padding(contentPadding) .consumeWindowInsets(contentPadding), ) { @@ -157,30 +138,10 @@ object HomeScreen : Screen() { } val goToLibraryTab = { tabNavigator.current = LibraryTab } - - var handlingBack by remember { mutableStateOf(false) } - PredictiveBackHandler(enabled = handlingBack || tabNavigator.current != LibraryTab) { progress -> - handlingBack = true - val currentTab = tabNavigator.current - try { - progress.collect { backEvent -> - scale = lerp(1f, 0.92f, LinearOutSlowInEasing.transform(backEvent.progress)) - tabNavigator.current = if (backEvent.progress > 0.25f) tabs[0] else currentTab - } - goToLibraryTab() - } catch (e: CancellationException) { - tabNavigator.current = currentTab - } finally { - animate( - initialValue = scale, - targetValue = 1f, - animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration), - ) { value, _ -> - scale = value - } - handlingBack = false - } - } + BackHandler( + enabled = tabNavigator.current != LibraryTab, + onBack = goToLibraryTab, + ) LaunchedEffect(Unit) { launch { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index a19753a84..105f1e2b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -28,9 +28,11 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -661,13 +663,15 @@ class LibraryScreenModel( val common = getCommonCategories(mangaList) // Get indexes of the mix categories to preselect. val mix = getMixCategories(mangaList) - val preselected = categories.map { - when (it) { - in common -> CheckboxState.State.Checked(it) - in mix -> CheckboxState.TriState.Exclude(it) - else -> CheckboxState.State.None(it) + val preselected = categories + .map { + when (it) { + in common -> CheckboxState.State.Checked(it) + in mix -> CheckboxState.TriState.Exclude(it) + else -> CheckboxState.State.None(it) + } } - } + .toImmutableList() mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) } } } @@ -683,7 +687,10 @@ class LibraryScreenModel( sealed interface Dialog { data object SettingsSheet : Dialog - data class ChangeCategory(val manga: List, val initialSelection: List>) : Dialog + data class ChangeCategory( + val manga: List, + val initialSelection: ImmutableList>, + ) : Dialog data class DeleteManga(val manga: List) : Dialog } 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 7184388a8..3ad313c4f 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 @@ -11,6 +11,7 @@ import android.os.Bundle import android.view.View import androidx.activity.ComponentActivity import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -64,7 +65,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.updater.AppUpdateChecker import eu.kanade.tachiyomi.data.updater.RELEASE_URL -import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.api.ExtensionApi import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen @@ -222,13 +223,14 @@ class MainActivity : BaseActivity() { contentWindowInsets = scaffoldInsets, ) { contentPadding -> // Consume insets already used by app state banners - // Shows current screen - DefaultNavigatorScreenTransition( - navigator = navigator, + Box( modifier = Modifier .padding(contentPadding) .consumeWindowInsets(contentPadding), - ) + ) { + // Shows current screen + DefaultNavigatorScreenTransition(navigator = navigator) + } } // Pop source-related screens when incognito mode is turned off @@ -335,7 +337,7 @@ class MainActivity : BaseActivity() { // Extensions updates LaunchedEffect(Unit) { try { - ExtensionGithubApi().checkForUpdates(context) + ExtensionApi().checkForUpdates(context) } catch (e: Exception) { logcat(LogPriority.ERROR, e) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index f4c7c48b6..b73b4bcc3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -104,7 +104,7 @@ class MangaScreen( MangaScreen( state = successState, snackbarHostState = screenModel.snackbarHostState, - fetchInterval = successState.manga.fetchInterval, + nextUpdate = successState.manga.expectedNextUpdate, isTabletUi = isTabletUi(), chapterSwipeStartAction = screenModel.chapterSwipeStartAction, chapterSwipeEndAction = screenModel.chapterSwipeEndAction, @@ -146,7 +146,7 @@ class MangaScreen( onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, onEditCategoryClicked = screenModel::showChangeCategoryDialog.takeIf { successState.manga.favorite }, onEditFetchIntervalClicked = screenModel::showSetFetchIntervalDialog.takeIf { - screenModel.isUpdateIntervalEnabled && successState.manga.favorite + successState.manga.favorite }, onMigrateClicked = { navigator.push(MigrateSearchScreen(successState.manga.id)) @@ -243,9 +243,10 @@ class MangaScreen( is MangaScreenModel.Dialog.SetFetchInterval -> { SetIntervalDialog( interval = dialog.manga.fetchInterval, - nextUpdate = dialog.manga.nextUpdate, + nextUpdate = dialog.manga.expectedNextUpdate, onDismissRequest = onDismissRequest, - onValueChanged = { screenModel.setFetchInterval(dialog.manga, it) }, + onValueChanged = { interval: Int -> screenModel.setFetchInterval(dialog.manga, interval) } + .takeIf { screenModel.isUpdateIntervalEnabled }, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index aa85aec5a..e7446e600 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.shouldDownloadNewChapters +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.catch @@ -360,7 +362,7 @@ class MangaScreenModel( successState.copy( dialog = Dialog.ChangeCategory( manga = manga, - initialSelection = categories.mapAsCheckboxState { it.id in selection }, + initialSelection = categories.mapAsCheckboxState { it.id in selection }.toImmutableList(), ), ) } @@ -992,7 +994,10 @@ class MangaScreenModel( // Track sheet - end sealed interface Dialog { - data class ChangeCategory(val manga: Manga, val initialSelection: List>) : Dialog + data class ChangeCategory( + val manga: Manga, + val initialSelection: ImmutableList>, + ) : Dialog data class DeleteChapters(val chapters: List) : Dialog data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog data class SetFetchInterval(val manga: Manga) : Dialog 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 37116eeef..789d26e3e 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 @@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import logcat.LogPriority import tachiyomi.core.preference.toggle +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchNonCancellable import tachiyomi.core.util.lang.withIOContext @@ -85,6 +86,7 @@ class ReaderViewModel @JvmOverloads constructor( private val sourceManager: SourceManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), private val downloadProvider: DownloadProvider = Injekt.get(), + private val tempFileManager: UniFileTempFileManager = Injekt.get(), private val imageSaver: ImageSaver = Injekt.get(), preferences: BasePreferences = Injekt.get(), val readerPreferences: ReaderPreferences = Injekt.get(), @@ -269,7 +271,7 @@ class ReaderViewModel @JvmOverloads constructor( val context = Injekt.get() val source = sourceManager.getOrStub(manga.source) - loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source) + loader = ChapterLoader(context, downloadManager, downloadProvider, tempFileManager, manga, source) loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id }) Result.success(true) @@ -904,6 +906,7 @@ class ReaderViewModel @JvmOverloads constructor( private fun deletePendingChapters() { viewModelScope.launchNonCancellable { downloadManager.deletePendingChapters() + tempFileManager.deleteTempFiles() } } 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 6a31ed029..b8f97c5f4 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 @@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import tachiyomi.core.i18n.stringResource -import tachiyomi.core.storage.toTempFile +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.system.logcat import tachiyomi.domain.manga.model.Manga @@ -24,6 +24,7 @@ class ChapterLoader( private val context: Context, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, + private val tempFileManager: UniFileTempFileManager, private val manga: Manga, private val source: Source, ) { @@ -85,17 +86,24 @@ class ChapterLoader( skipCache = true, ) return when { - isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider) + isDownloaded -> DownloadPageLoader( + chapter, + manga, + source, + downloadManager, + downloadProvider, + tempFileManager, + ) source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { is Format.Directory -> DirectoryPageLoader(format.file) - is Format.Zip -> ZipPageLoader(format.file.toTempFile(context)) + is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file)) is Format.Rar -> try { - RarPageLoader(format.file.toTempFile(context)) + RarPageLoader(tempFileManager.createTempFile(format.file)) } catch (e: UnsupportedRarV5Exception) { error(context.stringResource(MR.strings.loader_rar5_error)) } - is Format.Epub -> EpubPageLoader(format.file.toTempFile(context)) + is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file)) } } source is HttpSource -> HttpPageLoader(chapter, source) 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 3d385551d..775b493b3 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,7 +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 tachiyomi.core.storage.toTempFile +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.domain.manga.model.Manga import uy.kohesive.injekt.injectLazy @@ -23,6 +23,7 @@ internal class DownloadPageLoader( private val source: Source, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, + private val tempFileManager: UniFileTempFileManager, ) : PageLoader() { private val context: Application by injectLazy() @@ -46,8 +47,8 @@ internal class DownloadPageLoader( zipPageLoader?.recycle() } - private suspend fun getPagesFromArchive(chapterPath: UniFile): List { - val loader = ZipPageLoader(chapterPath.toTempFile(context)).also { zipPageLoader = it } + private suspend fun getPagesFromArchive(file: UniFile): List { + val loader = ZipPageLoader(tempFileManager.createTempFile(file)).also { zipPageLoader = it } return loader.getPages() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt index 3ba5f6dec..3d396ace9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt @@ -40,7 +40,6 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsMainScreen }, - onBackPressed = null, content = { val pop: () -> Unit = { if (it.canPop) { @@ -62,7 +61,6 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsAppearanceScreen }, - onBackPressed = null, ) { val insets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) TwoPanelBox( diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt index 7e8651111..60d357a53 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt @@ -4,11 +4,9 @@ package eu.kanade.tachiyomi.util.view import android.content.res.Resources import android.graphics.Rect -import android.os.Build import android.view.Gravity import android.view.Menu import android.view.MenuItem -import android.view.RoundedCorner import android.view.View import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -97,22 +95,3 @@ fun View?.isVisibleOnScreen(): Boolean { Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels) return actualPosition.intersect(screen) } - -/** - * Returns window radius (in pixel) applied to this view - */ -fun View.getWindowRadius(): Int { - val rad = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val windowInsets = rootWindowInsets - listOfNotNull( - windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT), - windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT), - windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT), - windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT), - ) - .minOfOrNull { it.radius } - } else { - null - } - return rad ?: 0 -} diff --git a/core/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt b/core/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt index 95f448f13..e6995448b 100644 --- a/core/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt @@ -128,14 +128,13 @@ fun OkHttpClient.Builder.dohQuad101() = dns( /* * Mullvad DoH * without ad blocking option - * Source : https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/ + * Source: https://mullvad.net/en/help/dns-over-https-and-dns-over-tls */ fun OkHttpClient.Builder.dohMullvad() = dns( DnsOverHttps.Builder().client(build()) - .url("https://doh.mullvad.net/dns-query".toHttpUrl()) + .url(" https://dns.mullvad.net/dns-query".toHttpUrl()) .bootstrapDnsHosts( InetAddress.getByName("194.242.2.2"), - InetAddress.getByName("193.19.108.2"), InetAddress.getByName("2a07:e340::2"), ) .build(), @@ -144,7 +143,7 @@ fun OkHttpClient.Builder.dohMullvad() = dns( /* * Control D * unfiltered option - * Source : https://controld.com/free-dns/? + * Source: https://controld.com/free-dns/? */ fun OkHttpClient.Builder.dohControlD() = dns( DnsOverHttps.Builder().client(build()) diff --git a/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt b/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt index 8e2bf43fc..afe60ed35 100644 --- a/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt +++ b/core/src/main/java/tachiyomi/core/storage/UniFileExtensions.kt @@ -1,11 +1,6 @@ package tachiyomi.core.storage -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.extension: String? get() = name?.substringAfterLast('.') @@ -15,27 +10,3 @@ val UniFile.nameWithoutExtension: String? val UniFile.displayablePath: String get() = filePath ?: uri.toString() - -fun UniFile.toTempFile(context: Context): File { - val inputStream = context.contentResolver.openInputStream(uri)!! - val tempFile = File.createTempFile( - nameWithoutExtension.orEmpty().padEnd(3), // Prefix must be 3+ chars - 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 -} diff --git a/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt b/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt new file mode 100644 index 000000000..0aa9f4b85 --- /dev/null +++ b/core/src/main/java/tachiyomi/core/storage/UniFileTempFileManager.kt @@ -0,0 +1,46 @@ +package tachiyomi.core.storage + +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 + +class UniFileTempFileManager( + private val context: Context, +) { + + private val dir = File(context.externalCacheDir, "tmp") + + fun createTempFile(file: UniFile): File { + dir.mkdirs() + + val inputStream = context.contentResolver.openInputStream(file.uri)!! + val tempFile = File.createTempFile( + file.nameWithoutExtension.orEmpty().padEnd(3), // Prefix must be 3+ chars + null, + dir, + ) + + 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 deleteTempFiles() { + dir.deleteRecursively() + } +} diff --git a/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt b/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt index f694355a4..2b99c29bf 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt @@ -1,8 +1,10 @@ package tachiyomi.domain.manga.model +import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.UpdateStrategy import tachiyomi.core.preference.TriState import java.io.Serializable +import java.time.Instant data class Manga( val id: Long, @@ -29,6 +31,11 @@ data class Manga( val favoriteModifiedAt: Long?, ) : Serializable { + val expectedNextUpdate: Instant? + get() = nextUpdate + .takeIf { status != SManga.COMPLETED.toLong() } + ?.let { Instant.ofEpochMilli(it) } + val sorting: Long get() = chapterFlags and CHAPTER_SORTING_MASK diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index 52dfafde6..1465a7c75 100644 --- a/gradle/androidx.versions.toml +++ b/gradle/androidx.versions.toml @@ -1,5 +1,5 @@ [versions] -agp_version = "8.2.0" +agp_version = "8.2.1" lifecycle_version = "2.6.2" paging_version = "3.2.1" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cea75f29..7eadf8c43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] aboutlib_version = "10.10.0" acra = "5.11.3" -leakcanary = "2.12" +leakcanary = "2.13" moko = "0.23.0" okhttp_version = "5.0.0-alpha.12" -richtext = "0.17.0" +richtext = "0.20.0" shizuku_version = "12.2.0" sqldelight = "2.0.0" sqlite = "2.4.0" diff --git a/i18n/src/commonMain/resources/MR/base/plurals.xml b/i18n/src/commonMain/resources/MR/base/plurals.xml index 21b544618..2f10b004c 100644 --- a/i18n/src/commonMain/resources/MR/base/plurals.xml +++ b/i18n/src/commonMain/resources/MR/base/plurals.xml @@ -80,4 +80,9 @@ Extension update available %d extension updates available + + + %d repo + %d repos + diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index e6cbf7c3f..ad6858899 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -164,7 +164,6 @@ Forward Refresh Start downloading now - FAQ and Guides Not now @@ -281,12 +280,12 @@ When charging Restrictions: %s - Skip updating entries - With unread chapter(s) - With \"Completed\" status - That haven\'t been started + Smart update + Skip entries with unread chapter(s) + Skip entries with \"Completed\" status + Skip unstarted entries + Predict next release time Show unread count on Updates icon - Outside expected release period Automatically refresh metadata Check for new cover and details when updating library @@ -323,7 +322,7 @@ Uninstall App info Untrusted extension - This extension was signed with an untrusted certificate and wasn\'t activated.\n\nA malicious extension could read any stored login credentials or execute arbitrary code.\n\nBy trusting this certificate you accept these risks. + This extension was signed by any unknown author and wasn\'t loaded.\n\nMalicious extensions can read any stored login credentials or execute arbitrary code.\n\nBy trusting this extension\'s certificate, you accept these risks. This extension is no longer available. It may not function properly and can cause issues with the app. Uninstalling it is recommended. This extension is not from the official list. Failed to get extensions list @@ -342,6 +341,18 @@ Shizuku is not running Install and start Shizuku to use Shizuku as extension installer. + + Extension repos + You have no repos set. + Add repo + Repo URL + Add additional repos to Tachiyomi. This should be a URL that ends with \"index.min.json\". + This repo already exists! + Delete repo + Invalid repo URL + Do you wish to delete the repo \"%s\"? + This extension is from an external repo. Tap to view the repo. + Fullscreen Show tap zones overlay @@ -708,9 +719,10 @@ Chapter %1$s Estimate every Set to update every + Next update - Next update expected in around %s - Customize interval + Next update expected in around %1$s, checking around every %2$s + Custom update frequency: Downloading (%1$d/%2$d) Error Paused diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt index 7e770fb43..d36e2593f 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt @@ -1,11 +1,7 @@ package tachiyomi.presentation.core.components -import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animate +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.AnchoredDraggableState @@ -30,7 +26,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember @@ -39,11 +34,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -53,14 +45,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch -import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt +private val sheetAnimationSpec = tween(durationMillis = 350) + @Composable fun AdaptiveSheet( isTabletUi: Boolean, @@ -99,11 +91,6 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier - .predictiveBackAnimation( - enabled = remember { derivedStateOf { alpha > 0f } }.value, - transformOrigin = TransformOrigin.Center, - onBack = internalOnDismissRequest, - ) .requiredWidthIn(max = 460.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -116,6 +103,7 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, tonalElevation = tonalElevation, content = { + BackHandler(enabled = alpha > 0f, onBack = internalOnDismissRequest) content() }, ) @@ -157,11 +145,6 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier - .predictiveBackAnimation( - enabled = anchoredDraggableState.targetValue == 0, - transformOrigin = TransformOrigin(0.5f, 1f), - onBack = internalOnDismissRequest, - ) .widthIn(max = 460.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -201,6 +184,10 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, tonalElevation = tonalElevation, content = { + BackHandler( + enabled = anchoredDraggableState.targetValue == 0, + onBack = internalOnDismissRequest, + ) content() }, ) @@ -270,37 +257,3 @@ private fun AnchoredDraggableState.preUpPostDownNestedScrollConnection() @JvmName("offsetToFloat") private fun Offset.toFloat(): Float = this.y } - -private fun Modifier.predictiveBackAnimation( - enabled: Boolean, - transformOrigin: TransformOrigin, - onBack: () -> Unit, -) = composed { - var scale by remember { mutableFloatStateOf(1f) } - PredictiveBackHandler(enabled = enabled) { progress -> - try { - progress.collect { backEvent -> - scale = lerp(1f, 0.85f, LinearOutSlowInEasing.transform(backEvent.progress)) - } - // Completion - onBack() - } catch (e: CancellationException) { - // Cancellation - } finally { - animate( - initialValue = scale, - targetValue = 1f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - ) { value, _ -> - scale = value - } - } - } - Modifier.graphicsLayer { - this.scaleX = scale - this.scaleY = scale - this.transformOrigin = transformOrigin - } -} - -private val sheetAnimationSpec = tween(durationMillis = 350) diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt index 6177e7746..d92f93900 100644 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt @@ -24,9 +24,9 @@ import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.copyFromComicInfo import tachiyomi.core.metadata.comicinfo.getComicInfo import tachiyomi.core.metadata.tachiyomi.MangaDetails +import tachiyomi.core.storage.UniFileTempFileManager import tachiyomi.core.storage.extension import tachiyomi.core.storage.nameWithoutExtension -import tachiyomi.core.storage.toTempFile import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.logcat @@ -56,6 +56,7 @@ actual class LocalSource( private val json: Json by injectLazy() private val xml: XML by injectLazy() + private val tempFileManager: UniFileTempFileManager by injectLazy() private val POPULAR_FILTERS = FilterList(OrderBy.Popular(context)) private val LATEST_FILTERS = FilterList(OrderBy.Latest(context)) @@ -213,7 +214,7 @@ actual class LocalSource( for (chapter in chapterArchives) { when (Format.valueOf(chapter)) { is Format.Zip -> { - ZipFile(chapter.toTempFile(context)).use { zip: ZipFile -> + ZipFile(tempFileManager.createTempFile(chapter)).use { zip: ZipFile -> zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile -> zip.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) @@ -222,7 +223,7 @@ actual class LocalSource( } } is Format.Rar -> { - JunrarArchive(chapter.toTempFile(context)).use { rar -> + JunrarArchive(tempFileManager.createTempFile(chapter)).use { rar -> rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile -> rar.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) @@ -272,7 +273,7 @@ actual class LocalSource( val format = Format.valueOf(chapterFile) if (format is Format.Epub) { - EpubFile(format.file.toTempFile(context)).use { epub -> + EpubFile(tempFileManager.createTempFile(format.file)).use { epub -> epub.fillMetadata(manga, this) } } @@ -331,7 +332,7 @@ actual class LocalSource( entry?.let { coverManager.update(manga, it.openInputStream()) } } is Format.Zip -> { - ZipFile(format.file.toTempFile(context)).use { zip -> + ZipFile(tempFileManager.createTempFile(format.file)).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) } } @@ -340,7 +341,7 @@ actual class LocalSource( } } is Format.Rar -> { - JunrarArchive(format.file.toTempFile(context)).use { archive -> + JunrarArchive(tempFileManager.createTempFile(format.file)).use { archive -> val entry = archive.fileHeaders .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } @@ -349,7 +350,7 @@ actual class LocalSource( } } is Format.Epub -> { - EpubFile(format.file.toTempFile(context)).use { epub -> + EpubFile(tempFileManager.createTempFile(format.file)).use { epub -> val entry = epub.getImagesFromPages() .firstOrNull() ?.let { epub.getEntry(it) }