Merge branch 'master' into sync-part-final

This commit is contained in:
KaiserBh 2024-01-06 16:35:24 +11:00 committed by GitHub
commit 2e5686372a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 887 additions and 756 deletions

View File

@ -22,7 +22,7 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi"
versionCode = 113
versionCode = 114
versionName = "0.14.7"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View File

@ -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()) }
}
}

View File

@ -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()

View File

@ -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
}
}

View File

@ -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<List<String>> {
return preferences.extensionRepos().changes()
.map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
}
}

View File

@ -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())

View File

@ -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)

View File

@ -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<Category>,
categories: ImmutableList<String>,
) {
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: Category,
categories: ImmutableList<String>,
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<CheckboxState<Category>>,
initialSelection: ImmutableList<CheckboxState<Category>>,
onDismissRequest: () -> Unit,
onEditCategories: () -> Unit,
onConfirm: (List<Long>, List<Long>) -> 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<Category>.anyWithName(name: String): Boolean {
return any { name == it.name }
}

View File

@ -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) {

View File

@ -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,

View File

@ -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 = {

View File

@ -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,37 +65,50 @@ 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) {
return@remember if (nextUpdate != null) {
val now = Instant.now()
val nextUpdateInstant = Instant.ofEpochMilli(nextUpdate)
now.until(nextUpdateInstant, ChronoUnit.DAYS)
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))
}
// 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,
@ -112,6 +131,7 @@ fun SetIntervalDialog(
)
}
}
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
@ -120,7 +140,7 @@ fun SetIntervalDialog(
},
confirmButton = {
TextButton(onClick = {
onValueChanged(selectedInterval)
onValueChanged?.invoke(selectedInterval)
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))

View File

@ -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(
title = if (nextUpdateDays != null) {
pluralStringResource(
MR.plurals.day,
count = fetchInterval.absoluteValue,
fetchInterval.absoluteValue,
),
count = nextUpdateDays,
nextUpdateDays,
)
} else {
stringResource(MR.strings.not_applicable)
},
icon = Icons.Default.HourglassEmpty,
color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = onEditIntervalClicked,
onClick = { onEditIntervalClicked?.invoke() },
)
}
MangaActionButton(
title = if (trackingCount == 0) {
stringResource(MR.strings.manga_tracking_tab)

View File

@ -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),

View File

@ -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<Preference> {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
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(

View File

@ -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),

View File

@ -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)
}
}
}
}
}

View File

@ -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>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = 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<String>,
val dialog: RepoDialog? = null,
) : RepoScreenState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}

View File

@ -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<String>,
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)
}
}
}
}

View File

@ -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<String>,
) {
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))
},
)
}

View File

@ -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,
)
}
}

View File

@ -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)
}
}
fun DefaultNavigatorScreenTransition(navigator: Navigator) {
val slideDistance = rememberSlideDistance()
val screenContent = remember {
movableContentOf<Screen> { 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 {
ScreenTransition(
navigator = navigator,
transition = {
materialSharedAxisX(
forward = forward,
forward = navigator.lastEvent != StackEvent.Pop,
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<Float>(
durationMillis = MotionConstants.DefaultMotionDuration,
easing = FastOutSlowInEasing,
)
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

View File

@ -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
}

View File

@ -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) }

View File

@ -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 }

View File

@ -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<Extension.Available> {
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<List<ExtensionJsonObject>>()
.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<Extension.Available> {
return try {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
.awaitSuccess()
with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.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<ExtensionJsonObject>.toExtensions(): List<Extension.Available> {
private fun List<ExtensionJsonObject>.toExtensions(
repoUrl: String,
isRepoSource: Boolean,
): List<Extension.Available> {
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<ExtensionSourceJsonObject>?,
)

View File

@ -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<Source>,
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<Source>,
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()
}

View File

@ -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<LoadResult> {
// 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<String>): Boolean {
if (officialSignature in signatures) {
return true
}
val trustedSignatures = preferences.trustedSignatures().get()
return trustedSignatures.any { signatures.contains(it) }
}

View File

@ -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,

View File

@ -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

View File

@ -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<CheckboxState.State<Category>>,
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog
data class Migrate(val newManga: Manga) : Dialog
}

View File

@ -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 -> {

View File

@ -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 {

View File

@ -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 {
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<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog
data class ChangeCategory(
val manga: List<Manga>,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog
data class DeleteManga(val manga: List<Manga>) : Dialog
}

View File

@ -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)
}

View File

@ -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 },
)
}
}

View File

@ -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<CheckboxState<Category>>) : Dialog
data class ChangeCategory(
val manga: Manga,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog
data class DeleteChapters(val chapters: List<Chapter>) : Dialog
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
data class SetFetchInterval(val manga: Manga) : Dialog

View File

@ -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<Application>()
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()
}
}

View File

@ -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)

View File

@ -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<ReaderPage> {
val loader = ZipPageLoader(chapterPath.toTempFile(context)).also { zipPageLoader = it }
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
val loader = ZipPageLoader(tempFileManager.createTempFile(file)).also { zipPageLoader = it }
return loader.getPages()
}

View File

@ -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(

View File

@ -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
}

View File

@ -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())

View File

@ -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
}

View File

@ -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()
}
}

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -80,4 +80,9 @@
<item quantity="one">Extension update available</item>
<item quantity="other">%d extension updates available</item>
</plurals>
<plurals name="num_repos">
<item quantity="one">%d repo</item>
<item quantity="other">%d repos</item>
</plurals>
</resources>

View File

@ -164,7 +164,6 @@
<string name="action_webview_forward">Forward</string>
<string name="action_webview_refresh">Refresh</string>
<string name="action_start_downloading_now">Start downloading now</string>
<string name="action_faq_and_guides">FAQ and Guides</string>
<string name="action_not_now">Not now</string>
<!-- Operations -->
@ -281,12 +280,12 @@
<string name="charging">When charging</string>
<string name="restrictions">Restrictions: %s</string>
<string name="pref_library_update_manga_restriction">Skip updating entries</string>
<string name="pref_update_only_completely_read">With unread chapter(s)</string>
<string name="pref_update_only_non_completed">With \"Completed\" status</string>
<string name="pref_update_only_started">That haven\'t been started</string>
<string name="pref_library_update_smart_update">Smart update</string>
<string name="pref_update_only_completely_read">Skip entries with unread chapter(s)</string>
<string name="pref_update_only_non_completed">Skip entries with \"Completed\" status</string>
<string name="pref_update_only_started">Skip unstarted entries</string>
<string name="pref_update_only_in_release_period">Predict next release time</string>
<string name="pref_library_update_show_tab_badge">Show unread count on Updates icon</string>
<string name="pref_update_only_in_release_period">Outside expected release period</string>
<string name="pref_library_update_refresh_metadata">Automatically refresh metadata</string>
<string name="pref_library_update_refresh_metadata_summary">Check for new cover and details when updating library</string>
@ -323,7 +322,7 @@
<string name="ext_uninstall">Uninstall</string>
<string name="ext_app_info">App info</string>
<string name="untrusted_extension">Untrusted extension</string>
<string name="untrusted_extension_message">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.</string>
<string name="untrusted_extension_message">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.</string>
<string name="obsolete_extension_message">This extension is no longer available. It may not function properly and can cause issues with the app. Uninstalling it is recommended.</string>
<string name="unofficial_extension_message">This extension is not from the official list.</string>
<string name="extension_api_error">Failed to get extensions list</string>
@ -342,6 +341,18 @@
<string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
<string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
<!-- Extension repos -->
<string name="label_extension_repos">Extension repos</string>
<string name="information_empty_repos">You have no repos set.</string>
<string name="action_add_repo">Add repo</string>
<string name="label_add_repo_input">Repo URL</string>
<string name="action_add_repo_message">Add additional repos to Tachiyomi. This should be a URL that ends with \"index.min.json\".</string>
<string name="error_repo_exists">This repo already exists!</string>
<string name="action_delete_repo">Delete repo</string>
<string name="invalid_repo_name">Invalid repo URL</string>
<string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
<string name="repo_extension_message">This extension is from an external repo. Tap to view the repo.</string>
<!-- Reader section -->
<string name="pref_fullscreen">Fullscreen</string>
<string name="pref_show_navigation_mode">Show tap zones overlay</string>
@ -708,9 +719,10 @@
<string name="display_mode_chapter">Chapter %1$s</string>
<string name="manga_display_interval_title">Estimate every</string>
<string name="manga_display_modified_interval_title">Set to update every</string>
<string name="manga_interval_header">Next update</string>
<!-- "... around 2 days" -->
<string name="manga_interval_expected_update">Next update expected in around %s</string>
<string name="manga_modify_calculated_interval_title">Customize interval</string>
<string name="manga_interval_expected_update">Next update expected in around %1$s, checking around every %2$s</string>
<string name="manga_interval_custom_amount">Custom update frequency:</string>
<string name="chapter_downloading_progress">Downloading (%1$d/%2$d)</string>
<string name="chapter_error">Error</string>
<string name="chapter_paused">Paused</string>

View File

@ -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<Float>(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 <T> AnchoredDraggableState<T>.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<Float>(durationMillis = 350)

View File

@ -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) }